{"id":"CVE-2026-110910","kind":"vulnerability","aliases":["CVE-2026-110910"],"related":[],"title":"qs: empty-array keys skip encoding, letting callers inject query parameters","description":"### Summary\n\n\n\n`qs.stringify` with `allowEmptyArrays: true` writes the key of an empty array without encoding it, even though keys are encoded by default. A key that contains `&` (or the configured `delimiter`), `=`, or `#` ∴ becomes query string syntax instead of data, so a caller-supplied key name can add parameters to the serialized query string, or cut it short.\n\n\n\n### Details\n\n\n\nIn `lib/stringify.js`, the `allowEmptyArrays` branch returns `adjustedPrefix + '[]'` early, before the code that passes every other key through `encoder` and `formatter`. So `{ \"x&k=v\": [\"1\"] }` serializes as `x%26k%3Dv%5B0%5D=1`, but `{ \"x&k=v\": [] }` serializes as `x&k=v[]`. The early return applies at any depth (`{ \"a\": { \"b&k=v\": [] } }` gives `a[b&k=v][]`), under every `arrayFormat`, with `allowDots`, and with a custom `encoder`, which is never called for that key. With `encode: false` or `encodeValuesOnly: true`, keys are intentionally left unencoded on every path; those configurations are not affected.\n\n\n\n#### PoC\n\n\n\n```js\n\n\n\nvar qs = require(\"qs\");\n\n\n\nvar query = qs.stringify({ q: \"hi\", \"x&apiKey=evil&y\": [] }, { allowEmptyArrays: true });\n\n\n\n// \"q=hi&x&apiKey=evil&y[]\"\n\n\n\nqs.parse(query);\n\n\n\n// { q: \"hi\", x: \"\", apiKey: \"evil\", y: [\"\"] }\n\n\n\nqs.stringify({ \"x#\": [], z: \"1\" }, { allowEmptyArrays: true });\n\n\n\n// \"x#[]&z=1\": in a URL, everything after \"#\" becomes the fragment\n\n\n\n```\n\n\n\n#### Fix\n\n\n\nApplied in 234d505 on `main` and released as v6.17.0: the key of an empty array goes through `encoder` and `formatter` exactly as the key of any other value does, so `{ \"x&apiKey=evil&y\": [] }` now serializes as `x%26apiKey%3Devil%26y[]`. Keys that need no encoding serialize as before, with two exceptions that match every other path: the brackets of a nested key are now encoded (`a[b][]` becomes `a%5Bb%5D[]`, which `qs.parse` reads identically), and with `encodeDotInKeys`, a dot in the key becomes `%252E` instead of `%2E` (which `decodeDotInKeys` previously misread as nesting).\n\n\n\n### Affected versions\n\n\n\n`>=6.12.0 <6.17.0`, fixed in v6.17.0.\n\n\n\n`allowEmptyArrays` was introduced in f22b2bc and first shipped in v6.12.0, with this early return. Every release from v6.12.0 through v6.16.1 is affected. Earlier versions have no `allowEmptyArrays` option and are not affected.\n\n\n\n### Impact\n\n\n\nAn application that passes an object whose key names come from untrusted input to `qs.stringify` with `allowEmptyArrays: true` (for example, to forward a parsed JSON body or user-selected fields to an upstream service) can have parameters injected into the resulting query string. The injected parameters are not keys of the object, so deleting or overwriting a key on the object (such as setting a trusted `apiKey` after copying the user's fields) does not remove them; whether the attacker's value or the application's wins depends on how the receiving side handles a duplicate parameter. A `#` in such a key ends the query string early when the result is placed in a URL. `allowEmptyArrays` is off by default, and only keys whose value (after any `filter`) is an empty array are affected.","summary":"The qs library's stringify function, when allowEmptyArrays is true, writes the key of an empty array without URL-encoding it. A key containing &, =, or # therefore turns into query-string syntax, so attacker-chosen key names can add parameters or truncate the query.","severity":"medium","cvss":6.3,"epss":null,"exploited":false,"exploited_at":null,"products":[],"ecosystems":[],"tags":["javascript","node.js","package","qs","self-hosted"],"not_affected_if":["allowemptyarrays-off","default-config","qs-before-6.12.0","trusted-keys-only"],"how_to_check":"- Run `npm ls qs` to see which qs version is installed and where it is pulled in.\n- Search the code for `allowEmptyArrays` passed to `qs.stringify`, especially where object keys come from user input.\n- Check whether that code forwards the serialized query string to an upstream service.","fix":"- Upgrade qs to `6.17.0` or later.\n- Until upgraded, stop passing user-controlled keys through `qs.stringify` with `allowEmptyArrays: true`, or turn that option off.","status":"open","published_at":"2026-10-27T07:16:32+00:00","first_seen_at":"2026-10-27T07:30:44+00:00","triaged_at":"2026-10-27T07:31:57+00:00","updated_at":"2026-10-27T07:31:57+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-27T07:31:57Z","model":"claude-haiku-5-5","output":{"fix":"- Upgrade qs to `6.17.0` or later.\n- Until upgraded, stop passing user-controlled keys through `qs.stringify` with `allowEmptyArrays: true`, or turn that option off.","reach":"network","title":"qs: empty-array keys skip encoding, letting callers inject query parameters","impact":"medium","summary":"The qs library's stringify function, when allowEmptyArrays is true, writes the key of an empty array without URL-encoding it. A key containing &, =, or # therefore turns into query-string syntax, so attacker-chosen key names can add parameters or truncate the query.","category":"package","nicknames":[],"who_cares":["node.js","qs","javascript","self-hosted"],"confidence":0.8,"needs_auth":"unknown","worst_case":"Attacker adds parameters to a forwarded query string, overriding trusted values.","analyst_note":"","how_to_check":"- Run `npm ls qs` to see which qs version is installed and where it is pulled in.\n- Search the code for `allowEmptyArrays` passed to `qs.stringify`, especially where object keys come from user input.\n- Check whether that code forwards the serialized query string to an upstream service.","needs_analyst":false,"not_affected_if":["allowEmptyArrays-off","default-config","trusted-keys-only","qs-before-6.12.0"],"who_is_affected":"Only applications that set allowEmptyArrays to true (it is off by default) and build the stringified object from keys an attacker can name, such as a parsed JSON body or user-selected fields forwarded to an upstream service. Affected releases run from 6.12.0 through 6.16.1. Applications that leave the option off, or that only stringify keys they control, can skip it.","user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.00076,"input_tokens":4436,"output_tokens":633,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-110910':15A,18A '-2026':14A,17A '1':154C,162C,258C,263C '234d505':275C '252e':358C '26apikey':311C '26k':158C '26y':313C '2e':361C '3devil':312C '3dv':159C '5b0':160C '5bb':343C '5d':161C,344C '6.12.0':370C '6.17.0':371C 'add':66B,118C 'adjustedprefix':136C 'affect':223C,368C,396C,406C,542C 'allowdot':192C 'allowemptyarray':33B,75C,133C,239C,259C,375C,401C,424C,524C 'apikey':236C,244C,252C,304C,475C 'appli':175C,273C 'applic':409C,493C 'array':4A,42B,83C,287C,540C 'arrayformat':190C 'attack':61B,488C 'attacker-chosen':60B 'b':180C,185C,340C 'becom':103C,269C,341C,357C 'bodi':433C 'bracket':331C 'branch':134C 'call':201C 'caller':9A,113C 'caller-suppli':112C 'chosen':62B 'code':140C 'come':417C 'configur':100C,220C 'contain':50B,97C 'copi':477C 'custom':196C 'cut':126C 'cve':13A,16A 'data':109C 'decodedotinkey':363C 'default':93C,528C 'delet':462C 'delimit':101C 'depend':496C 'depth':178C 'detail':129C 'dot':353C 'duplic':504C 'earli':137C,173C,387C,515C 'earlier':397C 'empti':3A,41B,82C,286C,539C 'empty-array':2A 'encod':7A,46B,85C,91C,147C,197C,206C,290C,318C,338C 'encodedotinkey':351C 'encodevalueson':209C 'end':511C 'even':87C 'everi':143C,189C,217C,327C,389C 'everyth':267C 'evil':237C,245C,253C,305C 'exact':293C 'exampl':427C 'except':324C 'f22b2bc':379C 'fals':207C 'field':438C,481C 'filter':536C 'first':381C 'fix':272C,372C 'formatt':149C,292C 'forward':429C 'fragment':271C 'function':31B 'give':183C 'goe':288C 'handl':502C 'hi':234C,242C,250C 'host':25B 'ident':348C 'impact':407C 'inject':10A,446C,453C 'input':420C 'instead':107C,359C 'intent':213C 'introduc':377C 'javascript':19B 'js':225C 'json':432C 'k':152C,165C,170C,181C,186C 'key':5A,38B,49B,63B,79C,89C,95C,115C,145C,204C,211C,283C,296C,314C,335C,356C,415C,457C,466C,510C,531C 'left':214C 'let':8A 'lib/stringify.js':131C 'librari':28B 'main':277C 'match':326C 'misread':365C 'name':64B,116C,416C 'need':316C 'nest':334C,367C 'never':200C 'node.js':20B 'object':413C,460C,469C 'option':402C 'overwrit':464C 'packag':21B 'paramet':12A,67B,119C,445C,454C,505C 'pars':431C 'pass':142C,411C 'path':218C,329C 'place':520C 'poc':224C 'previous':364C 'q':233C,241C,249C 'qs':1A,22B,27B,227C,229C 'qs.parse':247C,346C 'qs.stringify':73C,232C,255C,422C 'queri':11A,56B,71B,104C,123C,231C,248C,450C,513C 'query-str':55B 'read':347C 'receiv':500C 'releas':279C,390C 'remov':484C 'requir':228C 'result':449C,518C 'return':135C,174C,388C 'select':437C 'self':24B 'self-host':23B 'serial':122C,155C,167C,308C,319C 'servic':442C 'set':472C 'ship':382C 'short':128C 'side':501C 'skip':6A 'string':57B,105C,124C,451C,514C 'stringifi':30B 'summari':72C 'suppli':114C 'syntax':58B,106C 'therefor':52B 'though':88C 'true':35B,76C,210C,240C,260C,425C 'truncat':69B 'trust':474C 'turn':53B 'two':323C 'unencod':215C 'untrust':419C 'upstream':441C 'url':45B,266C,523C 'url-encod':44B 'user':436C,479C 'user-select':435C 'v':153C,166C,171C,182C,187C 'v6.12.0':384C,392C 'v6.16.1':394C 'v6.17.0':281C,374C 'valu':300C,490C,533C 'var':226C,230C 'version':369C,398C 'whether':486C 'whose':414C,532C 'win':495C 'without':43B,84C 'write':36B,77C 'x':151C,157C,164C,169C,235C,243C,251C,256C,261C,303C,310C 'y':238C,246C,254C,306C 'z':257C,262C","nicknames":[],"mitigations":[],"who_affected":"Only applications that set allowEmptyArrays to true (it is off by default) and build the stringified object from keys an attacker can name, such as a parsed JSON body or user-selected fields forwarded to an upstream service. Affected releases run from 6.12.0 through 6.16.1. Applications that leave the option off, or that only stringify keys they control, can skip it.","tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-27T07:16:32+00:00","kev":null,"cvss_vector":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","cvss_by":"7ffcee3d-2c14-4c3e-b844-86c6a321a158","poc_count":0,"sources":[{"source":"nvd","external_id":"CVE-2026-110910","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-110910","severity":null,"published_at":"2026-10-27T07:16:32+00:00","modified_at":"2026-10-27T07:16:32+00:00","first_seen_at":"2026-10-27T07:30:44+00:00","fetched_at":"2026-10-27T07:30:44+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}