0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPのOAuthは更新だけでは塞がらない――手元で再現して点検ツールを作った

0
Posted at

アイキャッチ

はじめに

MCP(Model Context Protocol)の公式 SDK には、2025年12月から2026年10月にかけてセキュリティアドバイザリが続けて出ました。この記事で扱うのはそのうちの一つで、OAuth で MCP サーバーに接続するクライアントが、MCP サーバーに指名された認可サーバーへ自分の資格情報を送ってしまう、という問題です。Python SDK では 2026年9月28日に GHSA-qx49-fqc8-xw99、TypeScript SDK では 9月30日に GHSA-6qxp-vccf-f47h として公開され、どちらも CVSS 7.5 の High です。

修正版はもう出ています。ところがアドバイザリには、Python の ClientCredentialsOAuthProvider と PrivateKeyJWTOAuthProvider について「issuer= を渡すまで、更新しても何も変わらない」と書かれています。手元で確かめたところ、そのとおりでした。修正版の 1.30.0 でも最新の 2.3.0 でも、引数を足さなければラボ用の偽のクライアントシークレットは悪性の認可サーバーにそのまま届き、1.30.0 ではそのことを知らせる警告も、普通の書き方では表示されませんでした。図1が、この記事で確かめたことの要約です。

図1 更新しただけでは結果が変わらない

認可の仕組みが絡むので、少し込み入った話になります。この記事では、まず用語と「自分が対象かどうか」を整理し、次に問題の仕組みを図で追い、再現の手順と結果を載せます。最後に、同じ状態を手元のプロジェクトから探すための点検スクリプトを作ったので、その設計と、自分の Mac で回した結果を紹介します。

  • リポジトリ:01rabbit/MCP-Trust-Check
  • 確認日:2026年11月11日〜12日。版や記述はこの時点のものです。

用語の整理

この先で何度も出てくる言葉を先にまとめておきます。

用語 この記事での意味
MCP サーバー AI エージェントにツールやデータを提供する側。ファイル操作、外部サービスの API などを提供する
MCP クライアント MCP サーバーに接続して使う側。Claude Desktop やエージェント基盤など
stdio 接続 クライアントが MCP サーバーを手元のプロセスとして起動し、標準入出力で話す方式。デスクトップ用の拡張機能はほとんどこれ
HTTP 接続 ネットワーク越しに MCP サーバーへ接続する方式。リモートの MCP サーバーはこちら
認可サーバー アクセストークンを発行するサーバー。クライアントはここに自分の ID と秘密を示してトークンをもらう
client credentials / 秘密鍵 JWT 人の操作なしに、クライアント ID とシークレット(または秘密鍵で署名した JWT)でトークンを取る方式。この記事では「無人」と呼ぶ
対話型 人がブラウザでサインインして同意し、トークンを取る方式
issuer 認可サーバーが名乗る自分の識別子(URL)。メタデータの中に書かれている

自分の環境が対象かどうか

最初に、読んでいる方の環境がこの件に関係するかどうかを判定できるようにしておきます(図2)。

図2 自分の環境が対象かどうかの判定

まず、HTTP で MCP サーバーに接続するクライアントがなければ、この件は関係ありません。MCP の仕様(2026-07-28 版)は、stdio で動かす実装について「SHOULD NOT follow this specification, and instead retrieve credentials from the environment」としていて、手元のプロセスとして起動する MCP サーバーには、そもそもこれから説明する認可の流れがありません。

HTTP で接続していても、トークンやヘッダを自前で付けていて SDK の OAuth 機能を使っていなければ、今回の件は対象外です。Python 側のアドバイザリも、影響しないものとしてこの形を挙げています。ただし、同じ時期に出た別のアドバイザリ(リダイレクトの件)には当てはまることがあり、これは後で自分の環境の結果のところで触れます。

SDK の OAuth 機能を使っている場合は、人がその場でサインインする対話型か、無人で動くかで対応が分かれます。一番危ないのは無人の側で、SDK を更新したうえで、期待する issuer を必ず渡す必要があります。理由は次の節で説明します。

