Skip to content

feat(transport): add --allow-host LAN opt-in for remote agents (#421)#505

Merged
dsarno merged 4 commits into
mainfrom
claude/issue-421-allow-host-lan
Jun 1, 2026
Merged

feat(transport): add --allow-host LAN opt-in for remote agents (#421)#505
dsarno merged 4 commits into
mainfrom
claude/issue-421-allow-host-lan

Conversation

@dsarno

@dsarno dsarno commented May 31, 2026

Copy link
Copy Markdown
Contributor

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 --insecure switch.

python -m godot_ai --transport streamable-http --port 8000 \
  --allow-host 192.168.1.0/24

--allow-host takes one or more CIDRs / bare IPs (repeatable, or comma-separated). When present:

  • the HTTP bind (fastmcp.settings.host) and the WebSocket bind both widen off loopback to 0.0.0.0, and
  • the DNS-rebinding guard (LocalhostOnlyHTTPMiddleware + the WebSocket process_request hook) widens its Host allowlist to those networks only.

When absent: today's loopback-only behavior, byte-for-byte (allowed_networks=None everywhere).

Why this is safe (rebinding defense survives the opt-in)

The opt-in widens only the Host allowlist. The Origin and Sec-Fetch-Site rules are deliberately left untouched:

  • A browser on the LAN sends a non-loopback Origin (rejected) and a foreign Sec-Fetch-Site (rejected) — so the classic rebinding / liveness-oracle shapes still fail.
  • A native remote agent (the heavy-lifting use case) sends neither header, so it passes once its Host IP falls inside an allowed CIDR.
  • A DNS name (the shape a rebinding attack presents in the Host header) never parses to an IP literal, so it can't slip into an allowed network even if it resolves there.

A blanket --bind 0.0.0.0 was 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.pyparse_allow_hosts() (CIDR/bare-IP/comma parsing, strict=False), CIDR-aware is_allowed_host(), and allowed_networks threaded through evaluate_loopback(), the ASGI middleware, and the WebSocket guard. Both transports share the one evaluate_loopback so they can't diverge.
  • transport/websocket.pyGodotWebSocketServer gains host + allowed_networks.
  • server.pycreate_server(allow_host_networks=...); http_app() installs the guard with the networks; the lifespan binds the WS server accordingly.
  • __init__.py--allow-host argparse, 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 via GODOT_AI_DEV_ALLOW_HOST and 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):

  • the developer-mode-gated dock field ("Allow remote hosts (CIDR)") with a warning banner;
  • surfacing the LAN URL in the configurator's manual_command.

Testing

  • 27 new tests in 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) and tests/unit/test_cli_reload.py (flag plumbing into create_server / run_with_reload, bind widening on/off, invalid-CIDR rejection, reload env).
  • Updated existing CLI/runtime fakes for the new kwarg.
  • Full suite: 1135 passed, 2 skipped. ruff check / ruff format clean.

https://claude.ai/code/session_01Jq5X4ivngAf1N6r5UX2BVw


Generated by Claude Code

claude added 2 commits May 31, 2026 18:08
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

codecov Bot commented May 31, 2026

Copy link
Copy Markdown

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, threading allowed_networks through HTTP middleware and WebSocket upgrade guard.
  • Plumbs --allow-host from CLI and reload env into create_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.

Comment thread src/godot_ai/server.py Outdated
Comment on lines +117 to +121
## #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"

Comment thread src/godot_ai/transport/origin_guard.py Outdated
Comment on lines +118 to +122
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

dsarno commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both addressed in f60b56d (also merged into beta-clean for #506):

  1. WebSocket stays loopback-only. You're right that widening it was wrong. The WS (9500) is the local Python-server↔Godot-editor bridge — the editor connects via ws://127.0.0.1 and remote agents reach us over HTTP only — so --allow-host no longer touches the WS bind. This removes the LAN exposure of the unauthenticated plugin WS and the Windows IPv6-only breakage of the editor's IPv4 loopback connection. (This intentionally deviates from the original Allow opting MCP server into LAN access for remote agents (e.g. --allow-host CIDR) #421 "widen both HTTP and WebSocket" note — the WS doesn't need widening for the remote-HTTP-agent use case.)

  2. bind_host_for_networks() now prioritizes IPv4 reachability instead of assuming :: is dual-stack: any IPv4 network in the allowlist → bind 0.0.0.0 (reachable on every platform, incl. Windows v6-only); :: only when the allowlist is exclusively IPv6. A mixed-family allowlist no longer silently drops IPv4 reachability.

Tests updated accordingly (test_bind_host_for_networks_prioritizes_ipv4_reachability); full suite green.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants