---
id: "CVE-2026-110816"
kind: "vulnerability"
aliases: ["CVE-2026-110816", "GHSA-6pq8-jhm2-2vcw"]
title: "qs : qs.stringify with arrayFormat 'comma' throws an uncaught TypeError on array elements carrying a non-callable own toString (DoS)"
severity: "medium"
cvss: 6.3
epss: 0.00368
exploited: false
status: "open"
category: "package"
products: [{"name": "qs", "fixed": ["6.16.1"], "affected": ">= 6.7.0, <= 6.16.0", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-248", "cwe-704", "express", "node", "npm", "package", "qs", "self-hosted"]
not_affected_if: ["default-config", "static-export"]
published_at: "2026-10-14T22:17:23+00:00"
first_seen_at: "2026-10-27T16:15:15+00:00"
updated_at: "2026-10-27T16:33:08+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-6pq8-jhm2-2vcw", "first_seen_at": "2026-10-27T16:15:15+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-110816", "first_seen_at": "2026-10-27T16:33:08+00:00"}]
---

# qs : qs.stringify with arrayFormat 'comma' throws an uncaught TypeError on array elements carrying a non-callable own toString (DoS)

qs.stringify with arrayFormat 'comma' throws an uncaught TypeError when an array element is an object whose own toString is not callable. An attacker who can shape data the application later serializes this way can force a synchronous throw, which crashes a process that has no error boundary around the call.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:qs affected >= 6.7.0, <= 6.16.0 fixed 6.16.1

## How to check

- Run `npm ls qs` in the project and check whether the resolved version is between `6.7.0` and `6.16.0`.
- Search the code for `arrayFormat: 'comma'` or `arrayFormat:"comma"` passed to `stringify`, especially on data derived from request input.
- Check whether `allowPrototypes: true` or `plainObjects: true` is used when parsing untrusted input.

## Fix

- Upgrade `qs` to `6.16.1` or later.
- Until upgraded, avoid `arrayFormat: 'comma'` on untrusted data, or wrap `qs.stringify` calls in a synchronous try/catch.
- Stop parsing untrusted input with `allowPrototypes: true` or `plainObjects: true` where serialization follows.

## Timeline

- 2026-10-27T16:15:15+00:00 ghsa https://github.com/advisories/GHSA-6pq8-jhm2-2vcw
- 2026-10-27T16:33:08+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-110816

## From the sources

## Summary

`qs.stringify(obj, { arrayFormat: 'comma' })` throws an uncaught `TypeError: Cannot convert object to primitive value` when an array in `obj` contains an element that carries an own, non-callable `toString` property (for example `{ a: [ { toString: 'x' } ] }`). A remote, unauthenticated attacker who can influence an object that the application later re-serializes with the comma array format triggers the throw. In a service that lacks a synchronous error boundary around the serialization call, the uncaught exception crashes the worker process, producing a denial of service.

## Root Cause

When `arrayFormat: 'comma'` is selected, the comma branch collapses the whole array into a single value via `Array.prototype.join`, or, under `encodeValuesOnly: true`, maps each element through the value encoder before joining. Both paths perform an implicit string coercion (`ToPrimitive` with hint `"string"`) on each element. For an object whose own `toString` is a non-callable value, coercion skips the shadowed non-callable `toString`, falls through to `Object.prototype.valueOf` (which returns the object rather than a primitive), and throws. The comma branch runs this coercion before the per-element null and skip handling in the main loop, so nothing downstream can neutralize the hostile element, and the throw is never caught inside the library.

```text
lib/stringify.js  (comma-array branch)
  line 152-154: obj = utils.maybeMap(obj, function (v) { return v == null ? v : encoder(v); });  // encodeValuesOnly path
  line 156:     objKeys = [{ value: obj.length > 0 ? obj.join(',') || null : void undefined }];   // default comma path
lib/utils.js
  line 292:     string = String(str);   // value encoder coercion reached via the encodeValuesOnly path
```

## Impact

