Skip to content

Commit c0e3be7

Browse files
dsarnoclaude
andauthored
harden(transport): gate --allow-host on the real socket peer, not the Host header (#544)
* harden(transport): gate --allow-host on the real socket peer, not the Host header When --allow-host widens access to a LAN range, the HTTP transport binds off loopback and authorization previously rested entirely on is_allowed_host, which range-checks the client-controlled Host header. A peer outside the allowed range could pass by spoofing `Host: <an-allowed-ip>`; the real peer address was never consulted (no scope["client"] / remote_address use anywhere). Add peer_ip_allowed(): the authoritative gate keyed on the unforgeable socket peer (scope["client"] for ASGI, remote_address for the WebSocket server). When an allowlist is set, the peer must be loopback or inside an allowed network; the Host header is kept as a secondary range filter but is no longer the authority. Fails closed when opted in and the peer can't be determined. Loopback-only mode (no --allow-host) is a pass-through — the kernel bind already guarantees a loopback peer — so behavior there is byte-for-byte unchanged. The WebSocket port stays loopback-only regardless, so its guard extracts the peer defensively but never gates on it today. Adds peer_ip_allowed unit coverage plus middleware/evaluate_loopback tests for the spoofed-Host-from-foreign-peer case and the fail-closed-on-unknown-peer case. Addresses advisory GHSA-pp3p-fhg5-f429. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * harden(transport): unwrap IPv4-mapped IPv6 peers in the allow-host gate Copilot review: on a dual-stack listener the real peer can arrive as an IPv4-mapped IPv6 address (::ffff:127.0.0.1). ipaddress marks that form .is_loopback == False and it never matches an IPv4 allowlist network, so a genuine loopback / in-network peer would be wrongly rejected under --allow-host. Unwrap .ipv4_mapped before the loopback / allowlist checks. Adds regression coverage for mapped loopback / in-network / out-of-network peers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(transport): reflect the peer gate in the 403 body + WS guard docstring Copilot re-review: with the new peer-address gate a request can be refused solely on its peer IP, but the 403 body said only "non-loopback Host or Origin" and the make_websocket_request_guard docstring said --allow-host only widens the Host allowlist. Both now mention the unforgeable peer address. The body keeps the "DNS rebinding" phrasing the existing test pins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ab4dceb commit c0e3be7

3 files changed

Lines changed: 244 additions & 34 deletions

File tree

src/godot_ai/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ def main(argv: Sequence[str] | None = None) -> None:
9494
"Expose the server to a named LAN range for remote agents (issue "
9595
"#421). Takes a CIDR or bare IP (repeatable, or comma-separated), "
9696
"e.g. --allow-host 192.168.1.0/24. When set, both transports bind "
97-
"off loopback and the rebinding guard widens its Host allowlist to "
98-
"these networks ONLY — browser Origin / Sec-Fetch-Site checks stay "
99-
"on. Omit for the default loopback-only behavior. Prefer an SSH "
100-
"tunnel / Tailscale on untrusted networks; only name ranges you trust."
97+
"off loopback and access is gated on the real socket peer address "
98+
"(unforgeable) falling inside these networks ONLY — the Host header "
99+
"is range-checked too but is not the authority, and browser Origin "
100+
"/ Sec-Fetch-Site checks stay on. Omit for the default loopback-only "
101+
"behavior. Prefer an SSH tunnel / Tailscale on untrusted networks; "
102+
"only name ranges you trust."
101103
),
102104
)
103105
parser.add_argument(

src/godot_ai/transport/origin_guard.py

Lines changed: 94 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
``no-cors`` subresources that wouldn't carry an Origin — is refused with
3636
HTTP 403 long before reaching FastMCP or our session registry.
3737
38+
When the ``--allow-host`` opt-in (#421) binds the transport off loopback,
39+
the **real socket peer** (``scope["client"]`` / ``remote_address``) is the
40+
authoritative LAN gate — see :func:`peer_ip_allowed`. The ``Host`` header
41+
is client-controlled, so a peer outside the allowed range could otherwise
42+
pass the range check by spoofing ``Host: <an-allowed-ip>``; gating on the
43+
unforgeable peer address closes that. In the default loopback-only mode the
44+
peer gate is a pass-through and behavior is byte-for-byte unchanged.
45+
3846
See umbrella #343, finding #1 (audit-v2).
3947
"""
4048

@@ -54,7 +62,7 @@
5462
LOOPBACK_ORIGIN_SCHEMES: frozenset[str] = frozenset({"http", "https", "ws", "wss"})
5563

5664
FORBIDDEN_BODY = (
57-
b"forbidden: non-loopback Host or Origin (DNS rebinding guard)\n"
65+
b"forbidden: peer, Host, or Origin not permitted (DNS rebinding / --allow-host guard)\n"
5866
b"see https://github.com/hi-godot/godot-ai issue #345 for details\n"
5967
)
6068
FORBIDDEN_BODY_TEXT = FORBIDDEN_BODY.decode("utf-8")
@@ -158,6 +166,45 @@ def _host_ip_in_networks(host_header: str, networks: Sequence[IPNetwork] | None)
158166
return any(ip in net for net in networks)
159167

160168

169+
def peer_ip_allowed(peer_ip: str | None, allowed_networks: Sequence[IPNetwork] | None) -> bool:
170+
"""Whether the real TCP peer address is permitted to reach the transport.
171+
172+
This is the authoritative LAN gate. The ``Host`` header is
173+
client-controlled — a peer outside an allowed network can spoof
174+
``Host: <an-allowed-ip>`` and pass :func:`is_allowed_host` — so when
175+
the transport is bound off loopback (the ``--allow-host`` opt-in, #421)
176+
the *socket peer* (``scope["client"]`` for ASGI, ``remote_address`` for
177+
the WebSocket server), which the client cannot forge, must itself be
178+
loopback or fall inside an allowed network.
179+
180+
With no allowlist (the default), the transport stays bound to loopback,
181+
so the kernel already guarantees a loopback peer and this is a
182+
pass-through that keeps the original behavior byte-for-byte — including
183+
contexts (e.g. unit scopes) where the peer address isn't populated.
184+
When an allowlist *is* set but the peer can't be determined, it fails
185+
closed: better to refuse than to fall back to the spoofable header.
186+
"""
187+
if not allowed_networks:
188+
return True
189+
if not peer_ip:
190+
return False
191+
## Strip an IPv6 zone id (``fe80::1%eth0``) before parsing.
192+
candidate = peer_ip.split("%", 1)[0].strip()
193+
try:
194+
ip = ipaddress.ip_address(candidate)
195+
except ValueError:
196+
return False
197+
## A dual-stack listener can report an IPv4 peer in IPv4-mapped IPv6 form
198+
## (``::ffff:127.0.0.1``), whose ``.is_loopback`` is False and which never
199+
## matches an IPv4 allowlist network. Unwrap it to the real IPv4 address so
200+
## a genuine loopback / in-network peer isn't wrongly rejected.
201+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
202+
ip = ip.ipv4_mapped
203+
if ip.is_loopback:
204+
return True
205+
return any(ip in net for net in allowed_networks)
206+
207+
161208
def is_allowed_host(
162209
host_header: str | None,
163210
allowed_networks: Sequence[IPNetwork] | None = None,
@@ -230,22 +277,30 @@ def evaluate_loopback(
230277
origins: list[str],
231278
sec_fetch_sites: list[str] | None = None,
232279
allowed_networks: Sequence[IPNetwork] | None = None,
280+
peer_ip: str | None = None,
233281
) -> bool:
234-
"""Return True iff the request's headers pass the allowlist.
282+
"""Return True iff the request passes the allowlist.
235283
236284
Both transports (ASGI middleware + WebSocket ``process_request``)
237-
funnel their per-request header extraction through this helper so
238-
the duplicate-header smuggling rule, the value-allowlist rule, and
239-
the Sec-Fetch-Site cross-origin reject rule are evaluated identically.
240-
A divergence between the two transports would be a security
241-
regression — this helper exists to prevent it.
242-
243-
``allowed_networks`` (the ``--allow-host`` opt-in, #421) only widens
244-
the *Host* allowlist to named LAN CIDRs. The Origin and Sec-Fetch-Site
245-
rules are deliberately left untouched: a browser on the LAN sends a
246-
non-loopback Origin (rejected) and a foreign Sec-Fetch-Site (rejected),
247-
so DNS-rebinding defense survives the opt-in. A native remote agent
248-
sends neither header, so it passes once its Host IP is allowed.
285+
funnel their per-request extraction through this helper so the
286+
duplicate-header smuggling rule, the value-allowlist rule, the
287+
Sec-Fetch-Site cross-origin reject rule, and the peer-address gate are
288+
evaluated identically. A divergence between the two transports would be
289+
a security regression — this helper exists to prevent it.
290+
291+
``allowed_networks`` (the ``--allow-host`` opt-in, #421) widens access
292+
to named LAN CIDRs. Authorization is anchored on ``peer_ip`` — the real
293+
socket peer, which the client cannot forge — not on the ``Host`` header
294+
(which it can). The Host header is still range-checked, but only as a
295+
secondary filter; the peer gate is what actually keeps out-of-range
296+
hosts off the server. The Origin and Sec-Fetch-Site rules are left
297+
untouched: a browser on the LAN sends a non-loopback Origin (rejected)
298+
and a foreign Sec-Fetch-Site (rejected), so DNS-rebinding defense
299+
survives the opt-in. A native remote agent sends neither header, so it
300+
passes once both its peer address and Host IP are allowed.
301+
302+
When ``allowed_networks`` is None (the loopback-only default), the peer
303+
gate is a pass-through and behavior is byte-for-byte unchanged.
249304
"""
250305
if len(hosts) > 1 or len(origins) > 1:
251306
return False
@@ -255,7 +310,8 @@ def evaluate_loopback(
255310
origin = origins[0] if origins else None
256311
sec_fetch_site = sec_fetch_sites[0] if sec_fetch_sites else None
257312
return (
258-
is_allowed_host(host, allowed_networks)
313+
peer_ip_allowed(peer_ip, allowed_networks)
314+
and is_allowed_host(host, allowed_networks)
259315
and is_allowed_origin(origin)
260316
and is_allowed_sec_fetch_site(sec_fetch_site)
261317
)
@@ -300,7 +356,13 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
300356
elif key == b"sec-fetch-site":
301357
sec_fetch_sites.append(raw_value.decode("latin-1"))
302358

303-
if evaluate_loopback(hosts, origins, sec_fetch_sites, self.allowed_networks):
359+
## ASGI populates ``scope["client"]`` with the real ``(host, port)``
360+
## peer — unforgeable, unlike the Host header. ``None`` in unusual
361+
## servers; the peer gate fails closed for it only when opted in.
362+
client = scope.get("client")
363+
peer_ip = client[0] if client else None
364+
365+
if evaluate_loopback(hosts, origins, sec_fetch_sites, self.allowed_networks, peer_ip):
304366
await self.app(scope, receive, send)
305367
return
306368
await _send_forbidden(send)
@@ -323,15 +385,15 @@ async def _send_forbidden(send: Send) -> None:
323385
def make_websocket_request_guard(allowed_networks: Sequence[IPNetwork] | None = None):
324386
"""Return a ``process_request`` hook for ``websockets.asyncio.server.serve``.
325387
326-
The hook fires before the WebSocket upgrade. When Host or Origin
327-
fails the loopback allowlist the hook synthesizes an HTTP 403 via
328-
``connection.respond(...)``; returning that response from
329-
``process_request`` aborts the upgrade without ever creating a
330-
Session.
388+
The hook fires before the WebSocket upgrade. When the real peer
389+
address, Host, or Origin fails the allowlist the hook synthesizes an
390+
HTTP 403 via ``connection.respond(...)``; returning that response from
391+
``process_request`` aborts the upgrade without ever creating a Session.
331392
332-
``allowed_networks`` (the ``--allow-host`` opt-in, #421) widens the
333-
Host allowlist identically to the HTTP middleware so the two
334-
transports never diverge.
393+
``allowed_networks`` (the ``--allow-host`` opt-in, #421) gates the
394+
unforgeable peer address (``remote_address``) and widens the Host
395+
allowlist identically to the HTTP middleware, so the two transports
396+
never diverge.
335397
"""
336398
networks = list(allowed_networks) if allowed_networks else None
337399

@@ -342,7 +404,14 @@ async def guard(connection, request):
342404
hosts = list(request.headers.get_all("Host"))
343405
origins = list(request.headers.get_all("Origin"))
344406
sec_fetch_sites = list(request.headers.get_all("Sec-Fetch-Site"))
345-
if evaluate_loopback(hosts, origins, sec_fetch_sites, networks):
407+
## ``remote_address`` is the real TCP peer (set before the HTTP
408+
## upgrade) — unforgeable, unlike the Host header. It's a
409+
## ``(host, port[, flowinfo, scopeid])`` tuple, or None if the
410+
## socket is already gone; the peer gate fails closed for None
411+
## only when opted in.
412+
remote = getattr(connection, "remote_address", None)
413+
peer_ip = remote[0] if remote else None
414+
if evaluate_loopback(hosts, origins, sec_fetch_sites, networks, peer_ip):
346415
return None
347416
return connection.respond(HTTPStatus.FORBIDDEN, FORBIDDEN_BODY_TEXT)
348417

0 commit comments

Comments
 (0)