Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/app/access_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/test_api_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading