feat(transport): add --allow-host LAN opt-in for remote agents (#421)#505
Conversation
Both transports are hard-locked to loopback with no escape hatch, so a remote agent on a trusted LAN / VPN can't reach an editor running on a workstation without an SSH tunnel or Tailscale. Add an explicit, scoped opt-in — not a blanket --insecure switch. --allow-host takes one or more CIDRs / bare IPs (repeatable or comma-separated). When present: - both the HTTP and WebSocket transports bind off loopback (0.0.0.0), and - the DNS-rebinding guard widens its Host allowlist to those networks ONLY. The Origin and Sec-Fetch-Site checks are deliberately left untouched, so rebinding defense survives the opt-in: a browser on the LAN still sends a non-loopback Origin (rejected) and a foreign Sec-Fetch-Site (rejected); a native remote agent sends neither and passes once its Host IP is allowed. A DNS name never parses to an IP, so it can't slip into an allowed network. Default (no flag) is byte-for-byte the original loopback-only behavior. A bad CIDR fails loudly at startup rather than silently exposing nothing (or worse). Scope: this is the server-side core. The developer-mode-gated dock field and the LAN-URL surfacing in the configurator's manual command (also in #421) are left as a follow-up; the CLI flag is the functional, security-critical gate. https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw
Code-review follow-up: the bind was hardcoded 0.0.0.0 (IPv4-only), so an IPv6 --allow-host CIDR set the guard to accept IPv6 hosts but the socket never listened on IPv6. Add a shared bind_host_for_networks() helper (single source of truth for the HTTP, WebSocket, and reload binds, mirroring the shared evaluate_loopback) that binds '::' when any requested network is IPv6 and '0.0.0.0' for an IPv4-only allowlist. https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Closes the one patch-coverage gap codecov flagged: the bracketed-IPv6 Host unwrap in _host_ip_in_networks was only exercised by IPv4 hosts. Add a test matching [fd00::1] against an IPv6 CIDR (in-range allowed, out-of-range rejected). https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw
There was a problem hiding this comment.
Pull request overview
Adds a scoped --allow-host opt-in to widen the server’s loopback-only DNS-rebinding guard to explicitly named LAN/VPN CIDRs, enabling remote (non-browser) agents to connect without disabling Origin/Sec-Fetch-Site protections.
Changes:
- Introduces CIDR/bare-IP parsing and CIDR-aware Host checks in
origin_guard, threadingallowed_networksthrough HTTP middleware and WebSocket upgrade guard. - Plumbs
--allow-hostfrom CLI and reload env intocreate_server(), widening HTTP bind only when the opt-in is present. - Updates WebSocket server construction to accept
host+allowed_networks, plus adds/adjusts unit tests for parsing, matching, and CLI/reload wiring.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_runtime_info.py | Updates test stub for the new create_server(..., allow_host_networks=...) kwarg. |
| tests/unit/test_origin_guard.py | Adds extensive tests for --allow-host parsing, Host matching, and middleware behavior. |
| tests/unit/test_cli_reload.py | Adds CLI + reload-path plumbing tests for --allow-host, including bind widening and invalid CIDR rejection. |
| src/godot_ai/transport/websocket.py | Allows configuring WS bind host and passes allowed networks into the WS rebinding guard. |
| src/godot_ai/transport/origin_guard.py | Implements parse_allow_hosts, CIDR-aware Host checks, and bind_host_for_networks(). |
| src/godot_ai/server.py | Threads allowlist into HTTP middleware and uses it to select WS bind host / guard behavior. |
| src/godot_ai/asgi.py | Reload path propagates allowlist via env var and widens uvicorn bind when opted in. |
| src/godot_ai/init.py | Adds --allow-host CLI flag, validates it, widens HTTP bind for HTTP transports, and forwards networks into server creation/reload. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ## #421: --allow-host opt-in. When set, expose both transports to the | ||
| ## named LAN CIDR(s) — bind the WS server off loopback and hand the | ||
| ## networks to its rebinding guard. None/empty = unchanged loopback-only. | ||
| ws_bind_host = bind_host_for_networks(allow_host_networks) or "127.0.0.1" | ||
|
|
| Returns ``None`` when no networks are named so callers keep their | ||
| loopback default (the byte-for-byte unchanged path). Otherwise returns | ||
| ``"::"`` when any requested network is IPv6 — on a dual-stack host that | ||
| also accepts IPv4 — and ``"0.0.0.0"`` for an IPv4-only allowlist, so an | ||
| IPv6 ``--allow-host`` actually listens on IPv6 instead of silently |
…ind (#421) Addresses Copilot review on #505: 1. The WebSocket server (port 9500) is the LOCAL editor<->server bridge — the editor connects via ws://127.0.0.1 and remote agents reach us over HTTP only. --allow-host must not widen the WS bind: doing so exposed the unauthenticated plugin WS to the LAN, and binding '::' (IPv6-only by default on Windows) would break the editor's IPv4 loopback connection. WS is now always 127.0.0.1, regardless of --allow-host. Only the HTTP transport widens. 2. bind_host_for_networks() no longer assumes '::' is dual-stack (it isn't on Windows). It now prioritizes IPv4 reachability: any IPv4 in the allowlist binds 0.0.0.0 (reachable on every platform); '::' only for an IPv6-only allowlist. A mixed allowlist no longer silently drops IPv4 reachability. https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw
|
Thanks — both addressed in f60b56d (also merged into
Tests updated accordingly ( Generated by Claude Code |
Summary
Implements the server-side core of #421. Both transports are hard-locked to loopback with no escape hatch, so a remote agent on a trusted LAN / VPN can't drive an editor running on a workstation without an SSH tunnel or Tailscale. This adds an explicit, scoped opt-in — not a blanket
--insecureswitch.--allow-hosttakes one or more CIDRs / bare IPs (repeatable, or comma-separated). When present:fastmcp.settings.host) and the WebSocket bind both widen off loopback to0.0.0.0, andLocalhostOnlyHTTPMiddleware+ the WebSocketprocess_requesthook) widens its Host allowlist to those networks only.When absent: today's loopback-only behavior, byte-for-byte (
allowed_networks=Noneeverywhere).Why this is safe (rebinding defense survives the opt-in)
The opt-in widens only the Host allowlist. The
OriginandSec-Fetch-Siterules are deliberately left untouched:Origin(rejected) and a foreignSec-Fetch-Site(rejected) — so the classic rebinding / liveness-oracle shapes still fail.A blanket
--bind 0.0.0.0was rejected (per the issue) because it's too easy to misuse on untrusted Wi-Fi; CIDR scoping forces the user to name the network they trust, and a bad CIDR fails loudly at startup rather than silently exposing nothing (or worse).Changes
transport/origin_guard.py—parse_allow_hosts()(CIDR/bare-IP/comma parsing,strict=False), CIDR-awareis_allowed_host(), andallowed_networksthreaded throughevaluate_loopback(), the ASGI middleware, and the WebSocket guard. Both transports share the oneevaluate_loopbackso they can't diverge.transport/websocket.py—GodotWebSocketServergainshost+allowed_networks.server.py—create_server(allow_host_networks=...);http_app()installs the guard with the networks; the lifespan binds the WS server accordingly.__init__.py—--allow-hostargparse, parse + validate, widen the HTTP bind only when set + only for HTTP transports.asgi.py— reload path carries the CIDRs to the uvicorn factory subprocess viaGODOT_AI_DEV_ALLOW_HOSTand widens the reload bind.Scope / follow-up
This is the server-side core — the functional, security-critical gate. The two remaining pieces from the issue are intentionally deferred to a follow-up (they're additive UI/UX, not security boundaries):
manual_command.Testing
tests/unit/test_origin_guard.py(parsing, CIDR host matching, loopback-still-passes, DNS-name-never-matches, native-LAN-agent passes, LAN-browser-Origin rejected, middleware opt-in on/off) andtests/unit/test_cli_reload.py(flag plumbing intocreate_server/run_with_reload, bind widening on/off, invalid-CIDR rejection, reload env).ruff check/ruff formatclean.https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw
Generated by Claude Code