diff --git a/backend/app/routes/proxy.py b/backend/app/routes/proxy.py index dfc898d..a776189 100644 --- a/backend/app/routes/proxy.py +++ b/backend/app/routes/proxy.py @@ -93,22 +93,34 @@ def _upstream_diagnostics(candidate: httpx.Response, *, stream_started: bool = F } -# Request headers we must never forward upstream. -_STRIP_REQUEST_HEADERS = { - "host", - "content-length", - "accept-encoding", # force identity so we can parse the SSE stream - "authorization", # replaced with the account's OAuth bearer - "x-api-key", # OAuth uses bearer, not api-key - "connection", - "keep-alive", - "transfer-encoding", - "te", - "trailer", - "upgrade", - "proxy-authorization", - "proxy-authenticate", -} +# The request headers Claude Code itself sends on /v1/messages (captured from claude-cli 2.1.263), minus auth and +# transport. Everything else is dropped: credentials are replaced per account, and gateway-added headers +# (X-Forwarded-*, X-Real-Ip, cookies) would leak client addresses and this deployment's hostnames to the provider. +_FORWARDED_REQUEST_HEADERS = frozenset( + { + "accept", + "content-type", + "user-agent", + "x-app", + "x-claude-code-session-id", + "anthropic-version", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "x-stainless-arch", + "x-stainless-lang", + "x-stainless-os", + "x-stainless-package-version", + "x-stainless-retry-count", + "x-stainless-runtime", + "x-stainless-runtime-version", + "x-stainless-timeout", + } +) + + +def _forwarded_request_headers(incoming) -> Dict[str, str]: + return {key: value for key, value in incoming.items() if key.lower() in _FORWARDED_REQUEST_HEADERS} + # Response headers the ASGI server sets itself, so we must not pass them back verbatim. _STRIP_RESPONSE_HEADERS = {"content-length", "content-encoding", "transfer-encoding", "connection", "keep-alive"} @@ -227,12 +239,8 @@ def _restore_requested_model_in_sse_frame(frame: bytes, requested_model: object) def _build_upstream_headers(incoming, access_token: str) -> Dict[str, str]: - """Clone the client's headers, strip auth/transport, inject the OAuth bearer and the required anthropic flags.""" - headers: Dict[str, str] = {} - for key, value in incoming.items(): - if key.lower() not in _STRIP_REQUEST_HEADERS: - headers[key] = value - + """Forward the client's API headers, inject the OAuth bearer and the required anthropic flags.""" + headers = _forwarded_request_headers(incoming) headers["Authorization"] = f"Bearer {access_token}" beta = headers.get("anthropic-beta") @@ -248,10 +256,7 @@ def _build_upstream_headers(incoming, access_token: str) -> Dict[str, str]: def _build_fallback_headers(incoming, api_key: str) -> Dict[str, str]: """Build Anthropic API-key headers without subscription-only OAuth beta flags.""" - headers: Dict[str, str] = {} - for key, value in incoming.items(): - if key.lower() not in _STRIP_REQUEST_HEADERS: - headers[key] = value + headers = _forwarded_request_headers(incoming) headers["x-api-key"] = api_key headers.setdefault("anthropic-version", config.ANTHROPIC_VERSION) headers.setdefault("User-Agent", config.CLAUDE_CODE_USER_AGENT) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a789b7c..349861d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -322,6 +322,41 @@ def test_proxy_streaming_relays_and_records(client, admin_headers, seed_account, assert record["model"] == "claude-sonnet-4-6" +@respx.mock +def test_proxy_forwards_only_api_headers_upstream(client, seed_account, make_user): + seed_account("header-account") + key = make_user("header-user") + + respx.route(host="testserver").pass_through() + route = respx.post(ANTHROPIC_MESSAGES).mock( + return_value=httpx.Response(200, json={"model": "claude-haiku-4-5", "usage": {"input_tokens": 1, "output_tokens": 1}}) + ) + client.post( + "/api/v1/messages", + headers={ + "Authorization": f"Bearer {key}", + "X-Forwarded-For": "100.64.0.7", + "X-Real-Ip": "100.64.0.7", + "X-Forwarded-Host": "proxy.internal", + "Cookie": "session=secret", + "X-Debug-Client": "1", + "anthropic-unlisted": "1", + "anthropic-beta": "context-1m-2025-08-07", + "x-stainless-os": "Linux", + "x-app": "cli", + }, + json={"model": "claude-haiku-4-5"}, + ) + + sent = route.calls[0].request.headers + for leaked in ("x-forwarded-for", "x-real-ip", "x-forwarded-host", "cookie", "x-api-key", "x-debug-client", "anthropic-unlisted"): + assert leaked not in sent + assert sent["authorization"] == "Bearer upstream-access-token" + assert "context-1m-2025-08-07" in sent["anthropic-beta"] + assert sent["x-stainless-os"] == "Linux" + assert sent["x-app"] == "cli" + + @respx.mock def test_proxy_fails_over_on_429(client, admin_headers, seed_account, make_user): from app.utils.models.api import AccountStatus