Skip to content

Commit 3e3c409

Browse files
chopratejasTejas Chopraclaude
authored
fix(security): validate caller-supplied upstreams on every resolution path (#3195)
## Summary CVE-2026-77775 (SSRF via `x-headroom-base-url`) is **not fully fixed on current `main`**. The advisory lists 0.36.1 as the last affected version; one route still forwards to any destination a caller names. `upstream_guard.is_safe_upstream_url` was added and wired into `/v1/messages` and the catch-all passthrough. But `select_passthrough_base_url` moved from `providers/proxy_routes.py` to `providers/proxy_targets.py`, and the guard did not follow it. Its Azure branch returns the header verbatim whenever an `api-key` header is present — **both values are caller-supplied** — and `POST /v1/alpha/search` resolves its upstream through that helper without checking the header itself. ## Verified, not inferred Against the current tree, with a listener on loopback standing in for an internal service: ``` proxy status : 200 internal service hit : 1 time(s) Authorization it received : 'Bearer SECRET-CLIENT-TOKEN' internal body relayed back : True ``` The caller's credentials are forwarded to the attacker-named host and the internal response is relayed back. After this change: `400`, zero hits, nothing relayed. A sweep of all 99 routes isolates exactly one leak on unfixed code — `POST /v1/alpha/search` with `api-key` — and zero after. ## 1. The missing enforcement **Guarded at the chokepoint, not just the route.** `select_passthrough_base_url` now validates before returning, in `proxy_targets.py` and in the parallel copy in `providers/registry.py`, so a future caller that forgets the header check cannot reopen this. `/v1/alpha/search` also rejects explicitly with 400, matching its sibling routes. ## 2. A second gap in the address policy RFC 6598 shared address space (`100.64.0.0/10`) is not `is_private`, so it passed the guard — while routing to ISP and cloud-internal infrastructure. `_is_internal_address` now also rejects anything not globally routable. Verified over a 27-vector battery — 0 bypasses, public control unaffected: | Vector | Before | After | |---|---|---| | `100.64.0.0/10` shared address space | **allowed** | blocked | | `198.18/15`, TEST-NET, `240/4` | **allowed** | blocked | | 6to4 / Teredo embedding internal IPv4 | **allowed** | blocked | | NAT64 `64:ff9b::/96` embedding loopback | **allowed** | blocked | | loopback, RFC1918, link-local, metadata, IPv4-mapped, userinfo tricks | blocked | blocked | | multicast `224.0.0.1` | blocked | blocked | | public `8.8.8.8` | allowed | allowed | The category checks are **kept alongside** `is_global` rather than replaced — `is_global` is `True` for multicast, so a replacement would have regressed. NAT64 also reports as global, so its embedded IPv4 is extracted and judged on its own. ## 3. Unauthenticated stall via the resolver `socket.getaddrinfo` takes no timeout and runs on the calling thread — the event loop. Since the hostname is caller-supplied, a deliberately slow-resolving name stalled every other in-flight request; a handful of concurrent requests made the proxy unresponsive, unauthenticated. Resolution now runs in a small dedicated pool with a budget (`HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S`, default 3s) and fails closed on overrun, which bounds every caller including the synchronous chokepoint. `is_safe_upstream_url_async` runs the lookup off the loop, and the three route handlers that validate a caller-supplied upstream now await it. Caching was deliberately avoided: a TTL cache in front of a security decision invites poisoning, and would widen the rebinding window rather than narrow it. ## Why this survived The existing tests unit-tested the guard's *logic* but never asserted it was *reached*. Added enforcement tests at the sinks plus a **sweep over the whole route table** that fails if any route forwards to a loopback address — so the next unguarded upstream resolution fails in CI rather than in a CVE. All new tests were confirmed failing against the unfixed tree and passing after. ## Known residual — deliberately not addressed **DNS rebinding.** Validation and connection resolve the host separately, so a low-TTL answer can differ between them. Closing this needs connection-time pinning in the shared `http_client` transport, which carries every request in the proxy — too broad to fold into this patch. It should not be described as fixed. ## Compatibility An endpoint that does not resolve publicly (split-horizon, on-prem) is now rejected where it previously passed unvalidated. `HEADROOM_ALLOWED_BASE_URLS` is the documented opt-in, covered by test. Three existing tests used fictional hostnames and legitimately began failing; DNS is pinned in them so they keep testing target precedence rather than depending on the missing guard. Separately: `docker-compose.yml` has already been hardened since the advisory — `HEADROOM_PROXY_TOKEN` is now mandatory and ports are loopback-only — so the "exposed by default" multiplier the advisory cites no longer applies to the shipped compose. Full suite: the 3 failures outside this area (`test_learn/test_integration`, `test_release_workflows::test_no_native_tls_in_wheel_build_tree`, and a `test_graceful_shutdown` ordering flake) reproduce on clean `main` and are unrelated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1617f83 commit 3e3c409

8 files changed

Lines changed: 391 additions & 25 deletions

headroom/providers/proxy_routes.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
custom_base_passthrough_telemetry as _custom_base_passthrough_telemetry,
6868
)
6969
from headroom.proxy.request_scope import normalize_request_path
70-
from headroom.proxy.upstream_guard import is_safe_upstream_url
70+
from headroom.proxy.upstream_guard import is_safe_upstream_url_async
7171

