Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 31 additions & 26 deletions backend/app/routes/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down