Skip to content
Open
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
45 changes: 45 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,48 @@ def origin_allowed(origin: str | None) -> bool:
# fullmatch (not match) so a trailing newline / extra suffix can't sneak past
# the `$` anchor.
return bool(_ORIGIN_RE and _ORIGIN_RE.fullmatch(origin))


# --- Host-header allowlist (DNS-rebinding defense) --------------------------
# The loopback trust in _access_ok (localhost mode, no token) rests on the socket
# peer being 127.0.0.1. That alone does NOT stop DNS rebinding: a remote page at
# http://evil.com:8000 whose name the attacker re-points to 127.0.0.1 reaches the
# backend over a loopback socket, and same-origin GET/EventSource requests carry
# no Origin header, so origin_allowed() is never consulted. Validating the Host
# header closes that hole — the browser sets Host from the connection's name and
# page JS cannot forge it (Host is a forbidden header), so a rebound public name
# is rejected while genuine localhost / private-LAN access is allowed.
# Same private ranges as the Origin regex; scheme-less, host[:port] only.
_DEFAULT_HOST_REGEX = (
r"^("
r"localhost|127\.0\.0\.1|\[::1\]|::1|"
r"10\.\d{1,3}\.\d{1,3}\.\d{1,3}|"
r"192\.168\.\d{1,3}\.\d{1,3}|"
r"172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
r")(:\d{1,5})?$"
)
# Override/disable the regex with VC_ALLOWED_HOST_REGEX (set empty to disable and
# rely solely on the exact VC_ALLOWED_HOSTS list — useful for a custom hostname).
_host_regex_raw = os.getenv("VC_ALLOWED_HOST_REGEX")
ALLOWED_HOST_REGEX: str | None = (
_DEFAULT_HOST_REGEX if _host_regex_raw is None else (_host_regex_raw.strip() or None)
)
_HOST_RE = re.compile(ALLOWED_HOST_REGEX) if ALLOWED_HOST_REGEX else None
# Extra exact Host header values (host or host:port), comma-separated. Mirrors
# VC_ALLOWED_ORIGINS for deployments reached via a custom DNS name.
ALLOWED_HOSTS: list[str] = [
h.strip().lower() for h in (os.getenv("VC_ALLOWED_HOSTS") or "").split(",") if h.strip()
]


def host_allowed(host: str | None) -> bool:
"""Whether an HTTP/WS Host header is permitted. Rejects a missing/empty Host
and any name outside the loopback / private-LAN allowlist (plus VC_ALLOWED_HOSTS),
defeating DNS-rebinding attacks against the loopback-trusted backend."""
if not host:
return False
h = host.strip().lower()
if h in ALLOWED_HOSTS:
return True
# fullmatch anchors both ends so a trailing suffix can't slip past.
return bool(_HOST_RE and _HOST_RE.fullmatch(h))
7 changes: 7 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ async def require_auth(request: Request) -> None:
# malicious page could otherwise fire a side-effecting "simple" request from
# a loopback-trusted browser. The same-origin Next proxy and native clients
# send no Origin; only real cross-origin browser requests carry one.
# Host-header allowlist: defeats DNS rebinding against the loopback-trusted
# backend. Enforced unconditionally (not just when an Origin is present), since
# a same-origin GET/EventSource — the rebinding vector — carries no Origin at all.
if not config.host_allowed(request.headers.get("host")):
raise HTTPException(status_code=403, detail="host not allowed")
origin = request.headers.get("origin")
if origin and not config.origin_allowed(origin):
raise HTTPException(status_code=403, detail="origin not allowed")
Expand All @@ -222,6 +227,8 @@ def _ws_access_ok(ws: WebSocket) -> tuple[bool, int]:
"""Authorize a WebSocket handshake (CORS middleware does not apply to WS).
Returns (ok, close_code). Enforces the Origin allowlist for browser clients
and the same token/loopback rule as HTTP."""
if not config.host_allowed(ws.headers.get("host")):
return False, 4403 # forbidden host (DNS-rebinding defense)
origin = ws.headers.get("origin")
if origin and not config.origin_allowed(origin):
return False, 4403 # forbidden origin
Expand Down
26 changes: 26 additions & 0 deletions backend/tests/test_security_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,31 @@ def test_configured_azure_host_allowed(self):
"https://my-azure.openai.azure.com/openai/v1/realtime/client_secrets")


class HostHeaderAllowlist(unittest.TestCase):
"""DNS-rebinding defense: only loopback / private-LAN Host headers pass."""

def test_loopback_hosts_allowed(self):
for good in ["localhost", "localhost:8000", "127.0.0.1:8000",
"[::1]:8000", "::1"]:
self.assertTrue(config.host_allowed(good), good)

def test_private_lan_hosts_allowed(self):
for good in ["192.168.1.5:8000", "10.0.0.7:3000", "172.16.0.9:8000"]:
self.assertTrue(config.host_allowed(good), good)

def test_rebound_public_name_rejected(self):
for bad in ["evil.com", "evil.com:8000", "attacker.example:8000",
"8.8.8.8:8000", "169.254.169.254"]:
self.assertFalse(config.host_allowed(bad), bad)

def test_missing_host_rejected(self):
self.assertFalse(config.host_allowed(None))
self.assertFalse(config.host_allowed(""))

def test_trailing_suffix_cannot_sneak_past(self):
self.assertFalse(config.host_allowed("127.0.0.1:8000.evil.com"))
self.assertFalse(config.host_allowed("localhost:8000\nevil.com"))


if __name__ == "__main__":
unittest.main()