FlawOpen

flawopen.com/Teardowns/cve-2026-71416-headroom-websocket-origin-hijacking

● CVE-2026-71416 · CVSS 8.8 · 高
セキュリティ研究 · FlawOpen

CVE-2026-71416:HeadroomのWebSocket Origin乗っ取り

HeadroomはWebSocketのOriginを検証せず、閲覧したサイトからプロキシ経由でLLM要求を中継できました。0.35.0以降へ更新してください。

💡 わかりやすい解説 (ELI5)

受付係が訪問者の身分証を確かめず、誰でも専用窓口に通してしまう状態です。Originはその身分証に当たります。保存済みAPIキーを使える接続を開く前に、プロキシが確認しなければなりません。

主要な概念と専門用語

WebSocket
HTTP Upgrade要求から始まり、双方向に継続してメッセージをやり取りする接続です。
Originヘッダー
要求を開始したページを示すため、ブラウザーが送るスキーム、ホスト、ポートです。WebSocketハンドシェイクではサーバー自身の信頼ポリシーが必要で、CORSヘッダーはその確認の代わりになりません。
CSWSH
Cross-Site WebSocket Hijacking。不審なページが到達可能なサービスへWebSocketを開き、サービスが接続元を確認しないことで成立します。
暗黙に利用可能な資格情報
環境変数のAPIキーのように、プロセスが既に利用でき、クライアント自身の資格情報がない要求にも使われ得る秘密情報です。
CWE-1385
WebSocketでOrigin検証が欠落している弱点です。エンドポイントが通信元を許可済みか確認しません。

フレームワーク対応

根本原因の分析 (Root Cause)

Headroomの/v1/responses WebSocketハンドラーは、ハンドシェイクのOriginを確認する前にブラウザー接続を受け入れていました。その後、プロキシは自身の環境変数からOPENAI_API_KEYを上流要求に加えることができました。ブラウザーの同一オリジンポリシーだけでは、JavaScriptによる別の到達可能なホストへのWebSocket開始を防げません。ブラウザーは通常Originを送信するため、サーバー側でポリシー外のOriginを拒否する必要があります。したがって、被害者のブラウザーから脆弱なHeadroomプロキシへ到達でき、プロキシに有効な資格情報または連携機能があることが条件です。アドバイザリによると、受け入れられた接続から任意のLLM要求を送信できます。ホスト上のコマンド実行は、要求がコマンドを実行する設定済みツールを使える場合に限られ、ヘッダー検証の欠落だけで自動的に起きるものではありません。CNAは0.35.0未満を影響範囲とし、GitHubアドバイザリのパッケージメタデータは0.23.0を挙げています。0.35.0以降へ更新し、インストール済みの版を確認してください。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

プロキシが上流への権限を保持

HeadroomプロセスはOPENAI_API_KEYにOpenAIキーを保持する場合があります。ブラウザークライアントがキーを送る必要はありません。

ステップ 2

ブラウザーで信頼できないページを開く

ブラウザーがHeadroomのWebSocketエンドポイントに到達できると、ページのスクリプトは接続を試みられます。ブラウザーはハンドシェイクにページのOriginを含めます。

ステップ 3

脆弱なハンドラーがOriginポリシーなしで接続を受け入れる

影響を受けるハンドラーでは、信頼できないOriginを拒否せずwebsocket.accept()に到達します。同一オリジンポリシーはサーバー側のWebSocket認可ではありません。

ステップ 4

プロキシが認証済み要求を中継

Headroomは不足した上流Authorizationヘッダーを環境変数で補えます。攻撃者が管理するページはプロキシの権限で要求を発生させられます。影響は設定ツール、資格情報、ネットワーク到達性、ユーザー操作に左右されます。

ステップ 5

修正はルーティング前に許可されないOriginを拒否

上流パッチはセッションを受け入れて中継する前にOriginを検証します。ブラウザーは既定でloopbackに制限され、明示的なOriginを設定でき、拒否した接続はコード1008で閉じられます。

ソースコード比較:脆弱 vs 堅牢化

アドバイザリはhandle_openai_responses_ws()を特定し、websocket.accept()前に検査がない点を説明しています。上流コミットc632023は_is_allowed_websocket_origin()を加え、許可されないOriginをルーティング前に拒否します。Originのないネイティブクライアントは維持され、ブラウザーOriginは既定でloopbackに限定され、HEADROOM_WS_ORIGINSまたはHEADROOM_CORS_ORIGINSで明示設定できます。この短い例は変更された信頼境界を示し、ヘルパー全体は省略しています。上流の回帰テストはOriginなし、loopback、リモート、不正形式、明示設定を検証します。

ソース抜粋: headroom/proxy/handlers/openai.py

脆弱な実装
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
    # 脆弱な形式: オリジン検証の前にブラウザーセッションを受け入れる。
    await websocket.accept()
    await self._relay_responses_session(websocket)
堅牢化されたセキュアパッチ
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
    ws_headers = dict(websocket.headers)
    if not _is_allowed_websocket_origin(ws_headers):
        logger.warning(
            "event=websocket_origin_not_allowed origin=%r",
            _header_get(ws_headers, "origin"),
        )
        await websocket.close(code=1008, reason="origin not allowed")
        return

    # ポリシーで許可されたブラウザーセッションだけが上流ルーティングに進む。
    await websocket.accept()
    await self._relay_responses_session(websocket)

エンジニアリング&システム堅牢化チェックリスト

参考資料