GitHub Advisoryadvisory · npm
GHSA-6pq8-jhm2-2vcw qs : qs.stringify with arrayFormat 'comma' throws an uncaught TypeError on array elements carrying a non-callable own toString (DoS)
MEDIUM
Official page ↗
- Aliases
- CVE-2026-110816
- Ecosystem
- npm
- Packages
- qs
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
- Weaknesses
- CWE-248, CWE-704
- Reviewed
- 2026-10-27
Affected packages
• npm qs: >= 6.7.0, < 6.16.1 → fixed in 6.16.1
Description
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: } ] }`, 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 serialization must not a…
Related
Alias · CVE (1)
- CVE-2026-110816 ### Summary `qs.stringify` with `arrayFormat: 'comma'` throws `TypeError: Cannot convert object to primitive value` when an array element… vulnerability