fix(proxy): allow HEAD method on catch-all passthrough route (#2035)

## 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 <lennney@users.noreply.github.com>
This commit is contained in:
GUOHAO LIU 2026-07-13 01:54:58 +08:00 committed by GitHub
parent 605e269f98
commit c41cf444c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 34 additions and 1 deletions

View file

@ -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:

View file

@ -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')}"
)