資格情報の送り先を決めているのは誰か

接続の流れ

HTTP で接続するときの流れは図3のとおりです。クライアントがトークンなしで MCP サーバーにリクエストを送ると、401 と一緒に Protected Resource Metadata(RFC 9728)の場所が返ってきます。そこには authorization_servers という項目があり、クライアントはここに書かれた認可サーバーからメタデータを取得して、トークンエンドポイントにクライアント ID とシークレット(あるいは署名済みの JWT)を送ります。

図3 MCPクライアントが資格情報の送り先を決めるまで

図の④を見るとわかるように、資格情報の送り先を決めているのは MCP サーバーです。相手が正規のサーバーであれば問題はありません。しかし、悪意のある MCP サーバーや乗っ取られた MCP サーバーに接続すると、相手は自分で用意した認可サーバーの URL を教えるだけで、クライアントの資格情報を受け取れてしまいます。

issuer の確認がなぜ効かないのか

⑤には、認可サーバーのメタデータに書かれた issuer を確かめる処理があります。それなのに防げない理由は、何と何を比べているかにあります(図4)。

図4 issuer の確認で、何と何を比べているか

期待値を渡さない場合、クライアントは「④で教えられた URL」と「⑤で認可サーバーが名乗った issuer」を比べます。この2つはどちらも相手が用意した値です。悪性のサーバー同士で話を合わせておけば一致するので、確認の処理は動いていても、相手を見分ける役には立っていません。

issuer=(TypeScript では expectedIssuer)を渡すと、比べる相手の片方が、自分のコードや設定に書いた値に変わります。相手が何を名乗っても、自分の側の値と食い違えばその場で止まり、トークン要求は送られません。仕様も認可レスポンスの検証の節で、期待する issuer を検証されていない出所から得た場合は「it provides no protection」と書いていて、この図の左側がまさにその状態です。

アドバイザリの中身

Python と TypeScript のアドバイザリは、同じ問題をそれぞれの SDK について述べたものです。要点を表にまとめます。

Python(GHSA-qx49-fqc8-xw99) TypeScript(GHSA-6qxp-vccf-f47h)
公開日 2026-09-28 2026-09-30
深刻度 High(7.5)。対話型の OAuthClientProvider は 6.5 High(7.5)
影響を受ける版 mcp 1.9.1 以上 1.30.0 未満、2.0.0a1 以上 2.2.0 未満 @modelcontextprotocol/sdk 1.12.0〜1.30.1、@modelcontextprotocol/client 2.0.0〜2.1.0
修正版 1.30.0、2.2.0 1.31.0、2.2.0
更新に加えて必要なこと ClientCredentialsOAuthProvider / PrivateKeyJWTOAuthProvider に issuer= を渡す。保存済みのクライアント情報を一度消して登録し直す 同梱のプロバイダに expectedIssuer を渡す。issuer なしで保存された資格情報に issuer を足すか、消して再サインインさせる
影響しないもの SDK で作った MCP サーバー、stdio のクライアント、自前でトークンやヘッダを付けるクライアント SDK で作った MCP サーバー、stdio のクライアント
CVE 記載なし 記載なし

TypeScript 側には、修正後も残るものとして、新規の対話型サインイン、refreshAuthorization() や exchangeAuthorization() の直接呼び出し、2.x で skipIssuerMetadataValidation: true を使っている場合が挙がっています。新規の対話型サインインについては「Only complete sign-ins for servers you trust.」とだけ書かれています。初めて接続するサーバーの認可サーバーを事前に知る方法はないので、これは SDK の側では防げません。

この2件を含む一連のアドバイザリについては、Cloud Security Alliance が 2026年11月9日に研究ノートを出しています。localhost の HTTP サーバーで DNS rebinding 対策が既定で無効だった件、今回の issuer の件、サーバーのインスタンスを使い回すと別のクライアントへの応答が混ざる件を、どれも「確かめていない手がかりで相手を決めていた」という同じ型の問題として整理していて、全体像をつかむには便利です。ただし、このノートは今回の2件に CVE 番号(CVE-2026-115563 と CVE-2026-104850)を対応付けていますが、11月12日の時点で GitHub のアドバイザリにはどちらも「No known CVE」と表示されています。この記事では GHSA の番号だけを使います。

手元で再現する

検証環境

再現は図5の構成で行いました。すべて 127.0.0.1 の中で完結していて、外部とは通信しません。

図5 検証環境の構成

  • 9200番:悪性の MCP サーバー。どんなリクエストにも 401 を返し、Protected Resource Metadata で認可サーバーとして 9300番を指名します。
  • 9300番:悪性の認可サーバー。メタデータを返し、トークンエンドポイントに届いた内容を記録して invalid_client を返します。受け取った値は判定に使うだけで、使用も転送もしません。
  • 9100番:正規の認可サーバー。issuer= に渡す値で、本来の送り先です。
  • クライアントのシークレットは LAB-ONLY-not-a-real-secret という偽物です。

クライアントは Python SDK の ClientCredentialsOAuthProvider で作り、httpx の auth に渡して MCP サーバーに接続します。プロバイダを作る部分は次のとおりで、アプリケーションのモジュールの中に置いています。

def make_provider(server_url, issuer=None):
    kwargs = {"issuer": issuer} if issuer else {}
    return ClientCredentialsOAuthProvider(
        server_url=server_url,
        storage=MemoryStorage(),
        client_id="lab-client",
        client_secret=LAB_SECRET,
        **kwargs,
    )

試したのは修正前の 1.29.1、1.x 系の修正版 1.30.0、最新の 2.3.0 の3つです。それぞれについて、issuer= を渡さない場合と渡す場合、プロバイダをアプリのモジュールで作る場合と __main__ で直接作る場合を組み合わせました。生成場所を分けたのは、後で述べる警告の出方が変わるからです。実行は Linux 上の Python 3.13 で、警告の表示設定には手を加えていません。

git clone https://github.com/01rabbit/MCP-Trust-Check
cd MCP-Trust-Check/lab
bash run_all.sh

結果

2026年11月12日に実行した結果です。

mcp issuer= 生成した場所 表示された警告 秘密が悪性の認可サーバーに届いたか
1.29.1 なし アプリのモジュール なし 届いた
1.29.1 なし __main__ なし 届いた
1.30.0 なし アプリのモジュール なし 届いた
1.30.0 なし __main__ DeprecationWarning 届いた
1.30.0 あり アプリのモジュール なし 届かない
2.3.0 なし アプリのモジュール MCPDeprecationWarning 届いた
2.3.0 なし __main__ MCPDeprecationWarning 届いた
2.3.0 あり アプリのモジュール なし 届かない

(1.29.1 には issuer 引数がないため、「あり」の組み合わせはありません)

issuer= を渡さなかった6通りでは、版にかかわらず悪性の認可サーバーにトークン要求が届きました。記録された項目は client_id、client_secret、grant_type、resource の4つで、client_secret は偽のシークレットと一致していました。正規の認可サーバーには、どの組み合わせでも一度も通信がありませんでした。

issuer= を渡した2通りでは、クライアントは悪性の認可サーバーのメタデータを取得した時点で止まり、トークン要求は送られませんでした。図4の右側で説明した動きです。2.3.0 での例外のメッセージは次のとおりです(1.30.0 では左側の URL の末尾に / が付くだけで、内容は同じです)。

OAuthFlowError: Authorization server metadata issuer mismatch: http://127.0.0.1:9300 != http://127.0.0.1:9100

警告が見えないことがある

修正版の SDK は、issuer を省略すると警告を出すようになっています。1.30.0 のコードではこうなっています。

def _checked_issuer(issuer: str | None) -> str | None:
    if issuer is None:
        warnings.warn(
            "Omitting `issuer` is deprecated and it will be required in 3.0. Without it, the MCP server "
            "decides which authorization server receives this client's credentials; pass "
            "issuer=<your authorization server's issuer URL> so they are only ever sent there.",
            DeprecationWarning,
            stacklevel=3,
        )

この警告が実際に画面に出るかどうかは、版と、プロバイダをどこで作ったかによって変わります(図6)。

図6 issuer を省略したときの警告が表示されるかどうか

Python は PEP 565 以降、DeprecationWarning を __main__ から発生したものだけ表示し、それ以外は既定で無視します。stacklevel=3 で警告の発生元はプロバイダを作った呼び出し元になるので、スクリプトに直接書いた場合は表示され、アプリケーションのモジュールの中で作った場合は表示されません。実際のアプリケーションはほとんどが後者でしょうから、1.30.0 に上げて何も表示されなければ、穴が残っていることに気づかない可能性が高いと思います。アドバイザリ自身も「On 1.30.0 this is a DeprecationWarning, which Python hides by default.」と書いています。

2.x ではこの警告が MCPDeprecationWarning という独自のクラスに変わっていて、これは UserWarning を継承しているので、どこで作っても表示されます。結果の表で 2.3.0 だけ警告が出ているのはこのためです。1.x 系を使い続ける場合は、試験の実行時だけでも -W error::DeprecationWarning を付けておくと、issuer の付け忘れが試験の失敗として表に出ます。

「不可能か、面倒なだけか」で見る

Anthropic が公開している「Zero Trust for AI Agents」という資料に、統制ごとに「それは攻撃を不可能にしているのか、それとも面倒にしているだけなのか」と問う考え方が出てきます。根気が尽きず、1回の試行にほとんど費用がかからないエージェント型の攻撃者に対しては、手間を増やすだけの対策は効きにくい、という趣旨です。今回の件に出てくる対策をこの問いで分けると、図7のようになります。

図7 今回の対策を「不可能か、面倒なだけか」で分ける

issuer= を渡すのは左側に入ります。期待する送り先をコードや設定に固定してしまえば、MCP サーバーが何を言ってきても資格情報は別の場所へ行きません。一方、「信頼できる MCP サーバーにだけ接続する」という運用は、接続先を人が選んでいる限り、間違いや乗っ取りの余地が残ります。警告に頼るのも、図6のとおり表示されない場合があるので右側です。

ただし、issuer を固定できるのは、送り先があらかじめ分かっている無人のクライアントの場合です。ユーザーがその場でサインインする対話型の流れでは、初めて接続するサーバーの認可サーバーを事前に知る方法がありません。TypeScript のアドバイザリが新規の対話型サインインを「修正後も残るもの」に入れているのはこのためです。対話型の接続については、どの MCP サーバーとどの認可サーバーの組み合わせを認めるかという許可リストをプロトコルの外で持ち、そこに無いものにはサインインさせない仕組みにしておくしかないと考えています。

点検ツールを作る

版だけを見ると判断を誤る

アドバイザリへの対応状況を確かめるとき、まず思いつくのは依存関係の版を見ることです。しかし今回の件では、版が新しくても直っていないことがあり、逆に版が古くても stdio でしか使っていなければ影響はありません。版だけを見る判定と実際の危険度を並べると、図8のようにずれます。

図8 版だけの判定と、このツールの判定

版だけを見る判定は、上の行(stdio だけで使う構成)では実害がないのに要対応と言い、下の行の中央(修正版なのに issuer を渡していない構成)では実害があるのに問題なしと言います。そこで、版とコードの両方を見る小さなスクリプトを書きました。

何を見るか

Python 3.8 以上の標準ライブラリだけで動き、ファイルを読むだけでネットワークには接続しません。主な指摘は次のとおりです。

