From c41cf444c74a9d190ab5922836122d0d10bc988c Mon Sep 17 00:00:00 2001 From: GUOHAO LIU <94768569+lennney@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:54:58 +0800 Subject: [PATCH] fix(proxy): allow HEAD method on catch-all passthrough route (#2035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Claude Code sends `HEAD /` against `ANTHROPIC_BASE_URL` as a connectivity preflight (UA `Bun/1.4.0`). The proxy catch-all route only accepted `GET/POST/PUT/DELETE`, so `HEAD /` returned 405. This made the preflight read as "endpoint down", obscuring the real Remote Control gate message. Closes #2032 ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) ## Changes Made - Add `"HEAD"` to the catch-all passthrough route methods list (`proxy_routes.py:1017`) - `handle_passthrough` already uses `method=request.method` generically — HEAD is forwarded upstream correctly - Add regression test: `test_head_root_returns_200_not_405` ## Testing - [x] Unit test with TestClient - [x] Adversarial: 10 HEAD variants (root, query, nested paths, URL-encoded, XSS query, custom headers, POST-only routes) - [x] Design scan: verified no other `methods=` definitions need HEAD (specific `@app.get` routes auto-handle HEAD) ``` $ uv run pytest tests/test_proxy_passthrough_integration.py tests/test_proxy_cors.py -q 16 passed, 19 skipped # Adversarial: 10 HEAD variants ALL PASSED: 10/10 ✅ HEAD / → 421 (upstream, not 405) ✅ HEAD /?query → 421 ✅ HEAD /v1/models → 401 ✅ HEAD /health → 404 ✅ HEAD /deep/nested → 404 ✅ HEAD /%E4%B8%AD%E6%96%87 → 404 ✅ HEAD / XSS+null query → 421 ✅ HEAD / x-headroom-base-url → 502 ✅ HEAD / Authorization → 421 ✅ HEAD /v1/messages → 404 ``` ## Real Behavior Proof - Environment: Python 3.12, headroom dev install, Ubuntu 24.04 - Exact command / steps: (1) `python3 -c "import urllib.request; req = urllib.request.Request(http://127.0.0.1:8787/, method=HEAD); print(urllib.request.urlopen(req, timeout=5).status)"` → no longer 405; (2) `uv run pytest tests/test_proxy_passthrough_integration.py::test_head_root_returns_200_not_405` → PASSED; (3) `uv run ruff check . && uv run ruff format --check . && uv run mypy headroom --ignore-missing-imports` → 0 errors - Observed result: HEAD / no longer returns 405. Proxy forwards HEAD upstream for all paths. Claude Code preflight reads the correct 421/redirect instead of falsely reporting proxy down. - Not tested: Windows/macOS (route definition is platform-independent) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: lennney --- headroom/providers/proxy_routes.py | 2 +- tests/test_proxy_passthrough_integration.py | 33 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/headroom/providers/proxy_routes.py b/headroom/providers/proxy_routes.py index b6bd641c3..ddd3795ea 100644 --- a/headroom/providers/proxy_routes.py +++ b/headroom/providers/proxy_routes.py @@ -1032,7 +1032,7 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None: "gemini", ) - @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) + @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"]) async def passthrough(request: Request, path: str): custom_base = request.headers.get("x-headroom-base-url") if custom_base: diff --git a/tests/test_proxy_passthrough_integration.py b/tests/test_proxy_passthrough_integration.py index 1f86b0090..9e893266d 100644 --- a/tests/test_proxy_passthrough_integration.py +++ b/tests/test_proxy_passthrough_integration.py @@ -492,3 +492,36 @@ class TestPassthroughErrorHandling: content=b"not valid json", ) assert response.status_code >= 400 + + +# ============================================================================= +# HEAD request regression test — GH #2032 +# ============================================================================= + + +def test_head_root_returns_200_not_405(monkeypatch: pytest.MonkeyPatch) -> None: + """HEAD / must not return 405; Claude Code uses it as connectivity preflight.""" + from headroom.proxy.server import ProxyConfig, create_app + + # Dummy upstream so the proxy doesn't crash on missing HTTP client. + monkeypatch.setenv("ANTHROPIC_TARGET_API_URL", "https://api.anthropic.example") + + config = ProxyConfig( + optimize=False, + cache_enabled=False, + rate_limit_enabled=False, + cost_tracking_enabled=False, + log_requests=False, + ccr_inject_tool=False, + ccr_handle_responses=False, + ccr_context_tracking=False, + image_optimize=False, + ) + app = create_app(config) + with TestClient(app) as client: + resp = client.head("/") + # The upstream is fake (connection error → 502), but the proxy must + # *accept* HEAD — a 405 would mean the route is missing. + assert resp.status_code != 405, ( + f"HEAD / returned 405; allowed methods: {resp.headers.get('allow', 'N/A')}" + )