3e3c409436
## 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>