diff --git a/backend/app/access_control.py b/backend/app/access_control.py index 0767b31..dbe12ee 100644 --- a/backend/app/access_control.py +++ b/backend/app/access_control.py @@ -61,6 +61,11 @@ async def dispatch(self, request: Request, call_next): if request.method == "OPTIONS" and "access-control-request-method" in request.headers: return await call_next(request) + # Duplicate Host headers are ambiguous across proxies and ASGI servers. + # Do not let a different hop authorize a different authority. + if len(request.headers.getlist("host")) != 1: + return JSONResponse({"detail": "Exactly one Host header is required"}, status_code=400) + origin = request.headers.get("origin") if origin is not None and origin not in self.allowed_origins(): return JSONResponse({"detail": "Untrusted browser origin"}, status_code=403) diff --git a/backend/tests/test_api_access.py b/backend/tests/test_api_access.py index ca08992..e0679c2 100644 --- a/backend/tests/test_api_access.py +++ b/backend/tests/test_api_access.py @@ -143,6 +143,16 @@ async def test_remote_with_valid_token_allowed_but_origin_still_checked(self): ) self.assertEqual(blocked.status_code, 403) + async def test_duplicate_host_headers_rejected_in_local_and_token_modes(self): + for env in ({}, {"PATCHPILOT_API_TOKEN": TOKEN}): + with self.subTest(token_enabled=bool(env)): + headers = [("Host", "localhost"), ("Host", "malicious.example")] + if env: + headers.append(("Authorization", "Bearer " + TOKEN)) + response = await self.request(headers=headers, env=env) + self.assertEqual(response.status_code, 400) + self.assertIn("Host header", response.text) + async def test_duplicate_auth_headers_refused(self): token = TOKEN response = await self.request(