種別 重さ 内容
issuer-not-set HIGH 無人のプロバイダを issuer なしで作っている(Python は issuer=、TypeScript は expectedIssuer)
no-issuer-option HIGH Python の RFC7523OAuthClientProvider(issuer を渡す手段がない)を使っている
issuer-check-disabled HIGH skipIssuerMetadataValidation: true
audience-not-checked MEDIUM requireBearerAuth に expectedResource がない
outdated-sdk MEDIUM 修正版より古い SDK がある
oauth-client-in-use INFO 対話型の OAuthClientProvider を使っている。更新と、保存済み登録の作り直しが要る
http-transport INFO HTTP で動く MCP サーバーがある

ほかにリダイレクトの追従や 0.0.0.0 での待ち受けなども見ています。一覧はリポジトリの README にあります。

版は requirements.txt、uv.lock、poetry.lock、package-lock.json、go.mod と、インストール済みのパッケージ(*.dist-info と node_modules/@modelcontextprotocol/*)から拾い、ツールの先頭に持たせた表と照合します。表のうち GitHub のアドバイザリ本文で確かめた行と、CSA の研究ノートの表から取ってこちらでは再確認していない行は、出力でも区別して表示するようにしました。

HIGH はコードの状態だけで決める

一番迷ったのは、何を HIGH にするかです。図8の考え方に沿って、版が古いことは MEDIUM にとどめ、HIGH は「修正版なのに修正が効いていないコード」と「修正の手段がないクラスを使っているコード」に限りました。版だけで HIGH を出すと、stdio で動くデスクトップ用の拡張機能がすべて真っ赤になり、本当に見るべきものが埋もれるからです。HIGH が1件でもあれば終了コード 1 を返すので、CI にそのまま入れられます。

$ python3 mcp_trust_check.py tests/fixtures/upgraded
MCP SDK copies found: 1
  py:mcp                                     1.30.0     tests/fixtures/upgraded/requirements.txt

Findings: 1
  [HIGH  ] issuer-not-set         tests/fixtures/upgraded/agent.py:2
           ClientCredentialsOAuthProvider(...) without issuer: the MCP server still chooses where the client secret goes  (GHSA-qx49-fqc8-xw99)

これは「修正版に上げたが issuer を渡していない」状態をわざと作った素材に対する出力で、図8の下の行の中央にあたります。版の照合では何も出ず、コードの点検で HIGH が1件出ています。

実機で出た誤検知

最初の版を自分の Mac で回したところ、HIGH が8件出ました。中身を見ると、すべて uv のキャッシュに展開された Python SDK 自身のソースで、SDK の中でプロバイダを定義・継承している箇所に反応していました。SDK のコピーそのもの(mcp/client/auth/oauth2.py を持つディレクトリ)と、キャッシュのディレクトリを走査の対象から外して解消しました。

もう一つは、エージェント基盤のコードにある transport = "http" if cfg.get("url") else ... という行を、HTTP で動く MCP サーバーとして拾っていたものです。設定を一覧表示するための変数で、サーバーではありません。HTTP サーバーの判定を、mcp.run(transport="streamable-http") のような呼び出しや、トランスポートのクラスを実際に生成している箇所に絞りました。どちらの誤検知も、試験用の素材として tests/ に残しています。

自分の環境で回した結果

2026年11月12日に、macOS 27.2 の標準の Python 3.9.6 で、Claude Desktop の拡張機能のフォルダと、手元で使っているエージェント基盤 Hermes Agent のフォルダを点検しました。

python3 mcp_trust_check.py \
  "$HOME/Library/Application Support/Claude/Claude Extensions" \
  "$HOME/.hermes" --claude-config

見つかった SDK のコピーは27、指摘は HIGH 0件、MEDIUM 56件(すべて版が古いというもの)、INFO 4件でした。中身を構成要素ごとに整理したのが図9です。

図9 自分の Mac での点検結果

Claude Desktop の拡張機能はどれも修正版より古い SDK を同梱していましたが、マニフェストを見るとすべて stdio で起動される MCP サーバーでした。図2の最初の分岐で「関係ない」に進む構成で、MEDIUM の大半はこれらに対するものです。

拡張機能のコード(node_modules を除く)も検索したところ、HTTP の MCP クライアントを持っていたのは Apify の拡張機能だけでした。これは Apify 上の MCP サーバーに接続するためのもので、SDK の OAuth プロバイダは使わず、自分の API トークンを authorization: Bearer ... ヘッダとして付けて StreamableHTTPClientTransport で接続しています。図2の2つめの分岐で「issuer の件は対象外」に進む形です。

ただ、同梱の SDK 1.29.0 は、TypeScript SDK の別のアドバイザリ GHSA-6prh-2h8m-c8cw(2026-10-02 公開、Moderate 6.5、1.32.0 で修正)の影響範囲に入っています。HTTP のクライアントが別オリジンへのリダイレクトに従い、requestInit.headers で付けた独自ヘッダや、307/308 のときはリクエスト本文まで送り直してしまうというものです。アドバイザリには「On current runtimes fetch already drops the Authorization header across origins.」とあり、Apify の拡張機能が付けているのは Authorization ヘッダなので、トークンそのものは送り直されないはずです(Claude Desktop に同梱の Node で実際に試してはいないので、推測です)。セッション ID のヘッダは送られる可能性があります。拡張機能の更新を待つ案件として控えておきました。

Hermes Agent は事情が少し違います。INFO の4件はすべてここで、SDK の対話型 OAuthClientProvider を継承して HTTP の MCP サーバーに OAuth で接続する機能を持っており、入っている mcp 2.0.0 はアドバイザリの影響範囲(2.0.0a1 以上 2.2.0 未満)に入っています。設定ファイルを確認したところ、MCP サーバーは1つも登録されていなかったので、今のところこの経路は使われていません。ただ、HTTP の MCP サーバーを OAuth 付きで1つ追加した時点で図2の黄色の枠に入るので、追加する前に SDK を 2.2.0 以上に上げる必要があります。

このほか、Codex の設定には HTTP で接続する MCP サーバーが1つありましたが、Codex は Rust で書かれていて、点検の対象(Python・TypeScript・Go の公式 SDK)の外なので、ここでは扱っていません。

対応のチェックリスト

最後に、アドバイザリの内容と今回の検証から、やることをまとめておきます。図2の判定で対象になった方は、上から順に確認してください。

  1. HTTP で MCP サーバーに OAuth 接続するクライアントがあるかを確かめる。stdio だけなら今回の件は関係ありません。
  2. Python は mcp 1.30.0 以上(1.x)か 2.2.0 以上、TypeScript は @modelcontextprotocol/sdk 1.31.0 以上か @modelcontextprotocol/client 2.2.0 以上に上げる。
  3. ClientCredentialsOAuthProvider と PrivateKeyJWTOAuthProvider(Python)、同梱のプロバイダ(TypeScript)に、期待する issuer を渡す。Python の RFC7523OAuthClientProvider は使うのをやめる。
  4. 保存済みのクライアント情報や資格情報を一度消すか、issuer を書き足す。古い保存データは issuer に結び付いていません。
  5. 1.x 系の Python では、試験時に -W error::DeprecationWarning を付けて付け忘れを検出する。
  6. 信頼できない MCP サーバーに接続した可能性があるクライアントは、シークレットや署名鍵を作り直し、トークンを失効させる。
  7. 対話型サインインで接続する MCP サーバーと認可サーバーの組み合わせは、プロトコルの外で許可リストとして管理する。

点検スクリプトは手順2と3の確認を機械的にやるためのものです。正規表現で見ているので、プロバイダを別の関数で包んでいる場合などは見逃すことがあり、何も出なかったからといって安全とは言えません。再現しているのも Python SDK だけで、TypeScript と Go の項目はアドバイザリと CSA の研究ノートの記述に依拠しています。不具合や誤検知を見つけたら Issue で教えてください。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?