{
    "dataType": "CVE_RECORD",
    "dataVersion": "5.2",
    "cveMetadata": {
        "cveId": "CVE-2026-110910",
        "assignerOrgId": "7ffcee3d-2c14-4c3e-b844-86c6a321a158",
        "state": "PUBLISHED",
        "assignerShortName": "harborist",
        "dateReserved": "2026-10-15T03:01:58.840Z",
        "datePublished": "2026-10-27T06:42:42.365Z",
        "dateUpdated": "2026-10-27T06:43:13.416Z"
    },
    "containers": {
        "cna": {
            "providerMetadata": {
                "orgId": "7ffcee3d-2c14-4c3e-b844-86c6a321a158",
                "shortName": "harborist",
                "dateUpdated": "2026-10-27T06:43:13.416Z"
            },
            "title": "qs.stringify with allowEmptyArrays does not encode the keys of empty arrays, allowing query string parameter injection",
            "problemTypes": [
                {
                    "descriptions": [
                        {
                            "lang": "en",
                            "cweId": "CWE-116",
                            "description": "CWE-116 Improper Encoding or Escaping of Output",
                            "type": "CWE"
                        }
                    ]
                }
            ],
            "affected": [
                {
                    "vendor": "ljharb",
                    "product": "qs",
                    "collectionURL": "https://npmjs.com/qs",
                    "packageName": "qs",
                    "repo": "https://github.com/ljharb/qs",
                    "versions": [
                        {
                            "status": "affected",
                            "version": "6.12.0",
                            "lessThan": "6.17.0",
                            "versionType": "semver"
                        }
                    ],
                    "defaultStatus": "unaffected"
                }
            ],
            "descriptions": [
                {
                    "lang": "en",
                    "value": "### 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.",
                    "supportingMedia": [
                        {
                            "type": "text/html",
                            "base64": false,
                            "value": "<p>### Summary</p><p>`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 `&amp;` (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.</p><p>### Details</p><p>In `lib/stringify.js`, the `allowEmptyArrays` branch returns `adjustedPrefix + '[]'` early, before the code that passes every other key through `encoder` and `formatter`. So `{ \"x&amp;k=v\": [\"1\"] }` serializes as `x%26k%3Dv%5B0%5D=1`, but `{ \"x&amp;k=v\": [] }` serializes as `x&amp;k=v[]`. The early return applies at any depth (`{ \"a\": { \"b&amp;k=v\": [] } }` gives `a[b&amp;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.</p><p>#### PoC</p><p>```js</p><p>var qs = require(\"qs\");</p><p>var query = qs.stringify({ q: \"hi\", \"x&amp;apiKey=evil&amp;y\": [] }, { allowEmptyArrays: true });</p><p>// \"q=hi&amp;x&amp;apiKey=evil&amp;y[]\"</p><p>qs.parse(query);</p><p>// { q: \"hi\", x: \"\", apiKey: \"evil\", y: [\"\"] }</p><p>qs.stringify({ \"x#\": [], z: \"1\" }, { allowEmptyArrays: true });</p><p>// \"x#[]&amp;z=1\": in a URL, everything after \"#\" becomes the fragment</p><p>```</p><p>#### Fix</p><p>Applied 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&amp;apiKey=evil&amp;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).</p><p>### Affected versions</p><p>`&gt;=6.12.0 &lt;6.17.0`, fixed in v6.17.0.</p><p>`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.</p><p>### Impact</p><p>An 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.</p>"
                        }
                    ]
                }
            ],
            "references": [
                {
                    "url": "https://github.com/ljharb/qs/security/advisories/GHSA-w4cr-vjpx-p82q",
                    "tags": [
                        "vendor-advisory"
                    ]
                },
                {
                    "url": "https://github.com/ljharb/qs/commit/234d5051f5071efaa025a7ca9e4d34a56a460a1b",
                    "tags": [
                        "patch"
                    ]
                },
                {
                    "url": "https://github.com/ljharb/qs/pull/561",
                    "tags": [
                        "patch"
                    ]
                },
                {
                    "url": "https://github.com/ljharb/qs/issues/626",
                    "tags": [
                        "issue-tracking"
                    ]
                }
            ],
            "metrics": [
                {
                    "format": "CVSS",
                    "scenarios": [
                        {
                            "lang": "en",
                            "value": "GENERAL"
                        }
                    ],
                    "cvssV3_1": {
                        "version": "3.1",
                        "attackVector": "NETWORK",
                        "attackComplexity": "HIGH",
                        "privilegesRequired": "NONE",
                        "userInteraction": "NONE",
                        "scope": "UNCHANGED",
                        "confidentialityImpact": "NONE",
                        "integrityImpact": "LOW",
                        "availabilityImpact": "NONE",
                        "baseSeverity": "LOW",
                        "baseScore": 3.7,
                        "vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N"
                    }
                },
                {
                    "format": "CVSS",
                    "scenarios": [
                        {
                            "lang": "en",
                            "value": "GENERAL"
                        }
                    ],
                    "cvssV4_0": {
                        "attackVector": "NETWORK",
                        "attackComplexity": "LOW",
                        "attackRequirements": "PRESENT",
                        "privilegesRequired": "NONE",
                        "userInteraction": "NONE",
                        "vulnConfidentialityImpact": "NONE",
                        "subConfidentialityImpact": "NONE",
                        "vulnIntegrityImpact": "LOW",
                        "subIntegrityImpact": "NONE",
                        "vulnAvailabilityImpact": "NONE",
                        "subAvailabilityImpact": "NONE",
                        "exploitMaturity": "NOT_DEFINED",
                        "Safety": "NOT_DEFINED",
                        "Automatable": "NOT_DEFINED",
                        "Recovery": "NOT_DEFINED",
                        "valueDensity": "NOT_DEFINED",
                        "vulnerabilityResponseEffort": "NOT_DEFINED",
                        "providerUrgency": "NOT_DEFINED",
                        "version": "4.0",
                        "baseSeverity": "MEDIUM",
                        "baseScore": 6.3,
                        "vectorString": "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"
                    }
                }
            ],
            "workarounds": [
                {
                    "lang": "en",
                    "value": "Leave `allowEmptyArrays` off (the default), or check key names that come from untrusted input against an allowlist before passing the object to `qs.stringify`.",
                    "supportingMedia": [
                        {
                            "type": "text/html",
                            "base64": false,
                            "value": "<p>Leave `allowEmptyArrays` off (the default), or check key names that come from untrusted input against an allowlist before passing the object to `qs.stringify`.</p>"
                        }
                    ]
                }
            ],
            "solutions": [
                {
                    "lang": "en",
                    "value": "Upgrade to qs 6.17.0 or later.",
                    "supportingMedia": [
                        {
                            "type": "text/html",
                            "base64": false,
                            "value": "<p>Upgrade to qs 6.17.0 or later.</p>"
                        }
                    ]
                }
            ],
            "credits": [
                {
                    "lang": "en",
                    "value": "DucMinhNe",
                    "type": "finder"
                },
                {
                    "lang": "en",
                    "value": "DucMinhNe",
                    "type": "remediation developer"
                },
                {
                    "lang": "en",
                    "value": "ljharb",
                    "type": "remediation reviewer"
                }
            ],
            "source": {
                "discovery": "EXTERNAL"
            }
        }
    }
}