From ddd6065e9d9f42b66bf70e00e873de5f6285b788 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 10:21:58 +0900 Subject: [PATCH] Fix HTTP MCP auth token precedence (#3156) --- DEVELOPER_GUIDE.md | 34 ++++++---- SECURITY.md | 8 ++- USER_GUIDE.md | 24 ++++--- changelog.d/unreleased/3156.security.md | 20 ++++++ src/CodeIndex/Cli/ProgramRunner.cs | 65 +++++++++++++------ src/CodeIndex/Mcp/McpAuthentication.cs | 9 ++- .../CodeIndex.Tests/HttpMcpTransportTests.cs | 34 +++++++++- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 28 ++++++++ 8 files changed, 175 insertions(+), 47 deletions(-) create mode 100644 changelog.d/unreleased/3156.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 01629f2612..65f601b680 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1743,10 +1743,15 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into extracted but *before* dispatch. The default `LocalStdioAuthenticator` is permissive (matches the historical stdio behaviour and tags every caller as `stdio` / `local`). Setting `CDIDX_MCP_AUTH_TOKEN` swaps in - `TokenMcpAuthenticator`, which requires every responded request to - carry a matching `params.auth.token` and compares it in constant time - via `CryptographicOperations.FixedTimeEquals`. Failures uniformly - return JSON-RPC `-32001 "Unauthorized"` (per #1530 sanitization — the + `TokenMcpAuthenticator` for stdio, which requires every responded request + to carry a matching `params.auth.token` and compares it in constant time + via `CryptographicOperations.FixedTimeEquals`. HTTP does not also use this + body-token gate: `ProgramRunner` resolves a bearer secret for the HTTP + transport from `CDIDX_MCP_HTTP_TOKEN`, falling back to `CDIDX_MCP_AUTH_TOKEN` + when the HTTP-specific variable is unset, and then relies on the + `Authorization: Bearer ...` transport check (#3156). For the JSON-RPC + body-token gate, failures uniformly return JSON-RPC `-32001 "Unauthorized"` + (per #1530 sanitization — the wire never distinguishes missing-from-wrong), and `BuildAuthFailureLog` emits the detailed reason to stderr. Notifications (`notifications/initialized`, `notifications/cancelled`) short-circuit @@ -1828,9 +1833,11 @@ return `-32600`. The wildcard hosts `+` / `*` are rejected at parse time. - Optional shared-secret auth: when `CDIDX_MCP_HTTP_TOKEN` is set the listener requires `Authorization: Bearer ` on every request - and compares the token in constant time. The CLI refuses to bind to - a non-loopback host without a token to keep the MCP catalog off the - LAN by default. + and compares the token in constant time. If `CDIDX_MCP_HTTP_TOKEN` is unset, + HTTP falls back to `CDIDX_MCP_AUTH_TOKEN` as the bearer secret; when both + are set, `CDIDX_MCP_HTTP_TOKEN` wins. HTTP clients never need to also send + `params.auth.token`. The CLI refuses to bind to a non-loopback host without + either token to keep the MCP catalog off the LAN by default. - Optional request-loop logging: `ProgramRunner` connects `HttpMcpTransport` to `GlobalToolLog`, so persistent logging records one `mcp_http_request` line per HTTP request when the lifecycle log is enabled. The record includes @@ -1844,9 +1851,10 @@ return `-32600`. Wire selection happens in `ProgramRunner.RunMcp`: `--transport stdio|http` and `--http-listen ` are stripped -from the args before downstream parsing, the bearer token is read from -`CDIDX_MCP_HTTP_TOKEN`, and the dispatch lands in either the legacy -stdio path or `RunMcpHttp`. The pluggable seam keeps the JSON-RPC +from the args before downstream parsing, HTTP bearer-token resolution uses +`CDIDX_MCP_HTTP_TOKEN` first and `CDIDX_MCP_AUTH_TOKEN` as a fallback, and +the dispatch lands in either the legacy stdio path or `RunMcpHttp`. The +pluggable seam keeps the JSON-RPC ordering invariant identical across both transports, so the existing McpServer test surface (which exercises `ProcessLineAsync`) continues to cover the per-method behavior, while `HttpMcpTransportTests` cover @@ -3462,7 +3470,7 @@ sequenceDiagram - `initialize` レスポンスは `protocolVersion`、`capabilities`、`serverInfo.name`、`serverInfo.version`(`ConsoleUi.LoadVersion()` — `version.json` が源)、および AI クライアントにツール選択を案内する長い `instructions` 文字列を返す。レスポンスを書き終えた後、サーバーはセッションごとに 1 回だけ互換性用の `notifications/initialized` ready signal を送るため、サーバー側の ready signal を待つクライアントも optimistic polling なしで進める(#1780)。MCP は `notifications/initialized` を client-to-server 通知としても定義しており、cdidx はその方向も no-op として受理する。非 HTTP transport では、この互換性 signal が唯一の server-origin emission です。HTTP session は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` を設定した場合、opt-in の keep-alive notification も `/events` で受け取れる。HTTP transport では out-of-band 通知は接続済みの `/events` SSE stream にだけ配送され、POST のみのクライアントは initialize response だけを受け取り、別通知 frame は受け取らない。 - advertised capability には `tools`、`resources`、`prompts`、`logging` が含まれる。`logging` は MCP `notifications/message` を示し、`logging/setLevel` は `debug`、`info`、`notice`、`warning`、`error`、`critical`、`alert`、`emergency` を受け付ける。 - `protocolVersion` は**ハードコードではなく交渉**で決まる(#1554)。サーバーは `McpServer.SupportedProtocolVersions`(新しい順: `2025-03-26`, `2024-11-05`)を保持し、`initialize` パラメータからクライアント要求バージョンを読み取って、対応集合にあればそれを返し(合意)、未指定/非文字列なら既定の最新バージョンに fallback し、対応外なら `error.data` に `requestedVersion` と `supportedVersions` を入れた JSON-RPC `-32602` で拒否する。これにより将来 MCP 仕様が改訂されても、wire format が黙ってずれるのではなく actionable な handshake 失敗として表面化する。配列を新バージョンで更新する際は `ProtocolVersion` を先頭エントリと揃えて意図的に bump する。 -- **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、`CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。通知(`notifications/initialized`、`notifications/cancelled`)は応答もエラーコードも持たないため、ゲート *より前* で short-circuit する。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。 +- **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、stdio では `CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。HTTP はこの body token ゲートを重ねず、`ProgramRunner` が `CDIDX_MCP_HTTP_TOKEN` を優先し、未設定なら `CDIDX_MCP_AUTH_TOKEN` を fallback として bearer secret に解決して、`Authorization: Bearer ...` の transport check に一本化する(#3156)。JSON-RPC body token ゲートの失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。通知(`notifications/initialized`、`notifications/cancelled`)は応答もエラーコードも持たないため、ゲート *より前* で short-circuit する。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。 MCP は独立したシリアライズ戦略(オブジェクトを JSON などの転送形式に変換する方式のこと。CLI の `--json` 側は .NET 標準の `JsonSerializer` に任せる方式、MCP 側は `JsonObject` を手で組み立てる方式と、別の手段を採っている)を採るため、「そもそもバイナリは走るのか?」を確かめる最も頑健なスモークテスト(デプロイや起動直後に行う、基本動作だけを短時間で確認する簡易テストのこと。詳細な正しさではなく「煙が出ていないか=致命的に壊れていないか」を見るためこの名で呼ばれる)となる — .NET ホスト、`Program.Main`、CLI ルーティング、`ConsoleUi.LoadVersion()` に負荷をかけるが、SQLite には触れない(`search` など MCP の*ツール呼び出し*は SQLite に触れるが、`initialize` 単独では触れない)。 @@ -3482,11 +3490,11 @@ MCP は独立したシリアライズ戦略(オブジェクトを JSON など - HTTP POST 1 件 = JSON-RPC フレーム 1 件で、対応する応答は HTTP レスポンスのボディ(`200 OK` / `application/json; charset=utf-8`)に乗る。通知は `204 No Content`。`GET /events` は将来のサーバー→クライアント frame 用に独立した `text/event-stream` subscription を開く。サーバーは `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification が opt-in された場合を除き、自発的な frame を送信しない。長寿命の event stream は通常の POST リクエストを塞がない。`/` への POST 以外は `405 Method Not Allowed`。空 / 空白のみのボディは stdio の空行と同じ扱いで `204 No Content` を返し、ループは殺さない — クライアントの誤動作で junk フレームに引っかからないため。リクエスト本文は `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`(既定: 1,000,000 bytes、最大: 16,777,216 bytes)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64、最大: 1,024)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。正でない値や数値でない環境変数値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否する。 - SSE stream lifetime は active stream registry だけで表現し、その registry entry が削除された後に完了済み stream task を保持しない。 - `ResolveListenSpec("host:port")` は prefix を事前に解決するため、CLI が stderr に `Listening on http://...` を出せる。ポート `0` は一時 `TcpListener` を probe して空きポートを取得する。probe から `HttpListener.Start()` までの TOCTOU window は、本トランスポートが local-only / single-tenant 想定であるため許容する。ワイルドカードホスト `+` / `*` はパース時点で拒否する。 -- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer ` を要求し、定数時間で比較する。トークン未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。 +- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer ` を要求し、定数時間で比較する。`CDIDX_MCP_HTTP_TOKEN` が未設定なら HTTP は `CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` を優先する。HTTP クライアントが `params.auth.token` も送る必要はない。どちらのトークンも未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。 - 任意のリクエストループログ: `ProgramRunner` は `HttpMcpTransport` を `GlobalToolLog` に接続するため、lifecycle log が有効な場合は HTTP リクエストごとに `mcp_http_request` 行を 1 件記録する。記録内容は method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id で、リクエスト/レスポンス本文は含めない。 - キャンセルは `_listener.Stop()` に接続するため、シャットダウン時に `GetContextAsync()` が unblock する。`HttpListenerException` / `ObjectDisposedException` は EOS と同じ扱いで MCP ループを stdin クローズと同じ経路で終了させる。 -ワイヤー選択は `ProgramRunner.RunMcp` で行う。`--transport stdio|http` と `--http-listen ` は下流の引数解析より前に取り除かれ、bearer token は `CDIDX_MCP_HTTP_TOKEN` から読み、ディスパッチは旧来の stdio 経路または `RunMcpHttp` に着地する。プラガブルなシームは JSON-RPC 順序不変条件を両トランスポートで同一に保つので、既存の McpServer テスト群(`ProcessLineAsync` を叩く)は引き続きメソッド単位の挙動をカバーし、新トランスポートのワイヤーレベル契約は `HttpMcpTransportTests` がカバーする。 +ワイヤー選択は `ProgramRunner.RunMcp` で行う。`--transport stdio|http` と `--http-listen ` は下流の引数解析より前に取り除かれ、HTTP bearer token 解決は `CDIDX_MCP_HTTP_TOKEN` を先に見て、未設定なら `CDIDX_MCP_AUTH_TOKEN` に fallback する。ディスパッチは旧来の stdio 経路または `RunMcpHttp` に着地する。プラガブルなシームは JSON-RPC 順序不変条件を両トランスポートで同一に保つので、既存の McpServer テスト群(`ProcessLineAsync` を叩く)は引き続きメソッド単位の挙動をカバーし、新トランスポートのワイヤーレベル契約は `HttpMcpTransportTests` がカバーする。 #### 構造化エラーエンベロープとサーバーコード — issue #1581 diff --git a/SECURITY.md b/SECURITY.md index 8dfb40a3c3..2ed71cbd7c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,11 @@ unless you explicitly add the optional token controls below. The optional HTTP transport is also a local operator surface, not a public multi-tenant service. It rejects wildcard listen hosts, refuses non-loopback -binds unless `CDIDX_MCP_HTTP_TOKEN` is set, and requires -`Authorization: Bearer ` on every HTTP request when that token is -configured. +binds unless `CDIDX_MCP_HTTP_TOKEN` or `CDIDX_MCP_AUTH_TOKEN` is set, and +requires `Authorization: Bearer ` on every HTTP request when a bearer +secret is configured. `CDIDX_MCP_HTTP_TOKEN` takes precedence when both +variables are set; `CDIDX_MCP_AUTH_TOKEN` is the HTTP bearer fallback and does +not make HTTP clients also send `params.auth.token`. Stdio requests can require a shared secret by setting `CDIDX_MCP_AUTH_TOKEN`. When the variable is unset, stdio keeps the historical local-trusted-client diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7aec5917fd..5a94b6afe3 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2075,25 +2075,29 @@ CDIDX_MCP_HTTP_TOKEN=s3cret cdidx mcp \ --transport http --http-listen 0.0.0.0:9000 # LAN bind; bearer token is mandatory ``` +For HTTP, `CDIDX_MCP_HTTP_TOKEN` is the preferred bearer secret. If it is +unset, HTTP falls back to `CDIDX_MCP_AUTH_TOKEN` as the bearer secret, and +clients still authenticate with `Authorization: Bearer `. + Each HTTP `POST /` carries one JSON-RPC frame in the request body, the matching response is returned in the same HTTP body (`200 OK`, `application/json`), and notifications return `204 No Content`. `GET /events` opens a `text/event-stream` channel for server-to-client frames; the server emits no unsolicited frames unless keep-alive notifications are opted in with `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S`. Accepted keep-alive values are finite seconds from `1` to `300`; invalid or out-of-range values leave keep-alive disabled with a `stderr` warning. The stream is independent and does not block normal POST requests. Non-POST verbs on `/` return `405 Method Not Allowed` with `Allow: POST`. Request bodies are capped at 1,000,000 bytes by default and oversized requests return `413 Payload Too Large`; the pending POST queue is capped at 64 requests by default and full queues return `429 Too Many Requests` with `Retry-After: 1`. Tune those positive-integer limits with `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` and `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`; accepted ranges are `1..16777216` bytes and `1..1024` queued requests. Invalid non-positive or non-numeric values fall back to the defaults, while values above those maximums are rejected before the listener starts. When the persistent lifecycle log is enabled, HTTP mode also writes one `mcp_http_request` record per request with method, path, status, duration, auth outcome, remote peer, correlation id, and JSON-RPC request id when available. Request and response bodies are not logged. Security defaults: - The listener binds to a loopback address (`127.0.0.1`) by default, and the wildcard hosts `+` / `*` are rejected outright. -- Binding to a non-loopback host (e.g. `0.0.0.0:9000`) is refused unless you set `CDIDX_MCP_HTTP_TOKEN` to a shared secret; when set, every request must carry `Authorization: Bearer ` or the listener returns `401 Unauthorized` with `WWW-Authenticate: Bearer realm="cdidx-mcp"`. +- Binding to a non-loopback host (e.g. `0.0.0.0:9000`) is refused unless you set `CDIDX_MCP_HTTP_TOKEN` or `CDIDX_MCP_AUTH_TOKEN` to a shared secret. `CDIDX_MCP_HTTP_TOKEN` wins when both are set. When an HTTP bearer secret is configured, every request must carry `Authorization: Bearer ` or the listener returns `401 Unauthorized` with `WWW-Authenticate: Bearer realm="cdidx-mcp"`; HTTP clients do not also need `params.auth.token`. - The configured token's SHA-256 digest is precomputed at start-up; per-request authentication only hashes the supplied input and compares against the stored digest in constant time, so neither the configured token's length nor its bytes leak through timing. The stdio transport stays byte-for-byte unchanged, so existing client configs keep working without modification. #### Optional MCP authentication: `CDIDX_MCP_AUTH_TOKEN` -`CDIDX_MCP_HTTP_TOKEN` above guards the HTTP transport at the `Authorization: Bearer ...` header. For an additional JSON-RPC-level auth gate that works on **any** transport (including stdio), `cdidx mcp` also recognises `CDIDX_MCP_AUTH_TOKEN` (#1559). +`CDIDX_MCP_HTTP_TOKEN` above guards the HTTP transport at the `Authorization: Bearer ...` header. If it is unset, HTTP uses `CDIDX_MCP_AUTH_TOKEN` as the bearer secret instead. For stdio, `CDIDX_MCP_AUTH_TOKEN` enables the JSON-RPC-level auth gate (#1559). The default `cdidx mcp` server is **permissive** — the OS-enforced stdio process boundary already gates access, and every existing client setup above (Claude Code, Cursor, Windsurf, Copilot, Codex) keeps working unchanged. When `CDIDX_MCP_AUTH_TOKEN` is unset (or whitespace-only), the server accepts every request and tags it with the shared `stdio` / `local` caller identity. -If you expose `cdidx mcp` over a less-trusted channel (a forwarded socket, a sandbox bridge, a shared CI runner), set `CDIDX_MCP_AUTH_TOKEN` to a non-whitespace secret. The server then requires every responded JSON-RPC request (`initialize`, `tools/list`, `tools/call`, `ping`) to include the same token at `params.auth.token`. The expected token is stored as a SHA-256 digest and the presented token is hashed to the same length before `CryptographicOperations.FixedTimeEquals`, so missing / wrong-length / wrong-value guesses share one constant-time path and neither token length nor bytes leak through timing. Mismatches return a uniform JSON-RPC `-32001 "Unauthorized"` — the wire body never distinguishes "missing token" from "wrong token", so the response cannot be used as a token-existence oracle (#1530). The detailed failure reason is written to `cdidx mcp` stderr for local diagnostics, with `method` sanitized to strip control characters so a malicious request body cannot forge log lines. Notifications (`notifications/initialized`, `notifications/cancelled`) skip the gate because they have no `id` and cannot signal an error code. +If you expose stdio `cdidx mcp` over a less-trusted channel (a forwarded socket, a sandbox bridge, a shared CI runner), set `CDIDX_MCP_AUTH_TOKEN` to a non-whitespace secret. The stdio server then requires every responded JSON-RPC request (`initialize`, `tools/list`, `tools/call`, `ping`) to include the same token at `params.auth.token`. HTTP uses the same variable only as a bearer-secret fallback when `CDIDX_MCP_HTTP_TOKEN` is unset, so HTTP clients send `Authorization: Bearer ` instead of duplicating the token in the JSON-RPC body. The expected token is stored as a SHA-256 digest and the presented token is hashed to the same length before `CryptographicOperations.FixedTimeEquals`, so missing / wrong-length / wrong-value guesses share one constant-time path and neither token length nor bytes leak through timing. Mismatches return a uniform JSON-RPC `-32001 "Unauthorized"` — the wire body never distinguishes "missing token" from "wrong token", so the response cannot be used as a token-existence oracle (#1530). The detailed failure reason is written to `cdidx mcp` stderr for local diagnostics, with `method` sanitized to strip control characters so a malicious request body cannot forge log lines. Notifications (`notifications/initialized`, `notifications/cancelled`) skip the gate because they have no `id` and cannot signal an error code. -This is a defensive primitive for custom MCP clients you control and for the networked transports that will reuse the same `McpCallerIdentity` shape (audit log #1562). Stdio clients that do not inject `params.auth.token` will be rejected once the variable is set, so leave it unset unless you actively want to enforce token authentication. +This remains useful for custom stdio MCP clients you control. Stdio clients that do not inject `params.auth.token` will be rejected once the variable is set, so leave it unset unless you actively want to enforce body-token authentication; HTTP clients should prefer the bearer-header contract above. #### Restricting which MCP tools a deployment exposes @@ -4255,25 +4259,29 @@ CDIDX_MCP_HTTP_TOKEN=s3cret cdidx mcp \ --transport http --http-listen 0.0.0.0:9000 # LAN 公開時は bearer token が必須 ``` +HTTP では `CDIDX_MCP_HTTP_TOKEN` が優先の bearer secret です。未設定の場合は +`CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、クライアントは引き続き +`Authorization: Bearer ` で認証します。 + HTTP の `POST /` 1 件が JSON-RPC フレーム 1 件に対応し、応答は同じ HTTP レスポンスのボディに `200 OK` / `application/json` で返ります。通知は `204 No Content` です。`GET /events` はサーバー→クライアントフレーム用の `text/event-stream` channel を開きます。server-initiated frame は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification を opt-in した場合だけ送信されます。受理される値は有限な `1`〜`300` 秒で、不正値や範囲外の値では `stderr` に警告を出して keep-alive を無効のままにします。この stream は独立しており通常の POST リクエストを塞ぎません。`/` への POST 以外は `405 Method Not Allowed`(`Allow: POST` 付き)です。リクエスト本文は既定で 1,000,000 bytes までに制限され、超過時は `413 Payload Too Large` を返します。保留中 POST queue は既定で 64 件までに制限され、満杯時は `Retry-After: 1` 付きの `429 Too Many Requests` を返します。正の整数の `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES` と `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH` で調整でき、受理範囲は本文が `1..16777216` bytes、queue が `1..1024` 件です。正でない値や数値でない値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否されます。永続 lifecycle log が有効な場合、HTTP mode はリクエストごとに `mcp_http_request` レコードも出力し、method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id を記録します。リクエスト/レスポンス本文は記録しません。 セキュリティ既定: - listener は既定で loopback アドレス(`127.0.0.1`)のみに bind し、ワイルドカード `+` / `*` は最初から拒否します。 -- 非 loopback ホスト(例: `0.0.0.0:9000`)に bind するには `CDIDX_MCP_HTTP_TOKEN` で共有秘密を指定する必要があります。指定時はすべてのリクエストに `Authorization: Bearer ` ヘッダーが必要で、欠落・不一致は `401 Unauthorized`(`WWW-Authenticate: Bearer realm="cdidx-mcp"` 付き)です。 +- 非 loopback ホスト(例: `0.0.0.0:9000`)に bind するには `CDIDX_MCP_HTTP_TOKEN` または `CDIDX_MCP_AUTH_TOKEN` で共有秘密を指定する必要があります。両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` が優先されます。HTTP bearer secret が設定されている場合、すべてのリクエストに `Authorization: Bearer ` ヘッダーが必要で、欠落・不一致は `401 Unauthorized`(`WWW-Authenticate: Bearer realm="cdidx-mcp"` 付き)です。HTTP クライアントは `params.auth.token` も送る必要はありません。 - 設定トークンの SHA-256 digest はサーバー起動時に一度だけ計算してメモリ保持し、リクエスト毎の認証では受信トークンのみハッシュ計算して FixedTimeEquals で比較します。設定トークン側はリクエスト毎にハッシュしないため、長さやバイト列が timing から漏れません。 stdio トランスポートはバイト単位で挙動が変わらないため、既存クライアント設定はそのまま動作します。 #### MCP 認証(任意): `CDIDX_MCP_AUTH_TOKEN` -上記の `CDIDX_MCP_HTTP_TOKEN` は HTTP トランスポートの `Authorization: Bearer ...` ヘッダーを守るためのものです。これに加えて、**どのトランスポート(stdio 含む)でも有効** な JSON-RPC レベルの認証ゲートとして、`cdidx mcp` は `CDIDX_MCP_AUTH_TOKEN` も認識します (#1559)。 +上記の `CDIDX_MCP_HTTP_TOKEN` は HTTP トランスポートの `Authorization: Bearer ...` ヘッダーを守るためのものです。未設定の場合、HTTP は `CDIDX_MCP_AUTH_TOKEN` を bearer secret として使います。stdio では `CDIDX_MCP_AUTH_TOKEN` が JSON-RPC レベルの認証ゲートを有効にします (#1559)。 既定の `cdidx mcp` サーバーは **permissive** です — OS のプロセス境界が stdio へのアクセスを既に絞っているため、上記の Claude Code / Cursor / Windsurf / Copilot / Codex の設定はそのまま動作します。`CDIDX_MCP_AUTH_TOKEN` を未設定(または空白のみ)にしておくと、サーバーは全リクエストを受理し、共有の `stdio` / `local` 呼び出し元アイデンティティを付与します。 -`cdidx mcp` を信頼度の低いチャネル(転送ソケット、サンドボックスブリッジ、共有 CI ランナーなど)に露出する場合は、`CDIDX_MCP_AUTH_TOKEN` に空白以外の秘密値を設定してください。設定すると、サーバーは応答が必要な全 JSON-RPC リクエスト(`initialize`、`tools/list`、`tools/call`、`ping`)に対し、`params.auth.token` が同じトークンと一致することを要求します。期待トークンは SHA-256 ダイジェストとして保持し、提示トークンも同じ長さにハッシュしてから `CryptographicOperations.FixedTimeEquals` で比較するため、「未提示/長さ違い/値違い」を 1 つの定数時間パスに集約し、トークン長やバイト列が timing から漏れません。不一致は統一された JSON-RPC `-32001 "Unauthorized"` を返します。ワイヤ本文では「未提示」と「不一致」を区別しないため、応答を用いたトークン存在判定オラクル攻撃を防ぎます(#1530)。失敗詳細はローカル診断用に `cdidx mcp` の stderr に出力されますが、`method` は制御文字を除去するサニタイズを通すため、悪意あるリクエスト本文によるログ偽造を防ぎます。通知(`notifications/initialized`、`notifications/cancelled`)はゲートをスキップします — `id` を持たずエラーコードも返せないためです。 +stdio の `cdidx mcp` を信頼度の低いチャネル(転送ソケット、サンドボックスブリッジ、共有 CI ランナーなど)に露出する場合は、`CDIDX_MCP_AUTH_TOKEN` に空白以外の秘密値を設定してください。stdio サーバーは応答が必要な全 JSON-RPC リクエスト(`initialize`、`tools/list`、`tools/call`、`ping`)に対し、`params.auth.token` が同じトークンと一致することを要求します。HTTP では `CDIDX_MCP_HTTP_TOKEN` が未設定の場合だけ同じ変数を bearer-secret fallback として使うため、HTTP クライアントは JSON-RPC body に token を重複させず `Authorization: Bearer ` を送ります。期待トークンは SHA-256 ダイジェストとして保持し、提示トークンも同じ長さにハッシュしてから `CryptographicOperations.FixedTimeEquals` で比較するため、「未提示/長さ違い/値違い」を 1 つの定数時間パスに集約し、トークン長やバイト列が timing から漏れません。不一致は統一された JSON-RPC `-32001 "Unauthorized"` を返します。ワイヤ本文では「未提示」と「不一致」を区別しないため、応答を用いたトークン存在判定オラクル攻撃を防ぎます(#1530)。失敗詳細はローカル診断用に `cdidx mcp` の stderr に出力されますが、`method` は制御文字を除去するサニタイズを通すため、悪意あるリクエスト本文によるログ偽造を防ぎます。通知(`notifications/initialized`、`notifications/cancelled`)はゲートをスキップします — `id` を持たずエラーコードも返せないためです。 -これは defense-in-depth の基盤であり、自分で制御する MCP クライアントや、同じ `McpCallerIdentity` を再利用するネットワーク transport(監査ログ #1562)で活用するためのものです。stdio クライアントが `params.auth.token` を注入しない場合、変数を設定した時点で拒否されるので、token 認証を能動的に強制したい場合以外は未設定のまま残してください。 +これは自分で制御する stdio MCP クライアント向けの defense-in-depth として有効です。stdio クライアントが `params.auth.token` を注入しない場合、変数を設定した時点で拒否されるので、body token 認証を能動的に強制したい場合以外は未設定のまま残してください。HTTP クライアントは上記の bearer header 契約を優先してください。 #### デプロイ単位で公開する MCP ツールを制限する diff --git a/changelog.d/unreleased/3156.security.md b/changelog.d/unreleased/3156.security.md new file mode 100644 index 0000000000..0b1d7bd26c --- /dev/null +++ b/changelog.d/unreleased/3156.security.md @@ -0,0 +1,20 @@ +--- +category: security +issues: + - 3156 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Mcp/McpAuthentication.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - SECURITY.md +--- + +## English + +- **HTTP MCP now has a single bearer-token precedence model (#3156)** — HTTP transport authentication now uses `CDIDX_MCP_HTTP_TOKEN` first and falls back to `CDIDX_MCP_AUTH_TOKEN` when the HTTP-specific variable is unset, so HTTP clients authenticate with one `Authorization: Bearer` header instead of needing a second `params.auth.token` body token. + +## 日本語 + +- **HTTP MCP の bearer token precedence を一本化しました (#3156)** — HTTP transport の認証は `CDIDX_MCP_HTTP_TOKEN` を優先し、HTTP 固有の変数が未設定の場合だけ `CDIDX_MCP_AUTH_TOKEN` に fallback するようになりました。これにより HTTP クライアントは `Authorization: Bearer` ヘッダー 1 つで認証でき、追加の `params.auth.token` body token は不要です。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 125db8b2e7..abd3947539 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2151,7 +2151,7 @@ internal static void EmitCommandMetric(string tool, string[] args, DateTimeOffse }; private const string DefaultMcpHttpListen = "127.0.0.1:38080"; - private const string McpHttpTokenEnvVar = "CDIDX_MCP_HTTP_TOKEN"; + internal const string McpHttpTokenEnvVar = "CDIDX_MCP_HTTP_TOKEN"; private static int RunLsp(string[] cmdArgs, string appVersion, JsonSerializerOptions jsonOptions) { @@ -2254,16 +2254,20 @@ private static int RunMcp(string[] cmdArgs, string appVersion) if (!TryOpenMcpAuditLog(runOptions.AuditOptions, out auditLog, out exitCode)) return exitCode; - // Pick the authenticator based on `CDIDX_MCP_AUTH_TOKEN` (#1559). When unset the - // permissive local-stdio default keeps the historical behaviour; when set every - // JSON-RPC request must include a matching `params.auth.token`. The tool-enablement - // gate (#1561) is wired automatically by the McpServer ctor via - // `McpToolFilter.FromEnvironment()`. - // `CDIDX_MCP_AUTH_TOKEN` の有無で authenticator を切り替える (#1559)。未設定なら - // permissive な stdio 既定で従来動作を維持し、設定済みなら全 JSON-RPC リクエストに - // `params.auth.token` の一致を要求する。ツール有効化ゲート (#1561) は McpServer の - // コンストラクタ内部で `McpToolFilter.FromEnvironment()` から自動取得される。 - var authenticator = Mcp.McpAuthenticatorFactory.FromEnvironment(); + // Pick the JSON-RPC authenticator for the selected transport. Stdio keeps the + // historical `CDIDX_MCP_AUTH_TOKEN` / `params.auth.token` gate (#1559). HTTP uses + // its bearer header gate instead, with `CDIDX_MCP_HTTP_TOKEN` taking precedence over + // `CDIDX_MCP_AUTH_TOKEN` as a fallback (#3156), so clients never need both header and + // body tokens for one HTTP request. The tool-enablement gate (#1561) is wired + // automatically by the McpServer ctor via `McpToolFilter.FromEnvironment()`. + // 選択済み transport に応じて JSON-RPC authenticator を選ぶ。stdio は従来通り + // `CDIDX_MCP_AUTH_TOKEN` / `params.auth.token` ゲートを使う (#1559)。HTTP は bearer + // header ゲートへ一本化し、`CDIDX_MCP_HTTP_TOKEN` を優先、未設定なら + // `CDIDX_MCP_AUTH_TOKEN` を fallback として使う (#3156)。そのため HTTP では同一 + // リクエストに header token と body token の両方を要求しない。ツール有効化ゲート + // (#1561) は McpServer のコンストラクタ内部で `McpToolFilter.FromEnvironment()` + // から自動取得される。 + var authenticator = CreateMcpAuthenticatorForTransport(runOptions.Transport); using var server = new McpServer(runOptions.QueryOptions.DbPath, appVersion, runOptions.QueryOptions.DbPathExplicit, authenticator, auditLog); return RunMcpServer(server, runOptions.Transport, runOptions.ListenSpec); } @@ -2429,6 +2433,23 @@ private static int RunMcpServer(McpServer server, string transport, string? list } } + internal static IMcpAuthenticator CreateMcpAuthenticatorForTransport(string transport) + => string.Equals(transport, "http", StringComparison.OrdinalIgnoreCase) + ? LocalStdioAuthenticator.Instance + : McpAuthenticatorFactory.FromEnvironment(); + + internal static string? ResolveMcpHttpBearerTokenFromEnvironment() + { + var httpToken = NormalizeMcpToken(Environment.GetEnvironmentVariable(McpHttpTokenEnvVar)); + if (httpToken is not null) + return httpToken; + + return NormalizeMcpToken(Environment.GetEnvironmentVariable(McpAuthenticatorFactory.AuthTokenEnvVar)); + } + + private static string? NormalizeMcpToken(string? token) + => string.IsNullOrWhiteSpace(token) ? null : token; + private static int RunMcpHttp(McpServer server, string listenSpec) { HttpMcpTransport.HttpListenSpec resolved; @@ -2444,17 +2465,23 @@ private static int RunMcpHttp(McpServer server, string listenSpec) } // Require a shared-secret bearer token when the user opts into a non-loopback bind so the - // MCP catalog is not exposed to the local network unauthenticated. Loopback binds skip the - // requirement because they're indistinguishable from the existing stdio threat model. - // 非 loopback への bind 時は共有秘密トークンを必須にし、認証なしの LAN 露出を防ぐ。 - // loopback bind は stdio と同等の脅威モデルとみなしてトークン要件を緩める。 - var bearerToken = Environment.GetEnvironmentVariable(McpHttpTokenEnvVar); - if (string.IsNullOrEmpty(bearerToken)) - bearerToken = null; + // MCP catalog is not exposed to the local network unauthenticated. HTTP resolves that + // bearer token from `CDIDX_MCP_HTTP_TOKEN` first, then falls back to the generic + // `CDIDX_MCP_AUTH_TOKEN` so setting the generic auth token also protects HTTP without + // forcing clients to send both `Authorization` and `params.auth.token` (#3156). Loopback + // binds skip the requirement because they're indistinguishable from the existing stdio + // threat model when neither token is configured. + // 非 loopback への bind 時は共有秘密 bearer token を必須にし、認証なしの LAN 露出を + // 防ぐ。HTTP はまず `CDIDX_MCP_HTTP_TOKEN` を使い、未設定なら汎用の + // `CDIDX_MCP_AUTH_TOKEN` を bearer token として使うため、汎用 token を設定しただけでも + // HTTP は保護され、クライアントに `Authorization` と `params.auth.token` の両方を + // 要求しない (#3156)。どちらの token も未設定なら、loopback bind は stdio と同等の脅威 + // モデルとみなしてトークン要件を緩める。 + var bearerToken = ResolveMcpHttpBearerTokenFromEnvironment(); if (!resolved.IsLoopback && bearerToken is null) { - Console.Error.WriteLine($"Error: --transport http refuses to bind to '{resolved.Host}' without a shared secret. Set the `{McpHttpTokenEnvVar}` environment variable or bind to a loopback address."); + Console.Error.WriteLine($"Error: --transport http refuses to bind to '{resolved.Host}' without a shared secret. Set the `{McpHttpTokenEnvVar}` or `{McpAuthenticatorFactory.AuthTokenEnvVar}` environment variable, or bind to a loopback address."); PrintMcpUsage(); return CommandExitCodes.UsageError; } diff --git a/src/CodeIndex/Mcp/McpAuthentication.cs b/src/CodeIndex/Mcp/McpAuthentication.cs index 65ffc990d2..1c55d57003 100644 --- a/src/CodeIndex/Mcp/McpAuthentication.cs +++ b/src/CodeIndex/Mcp/McpAuthentication.cs @@ -169,12 +169,15 @@ public McpAuthenticationResult Authenticate(JsonNode request) /// Pick the authenticator based on environment configuration. With /// CDIDX_MCP_AUTH_TOKEN unset (the default), keep the historical stdio behaviour /// (). When set to a non-whitespace value, enforce -/// token authentication on every request (). This is -/// the only public composition surface the CLI uses; tests inject authenticators directly. +/// token authentication on every request (). The CLI uses +/// this for stdio; HTTP resolves the same environment variable as a bearer-token fallback in +/// ProgramRunner so HTTP clients authenticate through Authorization: Bearer instead +/// of also sending params.auth.token. /// 環境変数に応じて authenticator を選ぶ。CDIDX_MCP_AUTH_TOKEN 未設定(既定)では /// 従来の stdio 動作を維持する ()。空白以外の値が /// セットされていれば全リクエストにトークン認証を強制する ()。 -/// CLI 側はこの公開合成 API のみを使い、テストは authenticator を直接 inject する。 +/// CLI はこれを stdio に使う。HTTP は ProgramRunner で同じ環境変数を bearer-token fallback +/// として解決し、params.auth.token ではなく Authorization: Bearer で認証する。 /// public static class McpAuthenticatorFactory { diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 5eb4d243fb..8e207af3f5 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -632,6 +632,35 @@ public async Task HttpTransport_EventsStream_UsesBearerAuth() Assert.Equal("text/event-stream", authorized.Content.Headers.ContentType!.MediaType); } + [Fact] + public async Task HttpTransport_GenericAuthTokenFallback_AcceptsBearerHeaderWithoutBodyToken() + { + using var env = EnvironmentVariableScope.Capture( + ProgramRunner.McpHttpTokenEnvVar, + McpAuthenticatorFactory.AuthTokenEnvVar); + env.Set(ProgramRunner.McpHttpTokenEnvVar, null); + env.Set(McpAuthenticatorFactory.AuthTokenEnvVar, "generic-token"); + + var bearerToken = ProgramRunner.ResolveMcpHttpBearerTokenFromEnvironment(); + var authenticator = ProgramRunner.CreateMcpAuthenticatorForTransport("http"); + await using var harness = await McpHttpHarness.StartAsync( + _dbPath, + bearerToken: bearerToken, + authenticator: authenticator); + + using var client = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, harness.Endpoint) + { + Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"ping"}""", Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "generic-token"); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("Unauthorized", body, StringComparison.Ordinal); + } + [Fact] public async Task HttpTransport_BearerToken_RejectsMissingHeader() { @@ -876,6 +905,7 @@ private McpHttpHarness(McpServer server, HttpMcpTransport transport, Cancellatio public static async Task StartAsync( string dbPath, string? bearerToken = null, + IMcpAuthenticator? authenticator = null, Action? requestLogger = null, int? maxRequestBodyBytes = null, int? maxQueuedRequests = null) @@ -889,7 +919,9 @@ public static async Task StartAsync( requestLogger, maxRequestBodyBytes, maxQueuedRequests); - var server = new McpServer(dbPath, ConsoleUi.LoadVersion()); + var server = authenticator is null + ? new McpServer(dbPath, ConsoleUi.LoadVersion()) + : new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: false, authenticator); var cts = new CancellationTokenSource(); var loopTask = Task.Run(() => server.RunAsync(transport, cts.Token)); // Give the listener a tick to start accepting; HttpListener.Start is synchronous but the diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index c22536e82f..f7bc10a4b8 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -45,6 +45,34 @@ public void IsProjectPathArg_WindowsPathForms_ReturnTrueOnWindows(string arg) Assert.True(ProgramRunner.IsProjectPathArg(arg)); } + [Fact] + public void ResolveMcpHttpBearerTokenFromEnvironment_HttpTokenWinsThenFallsBackToGeneric() + { + using var env = EnvironmentVariableScope.Capture( + ProgramRunner.McpHttpTokenEnvVar, + McpAuthenticatorFactory.AuthTokenEnvVar); + + env.Set(ProgramRunner.McpHttpTokenEnvVar, "http-secret"); + env.Set(McpAuthenticatorFactory.AuthTokenEnvVar, "generic-secret"); + Assert.Equal("http-secret", ProgramRunner.ResolveMcpHttpBearerTokenFromEnvironment()); + + env.Set(ProgramRunner.McpHttpTokenEnvVar, " "); + Assert.Equal("generic-secret", ProgramRunner.ResolveMcpHttpBearerTokenFromEnvironment()); + + env.Set(McpAuthenticatorFactory.AuthTokenEnvVar, "\t"); + Assert.Null(ProgramRunner.ResolveMcpHttpBearerTokenFromEnvironment()); + } + + [Fact] + public void CreateMcpAuthenticatorForTransport_HttpUsesBearerGateInsteadOfBodyTokenGate() + { + using var env = EnvironmentVariableScope.Capture(McpAuthenticatorFactory.AuthTokenEnvVar); + env.Set(McpAuthenticatorFactory.AuthTokenEnvVar, "generic-secret"); + + Assert.IsType(ProgramRunner.CreateMcpAuthenticatorForTransport("stdio")); + Assert.IsType(ProgramRunner.CreateMcpAuthenticatorForTransport("http")); + } + [Theory] [InlineData("--json")] [InlineData("--json=array")]