mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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>
80 lines
2.9 KiB
Python
80 lines
2.9 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
from headroom.providers.proxy_targets import (
|
|
api_target,
|
|
select_passthrough_base_url,
|
|
vertex_target_for_location,
|
|
)
|
|
from headroom.providers.registry import DEFAULT_VERTEX_API_URL
|
|
from headroom.proxy import upstream_guard
|
|
|
|
|
|
def _proxy(**legacy_targets: str):
|
|
class Runtime:
|
|
@staticmethod
|
|
def api_target(provider: str) -> str:
|
|
return f"https://runtime.{provider}.test"
|
|
|
|
@staticmethod
|
|
def model_metadata_provider(headers) -> str: # type: ignore[no-untyped-def]
|
|
return "anthropic" if headers.get("x-api-key") else "openai"
|
|
|
|
return type("Proxy", (), {**legacy_targets, "provider_runtime": Runtime()})()
|
|
|
|
|
|
def test_api_target_prefers_legacy_proxy_attrs() -> None:
|
|
proxy = _proxy(ANTHROPIC_API_URL="https://legacy.anthropic.test")
|
|
|
|
assert api_target(proxy, "anthropic") == "https://legacy.anthropic.test"
|
|
assert api_target(proxy, "openai") == "https://runtime.openai.test"
|
|
|
|
|
|
def test_vertex_target_for_location_derives_region_when_default_configured() -> None:
|
|
proxy = _proxy(VERTEX_API_URL=DEFAULT_VERTEX_API_URL)
|
|
|
|
assert vertex_target_for_location(proxy, "europe-west1") == (
|
|
"https://europe-west1-aiplatform.googleapis.com"
|
|
)
|
|
assert vertex_target_for_location(proxy, "global") == "https://aiplatform.googleapis.com"
|
|
|
|
|
|
def test_vertex_target_for_location_honors_explicit_gateway() -> None:
|
|
proxy = _proxy(VERTEX_API_URL="https://vertex-gateway.example")
|
|
|
|
assert vertex_target_for_location(proxy, "europe-west1") == "https://vertex-gateway.example"
|
|
|
|
|
|
def test_select_passthrough_base_url_handles_special_auth_modes() -> None:
|
|
proxy = _proxy(
|
|
ANTHROPIC_API_URL="https://legacy.anthropic.test",
|
|
OPENAI_API_URL="https://legacy.openai.test",
|
|
GEMINI_API_URL="https://legacy.gemini.test",
|
|
)
|
|
|
|
assert select_passthrough_base_url(proxy, {"chatgpt-account-id": "acct"}) == (
|
|
"https://chatgpt.com"
|
|
)
|
|
assert select_passthrough_base_url(proxy, {"x-goog-api-key": "test"}) == (
|
|
"https://legacy.gemini.test"
|
|
)
|
|
# The Azure branch honours the override only after the SSRF guard clears
|
|
# the destination (CVE-2026-77775), and `azure.example` does not resolve.
|
|
# Pin a public answer so this stays a test of target *precedence*.
|
|
with patch.object(
|
|
upstream_guard.socket,
|
|
"getaddrinfo",
|
|
return_value=[(None, None, None, None, ("20.10.10.10", 443))],
|
|
):
|
|
assert (
|
|
select_passthrough_base_url(
|
|
proxy,
|
|
{"api-key": "azure", "x-headroom-base-url": "https://azure.example/base/"},
|
|
)
|
|
== "https://azure.example/base"
|
|
)
|
|
assert select_passthrough_base_url(proxy, {"x-api-key": "anthropic"}) == (
|
|
"https://legacy.anthropic.test"
|
|
)
|
|
assert select_passthrough_base_url(proxy, {}) == "https://legacy.openai.test"
|