Prerequisites: the application calls `qs.stringify` with `arrayFormat: 'comma'` (a documented, supported option) on an object whose shape can be influenced by attacker input. Attacker-controllable sources include `qs.parse(input, { allowPrototypes: true })`, which retains a `toString` segment (Express 4's default query parser, Express 5's `extended` query parser, and body-parser's `extended` urlencoded parser all set it), `qs.parse(input, { plainObjects: true })`, whose null-prototype elements throw even without a `toString` key (eg, `a[][b]=c`), and `JSON.parse` of a request body, which needs no qs parse option at all. No authentication or user interaction is required. Demonstrated capability: a single request forces an uncaught synchronous `TypeError` in the serialization call. In a naive single-process Node service with no synchronous error boundary, this crashes the process and takes the service offline (availability impact). Under a framework such as Express that traps synchronous handler throws, the plausible outcome is a per-request 500 rather than a full outage; that lower-impact case is noted but not the worst credible supported deployment. There is no confidentiality or integrity impact, no memory unsafety, and no prototype pollution.

## Proof of Concept

`Install qs at the affected version (for example 6.16.0) and run the following script with Node.js. No network setup is required; it demonstrates the throw in isolation.`

```js
var qs = require('qs');

// Source A: qs.parse with allowPrototypes retains the hostile toString segment
qs.stringify(qs.parse('a[0][toString]=x', { allowPrototypes: true }), { arrayFormat: 'comma' });

// Source B: a JSON request body, no qs parse option needed
qs.stringify(JSON.parse('{"a":[{"toString":"x"}]}'), { arrayFormat: 'comma' });

// Also affected: the encodeValuesOnly sub-path
qs.stringify(JSON.parse('{"a":[{"toString":"x"}]}'), { arrayFormat: 'comma', encodeValuesOnly: true });
```

```text
A default-join (allowPrototypes) => THREW: TypeError: Cannot convert object to primitive value
B default-join (JSON.parse) => THREW: TypeError: Cannot convert object to primitive value
C encodeValuesOnly path => THREW: TypeError: Cannot convert object to primitive value
N2 callable toString => NO THROW; output="a=z"
N3 isBuffer vector (CVE-2026-82417) => NO THROW; output="a%5Bconstructor%5D%5BisBuffer%5D=x"
qs version: 6.16.0
```

## Attack Chain

1. **Exposure:** A remote, unauthenticated request reaches an endpoint whose handler eventually serializes attacker-influenced data with `qs.stringify(obj, { arrayFormat: 'comma' })`.
2. **Control:** The attacker supplies data that becomes `{ a: [ { toString: <non-callable value> } ] }`, either via `a[0][toString]=x` parsed with `allowPrototypes`, or via a JSON body `{"a":[{"toString":"x"}]}`.
3. **Path:** In `stringify`, the primitive/buffer guard at `lib/stringify.js:134` is false for an array, so control reaches the comma branch at `lib/stringify.js:149`.
4. **Guard:** On the `encodeValuesOnly` path, the closure `v == null ? v : encoder(v)` at `lib/stringify.js:152-154` passes only `null`/`undefined` through; a non-null object still reaches the encoder. The default path skips that closure entirely and reaches `obj.join(',')` at `lib/stringify.js:156` with no guard at all.
5. **Primitive:** The element is coerced to a string (`Array.prototype.join`, or `String(str)` at `lib/utils.js:292`). `ToPrimitive` skips the non-callable own `toString`, `valueOf` returns the object, and a `TypeError` is thrown.
6. **Result:** The uncaught synchronous exception propagates out of the serialization call, failing the request and, in a service without a synchronous error boundary, crashing the process (denial of service).

## Bypass Evidence

This is a different element type on the code path CVE-2026-8723 (GHSA-q8mj-m7cp-5q26) patched, plus the default comma path, which that fix never covered. CVE-2026-8723 addressed a `TypeError` crash on `null`/`undefined` entries in the comma plus `encodeValuesOnly` path. Its fix (commit 21f80b3) wraps the encoder in `function (v) { return v == null ? v : encoder(v); }`, so it only passes `null` and `undefined` through. A non-null element with a hostile own `toString` fails the `v == null` test, so the encoder still runs and still throws; the encodeValuesOnly case (case C above) throws on the exact fixed code. The default comma path (`encodeValuesOnly` unset, the library default) never entered that fix at all: `git log -S "obj.join(','" -- lib/stringify.js` shows no security fix has ever touched the `obj.join(',')` line, and cases A and B throw with `encodeValuesOnly` unset. The related isBuffer hardening for CVE-2026-82417 (commit e83d321) is a different sink at the `lib/stringify.js:134` guard, which is never entered for the array element; the negative control `{a:{constructor:{isBuffer:'x'}}}` serializes cleanly. Disproof attempts that failed: a `valueOf`-only object does not throw (it serializes to `[object Object]`), confirming the trigger is an element with no callable `toString` (an own non-callable `toString`, or a null-prototype object with no `toString` at all, as `qs.parse` produces under `plainObjects`); nested arrays (`{"a":[[{"toString":"x"}]]}`) throw the same way; a callable `toString` serializes cleanly; and the default (non-comma) array format serializes cleanly, confirming the comma format is required. All positive and negative cases were reproduced on the exact published 6.16.0 artifact.

## Affected Versions

- `Ecosystem: npm`
- `Package: qs`
- `Confirmed affected range: >= 6.7.0, <= 6.16.0`
- `Latest release checked: 6.16.0 (npm latest; git tag v6.16.0)`
- `Fix status: fixed in 6.16.1 (a952e75)`

The `arrayFormat: 'comma'` join sink was introduced in qs 6.7.0, so 6.7.0 is the lower boundary; releases before 6.7.0 lack the sink. No released version fixes this vector, and the latest published release (6.16.0) reproduces the throw, so the closed upper bound `<= 6.16.0` covers every affected release. The `encodeValuesOnly` sub-path was added later, but the default comma-join vector already affects 6.7.0, so 6.7.0 governs the lower boundary.

## Suggested Fix

The comma array 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
