From 05316b483e2004d04d2e2bddcd7cfd506727c352 Mon Sep 17 00:00:00 2001 From: Jerrett Davis Date: Thu, 13 Aug 2026 23:11:52 -0500 Subject: [PATCH] fix(proxy): validate Google API route hosts --- headroom/proxy/handlers/anthropic.py | 20 +++++++++++++++++++- tests/test_proxy_handler_helpers.py | 19 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 67a3b0fa0..09ef90d70 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -14,6 +14,7 @@ import time import uuid from datetime import datetime from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit from headroom.proxy.stage_timer import StageTimer, emit_stage_timings_log @@ -50,6 +51,23 @@ from headroom.proxy.outcome import RequestOutcome logger = logging.getLogger("headroom.proxy") +def _is_googleapis_endpoint(value: object) -> bool: + """Return whether *value* targets Google APIs by parsed hostname. + + A substring check would also trust attacker-controlled hosts such as + ``googleapis.com.example.test``. URL parsing plus a label-boundary suffix + check accepts Google API subdomains without widening the route gate. + """ + raw = str(value).strip() + if not raw: + return False + try: + hostname = (urlsplit(raw).hostname or "").rstrip(".").lower() + except ValueError: + return False + return hostname == "googleapis.com" or hostname.endswith(".googleapis.com") + + class _AnthropicTurnHookUsage: """Usage from hook-triggered Anthropic calls the main response omits. @@ -3020,7 +3038,7 @@ class AnthropicHandlerMixin: if ( not upstream_base_url or getattr(self, "anthropic_backend", None) is not None - or "googleapis.com" in str(upstream_base_url) + or _is_googleapis_endpoint(upstream_base_url) ) else None ), diff --git a/tests/test_proxy_handler_helpers.py b/tests/test_proxy_handler_helpers.py index 65eabe804..15b2cd869 100644 --- a/tests/test_proxy_handler_helpers.py +++ b/tests/test_proxy_handler_helpers.py @@ -11,7 +11,7 @@ import httpx import pytest from fastapi.responses import StreamingResponse -from headroom.proxy.handlers.anthropic import AnthropicHandlerMixin +from headroom.proxy.handlers.anthropic import AnthropicHandlerMixin, _is_googleapis_endpoint from headroom.proxy.handlers.openai import ( OpenAIHandlerMixin, _decode_openai_bearer_payload, @@ -35,6 +35,23 @@ def _jwt(payload: object) -> str: return f"{encode(header)}.{encode(payload)}." +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://us-central1-aiplatform.googleapis.com/v1", True), + ("https://googleapis.com/v1", True), + ("https://AIPLATFORM.GOOGLEAPIS.COM./v1", True), + ("https://googleapis.com.example.test/v1", False), + ("https://notgoogleapis.com/v1", False), + ("https://googleapis.com@attacker.test/v1", False), + ("not a url", False), + ("", False), + ], +) +def test_googleapis_endpoint_gate_uses_hostname_boundary(url: str, expected: bool) -> None: + assert _is_googleapis_endpoint(url) is expected + + class _ImageCompressor: def __init__(self, compressed_message): self._compressed_message = compressed_message