TL;DR
npm audit started reporting a moderate qs denial of service in more or less every Express app in late August. You did not install qs, you cannot remove it, and the advisory says it crashes worker processes. We reproduced CVE-2026-82417 end to end. Parsing a request never throws. The crash needs a second step most apps do not do, and on Express 5 it is a 500 rather than an outage. On Express 4 inside an async handler, the process exits.
If you run an Express app and ran npm audit any time after 29 August, you probably saw this:
qs 2.2.5 - 6.15.3
Severity: moderate
qs.stringify throws TypeError on objects with a non-callable constructor.isBuffer
fix available via npm audit fix
Two things make this one annoying. qs is not in your package.json, so there is nothing obvious to change. And the affected range covers nine years of releases, which makes it look like you have been exposed the whole time.
We wanted a real answer rather than a severity score, so we installed the vulnerable version and attacked it.
Where qs comes from
You did not add it. Express did.
We read the published manifests on 2026-09-25. express@5.2.1 declares qs: ^6.14.0 as a direct dependency, and also declares body-parser: ^2.2.1. body-parser@2.3.0 in turn declares qs: ^6.15.2. So a plain Express install carries qs twice over, and npm audit reports it against a package you have never heard of.
The bug, in one line
qs.stringify decides whether a value is a Buffer by calling obj.constructor.isBuffer(obj). It checks that the property is truthy. It never checks that it is a function.
Give it a constructor.isBuffer that is a string, and the call throws.
What we actually ran
Here is the first thing worth knowing, because it kills most of the panic. We ran the published payload against qs@6.15.3, the last vulnerable release, with default options:
const qs = require('qs');
qs.parse('x[constructor]isBuffer=y'); // -> {}
Empty object. The poisoned key is stripped before it can reach anything. With default settings the bug is unreachable.
Now the same payload with allowPrototypes: true:
const parsed = qs.parse('x[constructor]isBuffer=y', { allowPrototypes: true }); // -> { x: { constructor: { isBuffer: 'y' } } }
qs.stringify(parsed); // TypeError: obj.constructor.isBuffer is not a function
That is the vulnerability, reproduced. And it matters because of where that option comes from.
You do not set allowPrototypes yourself. body-parser sets it for you. We pulled the body-parser@2.3.0 tarball and read lib/types/urlencoded.js, lines 99 to 100: every express.urlencoded() call passes allowPrototypes: true into qs.parse. If your app accepts form posts, the precondition is already met.
The part the advisory does not tell you
Parsing does not throw. We checked that specifically: qs.parse with the poisoned payload returns happily, every time, on every version we tried.
The TypeError only fires when something serializes that object back into a query string. Most Express apps never do. The ones that do are doing something specific: rebuilding a query string for an upstream API call, constructing a redirect URL from user input, or canonicalising parameters before signing them.
So the real question is not "is qs in my tree." It is "does anything in my app call qs.stringify on data that came from a request."
What happens when it does fire
We built a minimal Express app with express.urlencoded({ extended: true }) and a handler that round-trips req.body through qs.stringify, then fired one unauthenticated POST at it. Same payload every time, on qs@6.15.3.
| Stack | Handler | Result | Process |
|---|---|---|---|
| express 5.2.1 | sync | HTTP 500 | survives |
| express 5.2.1 | async | HTTP 500 | survives |
| express 4.22.3 | sync | HTTP 500 | survives |
| express 4.22.3 | async | no response | exits |
Three of those four are a bad request, not an outage. The error reaches Express's error boundary, the client gets a 500, and the next request is served normally. We confirmed that by sending a clean request afterwards and getting a 200 back.
The fourth is a different thing entirely.
On Express 4, a throw inside an async handler becomes an unhandled promise rejection. Express 4 predates automatic promise handling in the router, so nothing catches it. Node terminates the process. In our test the app exited and every subsequent request failed, from a single unauthenticated POST with a 26-character body.
Express 5 fixed this class of problem by handling rejected promises in the router. That fix is doing real work here, and it is the reason the same bug is a nuisance on one major version and an outage on the other.
Why this lands on AI-built apps
Two defaults collide.
Ask any coding assistant for an Express route and you will almost always get app.post('/thing', async (req, res) => {...}). The async keyword goes in whether or not anything is awaited, because that is the modern shape and it is what the training data is full of. Separately, a great deal of scaffolded Express code is still Express 4, because that is what the majority of tutorials, templates and Stack Overflow answers use.
Express 4 plus async handlers is not an exotic combination. It is the default output.
Check your own app
npm ls qs
Anything at 6.16.0 or above is patched. If you see 6.15.3 or lower, keep going.
grep -rn "qs.stringify|stringify(req." --include=.js --include=.ts src/ app/ routes/ 2>/dev/null
No hits, and no library in your stack doing it for you, means you are carrying the advisory without a path to trigger it. That is worth knowing before you schedule an emergency upgrade.
npm ls express
Express 4 with async route handlers is the combination that turns a 500 into a dead process. This is worth fixing regardless of qs, because any throw in any async Express 4 handler behaves the same way.
The fix
qs@6.16.0 shipped on 2026-08-29, the same day as the advisory. It guards the call, so a non-callable isBuffer no longer throws. We verified it: the same payload round-trips cleanly and returns x%5Bconstructor%5D%5BisBuffer%5D=y.
npm update qs npm ls qs # confirm 6.16.0 or later
Express requests qs with a caret range, so a fresh npm install today already resolves to the patched version. The exposure lives in lockfiles, specifically ones resolved between 2026-06-24 (when 6.15.3 shipped) and 2026-08-29 (when 6.16.0 did). If your lockfile has not moved since the summer, you have the vulnerable copy pinned.
npm audit may list two qs advisories from this release. We tested the isBuffer one, CVE-2026-82417. The other covers array-limit handling and we did not reproduce it, so treat its impact as unverified here rather than taking our word for it. Both are fixed by the same upgrade.
The wider point
A moderate CVE in a transitive dependency is not automatically a fire, and it is not automatically noise either. Which one it is depends on facts about your app that no severity score can see: whether the vulnerable path is reachable, what calls it, and what your framework does with the exception.
Here the answer was three questions deep. Is qs in the tree (yes, always). Is the precondition set (yes, body-parser sets it). Is there a trigger (usually not, and if there is, it depends on your Express version whether you get a 500 or an outage).
We see the same pattern constantly in scans: the finding is real, the severity is accurate, and the actual risk to a specific app is somewhere between nothing and total, with nothing in the advisory to tell you which.
What is CVE-2026-82417?
A denial-of-service flaw in the npm package qs, disclosed 2026-08-29. qs.stringify calls obj.constructor.isBuffer(obj) after checking only that the property is truthy, never that it is callable. An attacker who gets a non-function isBuffer into a parsed object makes that call throw a TypeError. Affected versions are 2.2.5 through 6.15.3. Fixed in 6.16.0, published the same day.
I do not have qs in my package.json. Why is npm audit reporting it?
Because Express pulls it in. express 5.2.1 declares qs as a direct dependency and also depends on body-parser, which declares qs as well. You never installed it and you cannot remove it. That is what a transitive dependency is.
Does the default qs.parse leave me exposed?
No. We tested this on the vulnerable version. With default options, qs.parse strips the poisoned constructor key entirely and returns an empty object. The property only survives when allowPrototypes or plainObjects is turned on, and express.urlencoded turns allowPrototypes on for you.
Will this actually take my app down?
Only in one combination we could find. Parsing alone never throws. You need something that serializes the parsed object again with qs.stringify. On Express 5 that throw reaches the error handler and returns a 500 with the process still running. On Express 4 inside an async handler it becomes an unhandled rejection and the process exits.
I ran npm install this week. Am I affected?
Probably not, and by luck rather than judgement. Express asks for qs with a caret range, which now resolves to the patched 6.16.0. The exposure sits in lockfiles resolved between 2026-06-24 and 2026-08-29, when 6.15.3 was the newest release. Run npm ls qs and read the version.
Find the reachable ones
CheckYourVibe checks a deployed app for the issues that actually answer a stranger, not just the ones in your lockfile.