From 709d992975a23e89d2c025e98ed826843fb25d86 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 31 Jul 2026 05:45:15 +0000 Subject: [PATCH] fix(security): validate Host header to defeat DNS rebinding (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In tokenless local mode the backend trusts any loopback socket peer, and require_auth only consults the Origin allowlist when an Origin header is present. Same-origin GET/EventSource requests carry no Origin, and no Host allowlist existed, so a DNS-rebinding page (evil.com -> 127.0.0.1) could read /debug/stream and /debug/recent — leaking session transcripts, tool arguments, and file contents. Add config.host_allowed() (loopback + private-LAN + VC_ALLOWED_HOSTS, override via VC_ALLOWED_HOST_REGEX) and enforce it in require_auth and _ws_access_ok, unconditionally (independent of the Origin header). The browser sets Host from the connection name and page JS cannot forge it, so a rebound public name is rejected while genuine localhost/LAN access passes. Refs #55 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GmQcs7se3WnceoJYXwjnvY --- backend/config.py | 45 ++++++++++++++++++++++++ backend/main.py | 7 ++++ backend/tests/test_security_hardening.py | 26 ++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/backend/config.py b/backend/config.py index eb0d456..4178005 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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)) diff --git a/backend/main.py b/backend/main.py index dab1c78..f67b845 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") @@ -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 diff --git a/backend/tests/test_security_hardening.py b/backend/tests/test_security_hardening.py index 5a927f3..3e06d7f 100644 --- a/backend/tests/test_security_hardening.py +++ b/backend/tests/test_security_hardening.py @@ -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()