mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
605e269f98
commit
c41cf444c7
2 changed files with 34 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue