The npm qs package is safe to use today as long as you are on 6.10.3 or a patched backport, but the version that ships transitively under older Express and body-parser builds carried a prototype pollution flaw that let an unauthenticated request hang your Node process. If you have ever run an Express app, you have almost certainly run qs without choosing it — it is the default query-string parser Express reaches for, so it lands in your dependency tree whether or not it appears in your own package.json.
That transitive, invisible presence is exactly why the qs npm package deserves a real look rather than a shrug. It is small, it is everywhere, and it processes attacker-controlled input on the very first hop of most HTTP requests.
What is the qs package and where does it come from?
qs is a query-string parsing and stringifying library maintained by Jordan Harband. It handles nested objects and arrays in URL query strings — turning a[b][c]=d into a real JavaScript object — which is more than the built-in querystring module or URLSearchParams will do for you. That nesting support is the reason frameworks adopted it and also the reason it has a security history.
You rarely install qs directly. It arrives through express, body-parser, and dozens of HTTP clients. Run this to see where it actually lives in your tree:
npm ls qs
You will often find several copies at different versions, because different parents pin different ranges. Each one is a separate thing to check.
The CVE-2022-24999 prototype pollution flaw
The headline issue is CVE-2022-24999, a prototype pollution vulnerability in every qs version before 6.10.3. The parser did not properly guard the __proto__ key, so a crafted query string could reach Object.prototype and, in the documented denial-of-service path, hang the Node event loop. The advisory's example payload looks like this:
?a[__proto__]=b&a[__proto__]&a[length]=100000000
An attacker does not need authentication or a foothold — the payload rides in a normal URL query string, which is precisely the input qs is built to parse. Prototype pollution is dangerous beyond the hang, too: once you can write to Object.prototype, downstream code that reads unexpected properties can be tricked into logic errors or, in the worst cases, code execution paths, depending on what your app does with parsed objects.
Which versions are actually fixed?
The fix landed in 6.10.3 and, because so many projects were stuck on older major lines, the maintainer backported it. Patched versions are 6.2.4, 6.3.3, 6.4.1, 6.5.3, 6.6.1, 6.7.3, 6.8.3, 6.9.7, and 6.10.3. Anything at or above one of those on its major line is clear of CVE-2022-24999.
If npm ls qs shows an old copy, the cleanest fix is usually to bump the parent. For a nested transitive that refuses to move, an override forces resolution:
{
"overrides": {
"qs": "^6.10.3"
}
}
Older qs releases also had denial-of-service issues tied to how it expanded sparse arrays — a large index like a[100000000]=x could force excessive memory allocation. Staying current closes those alongside the prototype pollution fix, which is the practical reason not to pin qs to an ancient version for the sake of "stability."
How to use qs safely in your own code
Version currency is necessary but not sufficient. qs gives you parsing controls that limit blast radius regardless of CVE status, and you should set them explicitly:
const qs = require('qs');
const parsed = qs.parse(req.url.split('?')[1], {
depth: 5, // cap nesting; default is 5, but be explicit
parameterLimit: 100, // reject query strings with too many keys
arrayLimit: 20, // stop huge sparse arrays becoming huge objects
allowPrototypes: false, // never write prototype keys (this is the default)
});
allowPrototypes defaults to false on modern versions, and you should never flip it to true on input you do not control. The depth, parameterLimit, and arrayLimit caps turn an unbounded parse into a bounded one, which is the core defense against parser denial-of-service in general — not just in qs.
If your app never uses nested query syntax, consider whether you need qs at all. URLSearchParams is built into Node and the browser, has no nesting, and therefore no nesting-based attack surface. Fewer features is a legitimate security choice.
Finding the vulnerable qs copy you did not install
The hard part of the qs npm package is not fixing the version you chose — it is the one three layers down that you never chose. Version-based tooling that walks your full lockfile will surface every qs copy, its version, and which parent dragged it in. An SCA tool such as Safeguard can flag a vulnerable transitive qs and tell you whether a straight parent bump or an override is the right remediation, which saves the manual npm ls archaeology.
Whatever tool you use, wire the check into CI so a fresh npm install cannot silently reintroduce an old qs through a parent that loosened its range. A one-time audit ages out fast in a dependency tree this active. Our software composition analysis overview walks through how transitive detection differs from a plain npm audit, and the npm security best practices guide covers the broader install-time risks that sit alongside parser flaws like this one.
FAQ
Is qs npm still maintained?
Yes. qs is actively maintained, receives regular releases, and the security backports for CVE-2022-24999 across eight older version lines are strong evidence the maintainer takes downstream stability seriously. It is not an abandonment risk.
Do I need qs if I use Express 5?
You still get qs transitively — Express uses it for its extended query parser. You can switch Express to the simple parser with app.set('query parser', 'simple') if you do not need nested query objects, which sidesteps qs for query parsing entirely.
How do I check which qs version I have?
Run npm ls qs from your project root. It prints every copy in the tree with its resolved version and parent. Compare each against the patched list (6.10.3 and the backports) to confirm none predate the CVE-2022-24999 fix.
Is prototype pollution in qs still exploitable?
Not on patched versions. The parser now blocks __proto__ and prototype keys by default. The risk only returns if you run an old qs copy or deliberately set allowPrototypes: true on untrusted input.