7272
logger = logging.getLogger("headroom.proxy.routes")
7373

@@ -267,7 +267,7 @@ async def anthropic_messages(request: Request):
267267
# OpenAI-compatible and generic passthrough routes.
268268
custom_base = request.headers.get("x-headroom-base-url", "").strip()
269269
if custom_base:
270-
if not is_safe_upstream_url(custom_base):
270+
if not await is_safe_upstream_url_async(custom_base):
271271
logger.warning("rejecting unsafe x-headroom-base-url: %r", custom_base)
272272
raise HTTPException(status_code=400, detail="Rejected unsafe upstream base URL")
273273
return await proxy.handle_anthropic_messages(
@@ -495,6 +495,14 @@ async def codex_alpha_search(request: Request):
495495
chatgpt_response = await _handle_chatgpt_codex_alpha_search(request, proxy)
496496
if chatgpt_response is not None:
497497
return chatgpt_response
498+
# This route resolves a caller-named upstream like the catch-all does,
499+
# so it needs the same rejection. Without it a client could point the
500+
# proxy at loopback/RFC1918/cloud-metadata and read the response back
501+
# (CVE-2026-77775).
502+
custom_base = request.headers.get("x-headroom-base-url", "").strip()
503+
if custom_base and not await is_safe_upstream_url_async(custom_base):
504+
logger.warning("rejecting unsafe x-headroom-base-url: %r", custom_base)
505+
raise HTTPException(status_code=400, detail="Rejected unsafe upstream base URL")
498506
return await proxy.handle_passthrough(
499507
request,
500508
_select_passthrough_base_url(proxy, dict(request.headers)),
@@ -510,7 +518,7 @@ async def codex_alpha_search(request: Request):
510518
async def passthrough(request: Request, path: str):
511519
custom_base = request.headers.get("x-headroom-base-url")
512520
if custom_base:
513-
if not is_safe_upstream_url(custom_base):
521+
if not await is_safe_upstream_url_async(custom_base):
514522
logger.warning("rejecting unsafe x-headroom-base-url: %r", custom_base)
515523
raise HTTPException(status_code=400, detail="Rejected unsafe upstream base URL")
516524
base_url = custom_base.rstrip("/")

headroom/providers/proxy_targets.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import logging
56
from collections.abc import Mapping
67
from typing import Any, cast
78

@@ -13,6 +14,7 @@
1314
from headroom.providers.codex import resolve_codex_routing
1415
from headroom.providers.codex.endpoints import CHATGPT_BACKEND_API_URL
1516
from headroom.providers.vertex import vertex_target_for_location as _vertex_target_for_location
17+
from headroom.proxy.upstream_guard import is_safe_upstream_url
1618

1719
LEGACY_API_TARGET_ATTRS: dict[str, str] = {
1820
"anthropic": "ANTHROPIC_API_URL",
@@ -34,6 +36,9 @@ def vertex_target_for_location(proxy: Any, location: str) -> str:
3436
return _vertex_target_for_location(api_target(proxy, "vertex"), location)
3537

3638

39+
logger = logging.getLogger("headroom.proxy")
40+
41+
3742
def select_passthrough_base_url(
3843
proxy: Any, headers: Mapping[str, str], path: str | None = None
3944
) -> str:
@@ -46,7 +51,14 @@ def select_passthrough_base_url(
4651
if headers.get("api-key"):
4752
azure_base = headers.get("x-headroom-base-url", "")
4853
if azure_base:
49-
return azure_base.rstrip("/")
54+
# Validate here, not only at the routes. `api-key` is attacker-
55+
# supplied too, so this branch is reachable by anyone who can send
56+
# a header, and it returns the destination the caller named. Routes
57+
# that forgot to guard turned the proxy into an SSRF relay into
58+
# loopback/RFC1918/cloud-metadata space (CVE-2026-77775).
59+
if is_safe_upstream_url(azure_base):
60+
return azure_base.rstrip("/")
61+
logger.warning("ignoring unsafe x-headroom-base-url override: %r", azure_base)
5062
provider_name = proxy.provider_runtime.model_metadata_provider(headers)
5163
target = api_target(proxy, provider_name)
5264
if (

headroom/providers/registry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from headroom.providers.claude import DEFAULT_API_URL as DEFAULT_ANTHROPIC_API_URL
1313
from headroom.providers.codex import DEFAULT_API_URL as DEFAULT_OPENAI_API_URL
1414
from headroom.providers.gemini import DEFAULT_API_URL as DEFAULT_GEMINI_API_URL
15+
from headroom.proxy.upstream_guard import is_safe_upstream_url
1516

1617
DEFAULT_CLOUDCODE_API_URL = "https://cloudcode-pa.googleapis.com"
1718
DEFAULT_VERTEX_API_URL = "https://us-central1-aiplatform.googleapis.com"
@@ -79,7 +80,9 @@ def select_passthrough_base_url(self, headers: Mapping[str, str]) -> str:
7980
return self.api_targets.gemini
8081
if headers.get("api-key"):
8182
azure_base = headers.get("x-headroom-base-url", "")
82-
if azure_base:
83+
# Same SSRF guard as `proxy_targets.select_passthrough_base_url`;
84+
# both resolve a caller-named upstream (CVE-2026-77775).
85+
if azure_base and is_safe_upstream_url(azure_base):
8386
return azure_base.rstrip("/")
8487
return self.api_targets.openai
8588

headroom/proxy/upstream_guard.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,38 @@
2020

2121
from __future__ import annotations
2222

23+
import asyncio
2324
import ipaddress
2425
import os
2526
import socket
27+
from concurrent.futures import ThreadPoolExecutor
28+
from concurrent.futures import TimeoutError as _FutureTimeout
2629
from urllib.parse import urlparse
2730

2831
ALLOWED_BASE_URLS_ENV = "HEADROOM_ALLOWED_BASE_URLS"
2932

33+
# `socket.getaddrinfo` has no timeout parameter and runs on whatever thread
34+
# calls it -- which, for the proxy, is the event loop. A caller-supplied host
35+
# that resolves slowly therefore stalls every other in-flight request, so the
36+
# lookup is bounded here and fails closed when it overruns. Callers already in
37+
# async context should prefer `is_safe_upstream_url_async`, which keeps the
38+
# wait off the loop entirely.
39+
RESOLVE_TIMEOUT_ENV = "HEADROOM_UPSTREAM_RESOLVE_TIMEOUT_S"
40+
_DEFAULT_RESOLVE_TIMEOUT_S = 3.0
41+
_RESOLVER_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="hr-upstream-dns")
42+
43+
44+
def _resolve_timeout_seconds() -> float:
45+
raw = (os.environ.get(RESOLVE_TIMEOUT_ENV) or "").strip()
46+
if not raw:
47+
return _DEFAULT_RESOLVE_TIMEOUT_S
48+
try:
49+
value = float(raw)
50+
except ValueError:
51+
return _DEFAULT_RESOLVE_TIMEOUT_S
52+
return value if value > 0 else _DEFAULT_RESOLVE_TIMEOUT_S
53+
54+
3055
_SAFE_SCHEMES = {"http", "https", "ws", "wss"}
3156

3257

@@ -58,19 +83,53 @@ def _allowlisted_destinations() -> tuple[set[str], set[tuple[str, str, int]]] |
5883
return hosts, origins
5984

6085

86+
# RFC 6052 / RFC 8215: these IPv6 prefixes embed an IPv4 address in their low
87+
# 32 bits, and `ipaddress` reports the well-known one as globally routable. On a
88+
# NAT64 network `64:ff9b::7f00:1` reaches 127.0.0.1, so the embedded address is
89+
# what has to be judged. 6to4, Teredo and IPv4-mapped forms are already caught
90+
# by the `is_global` test below.
91+
_NAT64_PREFIXES = (
92+
ipaddress.IPv6Network("64:ff9b::/96"),
93+
ipaddress.IPv6Network("64:ff9b:1::/48"),
94+
)
95+
96+
97+
def _nat64_embedded_ipv4(addr: ipaddress.IPv6Address) -> ipaddress.IPv4Address | None:
98+
if not any(addr in prefix for prefix in _NAT64_PREFIXES):
99+
return None
100+
try:
101+
return ipaddress.IPv4Address(int(addr) & 0xFFFFFFFF)
102+
except (ipaddress.AddressValueError, ValueError): # pragma: no cover - defensive
103+
return None
104+
105+
61106
def _is_internal_address(ip: str) -> bool:
62107
try:
63108
addr = ipaddress.ip_address(ip)
64109
except ValueError:
65110
return True # unparseable (e.g. scoped link-local) -> treat as unsafe
66-
return (
111+
if (
67112
addr.is_private
68113
or addr.is_loopback
69114
or addr.is_link_local
70115
or addr.is_reserved
71116
or addr.is_multicast
72117
or addr.is_unspecified
73-
)
118+
):
119+
return True
120+
# Anything not globally routable. This is what catches RFC 6598 shared
121+
# address space (100.64.0.0/10) -- which `is_private` does not flag, and
122+
# which reaches ISP and cloud-internal infrastructure -- along with
123+
# benchmarking (198.18/15), TEST-NET, 240/4, 6to4 and Teredo tunnels that
124+
# embed an internal IPv4, and any future special-use range the stdlib
125+
# learns about.
126+
if not addr.is_global:
127+
return True
128+
if isinstance(addr, ipaddress.IPv6Address):
129+
embedded = _nat64_embedded_ipv4(addr)
130+
if embedded is not None and _is_internal_address(str(embedded)):
131+
return True
132+
return False
74133

75134

76135
def is_safe_upstream_url(url: str) -> bool:
@@ -101,10 +160,22 @@ def is_safe_upstream_url(url: str) -> bool:
101160
return (parsed.scheme.lower(), host.lower(), port) in origins
102161

103162
try:
104-
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
105-
except OSError:
163+
infos = _RESOLVER_POOL.submit(
164+
socket.getaddrinfo, host, None, 0, 0, socket.IPPROTO_TCP
165+
).result(timeout=_resolve_timeout_seconds())
166+
except (OSError, _FutureTimeout):
106167
# Resolution and connection are separate operations, so allowing a DNS
107168
# miss here would fail open if the name resolves on the later lookup.
169+
# A lookup that overruns the budget is treated the same way.
108170
# Operators can explicitly allowlist split-horizon/internal endpoints.
109171
return False
110172
return all(not _is_internal_address(str(info[4][0])) for info in infos)
173+
174+
175+
async def is_safe_upstream_url_async(url: str) -> bool:
176+
"""Async form of :func:`is_safe_upstream_url` for event-loop callers.
177+
178+
Same policy; the blocking resolution runs off the loop so a hostile or
179+
slow-resolving hostname cannot stall unrelated in-flight requests.
180+
"""
181+
return await asyncio.to_thread(is_safe_upstream_url, url)

tests/test_provider_proxy_routes.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from fastapi.testclient import TestClient
1111

1212
from headroom.providers.codex.runtime import CodexRoutingDecision
13+
from headroom.proxy import upstream_guard
1314
from headroom.proxy.project_context import get_current_project
1415
from headroom.proxy.server import HeadroomProxy, ProxyConfig, create_app
1516

@@ -430,12 +431,21 @@ def test_proxy_route_helpers_prefer_legacy_targets_and_gemini_passthrough() -> N
430431
assert proxy_routes._select_passthrough_base_url(proxy, {"x-goog-api-key": "test"}) == (
431432
"https://legacy.gemini.test"
432433
)
433-
assert (
434-
proxy_routes._select_passthrough_base_url(
435-
proxy, {"api-key": "azure", "x-headroom-base-url": "https://azure.example/base/"}
434+
# The azure branch honours the override, but only after the SSRF guard
435+
# clears the destination (CVE-2026-77775). `azure.example` does not
436+
# resolve, and the guard fails closed on resolution failure, so pin a
437+
# public answer to keep this assertion about target *precedence*.
438+
with patch.object(
439+
upstream_guard.socket,
440+
"getaddrinfo",
441+
return_value=[(None, None, None, None, ("20.10.10.10", 443))],
442+
):
443+
assert (
444+
proxy_routes._select_passthrough_base_url(
445+
proxy, {"api-key": "azure", "x-headroom-base-url": "https://azure.example/base/"}
446+
)
447+
== "https://azure.example/base"
436448
)
437-
== "https://azure.example/base"
438-
)
439449
assert proxy_routes._select_passthrough_base_url(proxy, {"api-key": "azure"}) == (
440450
"https://legacy.anthropic.test"
441451
)

tests/test_provider_proxy_targets.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from __future__ import annotations
22

3+
from unittest.mock import patch
4+
35
from headroom.providers.proxy_targets import (
46
api_target,
57
select_passthrough_base_url,
68
vertex_target_for_location,
79
)
810
from headroom.providers.registry import DEFAULT_VERTEX_API_URL
11+
from headroom.proxy import upstream_guard
912

1013

1114
def _proxy(**legacy_targets: str):
@@ -56,13 +59,21 @@ def test_select_passthrough_base_url_handles_special_auth_modes() -> None:
5659
assert select_passthrough_base_url(proxy, {"x-goog-api-key": "test"}) == (
5760
"https://legacy.gemini.test"
5861
)
59-
assert (
60-
select_passthrough_base_url(
61-
proxy,
62-
{"api-key": "azure", "x-headroom-base-url": "https://azure.example/base/"},
62+
# The Azure branch honours the override only after the SSRF guard clears
63+
# the destination (CVE-2026-77775), and `azure.example` does not resolve.
64+
# Pin a public answer so this stays a test of target *precedence*.
65+
with patch.object(
66+
upstream_guard.socket,
67+
"getaddrinfo",
68+
return_value=[(None, None, None, None, ("20.10.10.10", 443))],
69+
):
70+
assert (
71+
select_passthrough_base_url(
72+
proxy,
73+
{"api-key": "azure", "x-headroom-base-url": "https://azure.example/base/"},
74+
)
75+
== "https://azure.example/base"
6376
)
64-
== "https://azure.example/base"
65-
)
6677
assert select_passthrough_base_url(proxy, {"x-api-key": "anthropic"}) == (
6778
"https://legacy.anthropic.test"
6879
)

tests/test_provider_registry_extended.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
from types import SimpleNamespace
55
from typing import Any
6+
from unittest.mock import patch
67

78
import pytest
89

@@ -13,6 +14,7 @@
1314
create_proxy_backend,
1415
format_backend_status,
1516
)
17+
from headroom.proxy import upstream_guard
1618

1719

1820
class DummyStorage:
@@ -85,12 +87,20 @@ def test_proxy_provider_runtime_selects_targets_and_providers() -> None:
8587
assert runtime.select_passthrough_base_url({"x-goog-api-key": "test"}) == (
8688
"https://gemini.example"
8789
)
88-
assert (
89-
runtime.select_passthrough_base_url(
90-
{"api-key": "azure-key", "x-headroom-base-url": "https://azure.example/openai/"}
90+
# The Azure branch honours the override only after the SSRF guard clears
91+
# the destination (CVE-2026-77775), and `azure.example` does not resolve.
92+
# Pin a public answer so this stays a test of target *precedence*.
93+
with patch.object(
94+
upstream_guard.socket,
95+
"getaddrinfo",
96+
return_value=[(None, None, None, None, ("20.10.10.10", 443))],
97+
):
98+
assert (
99+
runtime.select_passthrough_base_url(
100+
{"api-key": "azure-key", "x-headroom-base-url": "https://azure.example/openai/"}
101+
)
102+
== "https://azure.example/openai"
91103
)
92-
== "https://azure.example/openai"
93-
)
94104
assert runtime.select_passthrough_base_url({}) == "https://openai.example"
95105

96106

0 commit comments

Comments
 (0)