fix: default WebUI bind host back to loopback (#412)

* fix: change default bind host to loopback for security across all components

* fix: update documentation and tests for loopback host configuration and security warnings
This commit is contained in:
Xi Zhang
2026-08-07 17:13:57 +01:00
committed by GitHub
parent b40b6f784d
commit 0c21a01f6f
12 changed files with 162 additions and 116 deletions
+20 -16
View File
@@ -527,12 +527,9 @@ def _ensure_async_subagent_server(config: Any, *, workspace_dir: str) -> None:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
# This backend is shared by every UI mode, not just `deploy` / WebUI — so
# the exposure warning belongs here too, or a plain `EvoSci` session would
# put an unauthenticated, shell-capable API on the network with no signal
# at all. Gated on the server actually being up: ensure_langgraph_dev
# fails soft (async falls back to in-process), and warning about a bind
# that never happened would be worse than saying nothing.
# The backend is shared by every UI mode, so the exposure warning lives
# here, not just in deploy/WebUI. Gated on the server being up: warning
# about a bind that never happened would be worse than saying nothing.
bind_host = str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip()
if (
bind_host
@@ -1445,6 +1442,13 @@ def serve(
workdir: str | None = typer.Option(
None, "--workdir", help="Override workspace directory"
),
host: str | None = typer.Option(
None,
"--host",
help="Interface to bind the langgraph dev backend to (default: "
"langgraph_dev_host = 127.0.0.1). Pass 0.0.0.0 to reach it from "
"another machine — the backend has no auth.",
),
auto_approve: bool = typer.Option(
False,
"--auto-approve",
@@ -1479,6 +1483,9 @@ def serve(
from ..config import apply_config_to_env, get_effective_config
cli_overrides = {}
# serve starts no front-end, so only the backend bind applies here.
if host is not None and host.strip():
cli_overrides["langgraph_dev_host"] = host.strip()
if auto_approve:
cli_overrides["auto_approve"] = True
if auto_mode:
@@ -2167,12 +2174,11 @@ def _main_callback(
host: str | None = typer.Option(
None,
"--host",
help="Interface to bind servers to (defaults: langgraph_dev_host "
"127.0.0.1, webui_host 0.0.0.0). Sets langgraph_dev_host, which "
"applies in EVERY UI mode — the background langgraph dev backend is "
"shared by tui/cli/webui/serve — and webui_host, which only matters in "
"WebUI mode. Pass 0.0.0.0 to reach both from another machine (the "
"backend has no auth).",
help="Interface to bind servers to (default: 127.0.0.1 for both). "
"Sets langgraph_dev_host — the backend shared by every UI mode — and "
"webui_host (WebUI mode only). Applies to the default entry; the "
"serve and deploy subcommands take their own --host. Pass 0.0.0.0 to "
"reach both from another machine (the backend has no auth).",
),
output_format: str | None = typer.Option(
None,
@@ -2235,10 +2241,8 @@ def _main_callback(
if ui:
cli_overrides["ui_backend"] = ui
if host is not None and host.strip():
# One flag drives both servers. Note this is NOT WebUI-specific: the
# langgraph dev backend is auto-started for tui/cli/serve too (see
# _ensure_async_subagent_server), so --host narrows or widens the
# agent API in every mode. Only webui_host is WebUI-only.
# One flag drives both servers; the backend applies in EVERY UI mode
# (auto-started for tui/cli/serve too), webui_host only in WebUI mode.
cli_overrides["webui_host"] = host.strip()
cli_overrides["langgraph_dev_host"] = host.strip()
if auto_approve:
+10 -14
View File
@@ -214,15 +214,10 @@ class EvoScientistConfig:
langgraph_dev_port: int = 6174
# Network interface the langgraph dev subprocess binds to. Loopback by
# default: this server is the agent API — no authentication, and the
# deployed agent can run shell commands — so it stays off the network until
# asked. Set "0.0.0.0" to reach it from another machine (the WebUI talks to
# it FROM THE BROWSER, and external SDK clients need it too); every launcher
# then prints a red PUBLIC BIND banner while it is exposed.
#
# Callers that *connect* (health probes, async sub-agent self-dispatch) map
# a wildcard bind back to loopback via manager._probe_host, so widening this
# never redirects internal traffic off-box.
# default — this is the unauthenticated agent API (the agent can run
# shell), so "0.0.0.0" is opt-in and every launcher prints a PUBLIC BIND
# banner while exposed. Internal callers *connect* via manager._probe_host,
# so widening never redirects their traffic off-box.
langgraph_dev_host: str = "127.0.0.1"
# Port for the WebUI front-end (Next.js server from @evoscientist/webui),
@@ -231,10 +226,11 @@ class EvoScientistConfig:
# its own port (langgraph_dev_port); this is just the browser server.
webui_port: int = 4716
# Network interface the WebUI front-end binds to. Also all interfaces by
# default; it serves the app shell only and holds no credentials (see
# deploy/webui.py:_scrubbed_env). Set "127.0.0.1" to keep it local-only.
webui_host: str = "0.0.0.0"
# Network interface the WebUI front-end binds to. Loopback by default,
# matching langgraph_dev_host: this server is not a passive app shell —
# its API reads, writes and uploads workspace files and installs skills,
# all unauthenticated. Set "0.0.0.0" (with langgraph_dev_host) for LAN.
webui_host: str = "127.0.0.1"
# --- Scheduled tasks (cron) ---
# Master switch for scheduled tasks (/schedule, NL tools, scheduler context). Defaults
@@ -511,7 +507,7 @@ class EvoScientistConfig:
# startup. Normalize to the field's own default instead.
for _host_field, _host_default in (
("langgraph_dev_host", "127.0.0.1"),
("webui_host", "0.0.0.0"),
("webui_host", "127.0.0.1"),
):
_host = getattr(self, _host_field, _host_default)
_host = _host.strip() if isinstance(_host, str) else ""
+9 -13
View File
@@ -124,20 +124,16 @@ def deploy(
)
raise typer.Exit(1)
# Same explicit-None resolution for the bind interface. Both branches strip
# (matching run_webui): whitespace reaching socket.bind() surfaces as an
# opaque gaierror, and an all-whitespace value collapses to the default
# rather than erroring. The config branch needs it too even though
# ``EvoScientistConfig.__post_init__`` normalizes these fields — this
# function reads via ``getattr`` and is routinely handed duck-typed config
# objects, which never run that normalization.
# A blank ``--host`` means "not passed" (matching serve), so it can never
# discard the configured bind. Both branches strip: whitespace reaching
# socket.bind() surfaces as an opaque gaierror, and duck-typed configs
# handed to this function never ran ``__post_init__`` normalization.
cli_host = host.strip() if host is not None else ""
effective_host = (
str(
getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST
).strip()
if host is None
else host.strip()
) or _DEFAULT_HOST
cli_host
or str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or "").strip()
or _DEFAULT_HOST
)
# 4. Pre-flight port check — refuse to start if a non-EvoSci process is
# holding the port. If an existing EvoSci langgraph dev is already up,
+14 -12
View File
@@ -42,7 +42,7 @@ from ..stream.console import console
# Front-end npm package + spec. ``@latest`` → always the newest published UI.
_WEBUI_PACKAGE = "@evoscientist/webui@latest"
_DEFAULT_WEBUI_PORT = 4716
_DEFAULT_WEBUI_HOST = "0.0.0.0"
_DEFAULT_WEBUI_HOST = "127.0.0.1"
def run_webui(config: Any, workspace_dir: str | None = None) -> None:
@@ -89,9 +89,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
# webui_port = the local Next.js server the browser actually opens.
backend_port = int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
webui_port = int(getattr(config, "webui_port", _DEFAULT_WEBUI_PORT))
# ...and their bind interfaces. The defaults deliberately differ: the
# front-end is exposed (it serves the app shell and holds no credentials),
# the backend is not (unauthenticated API, agent can run shell).
# ...and their bind interfaces, both loopback by default — the front-end
# carries workspace/skill APIs of its own (see config.webui_host).
backend_host = (
str(getattr(config, "langgraph_dev_host", _DEFAULT_HOST) or _DEFAULT_HOST)
).strip() or _DEFAULT_HOST
@@ -213,10 +212,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
# lets the UI's config prefill point at our backend automatically. Secrets
# are scrubbed — the browser UI never needs LLM provider API keys.
#
# HOSTNAME is the front-end's bind knob: the package ships no --host flag,
# and its bin launcher passes `HOSTNAME: process.env.HOSTNAME || "127.0.0.1"`
# through to the Next standalone server. Setting it here is therefore the
# only supported way to widen the front-end's interface.
# HOSTNAME is the front-end's only bind knob: the package has no --host
# flag; its launcher forwards `HOSTNAME || "127.0.0.1"` to the Next server.
webui_env = _scrubbed_env(
{
"EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port),
@@ -224,10 +221,8 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
"HOSTNAME": webui_host,
}
)
# The UI connects to the backend from the BROWSER, not server-side, so a
# remote visitor needs a backend address that resolves on *their* machine.
# Spell that out when the front-end is exposed but the backend isn't —
# otherwise the page loads and every request silently fails.
# The UI reaches the backend from the BROWSER; when only the front-end is
# exposed, remote pages load but every request fails — say so.
remote_backend_hint = ""
if not _is_loopback_host(webui_host) and _is_loopback_host(backend_host):
remote_backend_hint = (
@@ -260,6 +255,13 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None:
f"[bold red]Backend listening on {backend_host} — no auth, and the "
f"agent can run shell. Trusted networks only.[/bold red]"
)
if not _is_loopback_host(webui_host):
console.print(
"[bold white on red] ⚠ PUBLIC BIND [/bold white on red] "
f"[bold red]WebUI listening on {webui_host} — its API reads, writes "
f"and uploads workspace files and installs skills, with no auth. "
f"Trusted networks only.[/bold red]"
)
popen_kwargs: dict[str, Any] = {"env": webui_env}
if os.name == "posix":
+13 -22
View File
@@ -116,11 +116,9 @@ _LOCK = threading.RLock()
# corresponding url= field on AsyncSubAgent specs.
_DEFAULT_PORT = 6174
# Default bind interface — loopback, matching ``config.langgraph_dev_host`` so
# there is a single story about where this server listens. SECURITY: the
# langgraph dev server is the unauthenticated agent API; widening it with
# ``config.langgraph_dev_host = "0.0.0.0"`` puts it on the network, and every
# launcher warns while it is exposed.
# Default bind interface — loopback, matching ``config.langgraph_dev_host``.
# SECURITY: this is the unauthenticated agent API; launchers print a PUBLIC
# BIND banner while it is exposed.
_DEFAULT_HOST = "127.0.0.1"
# Wildcard bind addresses: the server listens on every interface, but you
@@ -132,12 +130,8 @@ _WILDCARD_HOSTS = frozenset({"0.0.0.0", "::", ""})
def _probe_host(host: str = _DEFAULT_HOST) -> str:
"""Map a bind address to one a client can actually connect to.
The distinction that makes host support tractable: only ``bind()`` needs
the configured interface. Every consumer in this module that *connects* —
health probes, occupancy checks, async sub-agent self-dispatch — wants a
reachable address. Binding ``0.0.0.0`` includes loopback, so ``127.0.0.1``
stays correct there; a specific IP is returned as-is because loopback
would not reach a server bound only to that interface.
A wildcard bind includes loopback, so clients use ``127.0.0.1``; a
specific interface is returned as-is — loopback would not reach it.
"""
return "127.0.0.1" if host in _WILDCARD_HOSTS else host
@@ -145,9 +139,8 @@ def _probe_host(host: str = _DEFAULT_HOST) -> str:
def _is_loopback_host(host: str) -> bool:
"""Return True if binding ``host`` keeps the server unreachable off-box.
Drives the "public bind" security warning in the deploy / WebUI launchers,
so it must be conservative: anything not provably loopback (a wildcard, a
LAN address, an unresolvable name) counts as exposed and gets the banner.
Drives the PUBLIC BIND warning, so it is conservative: anything not
provably loopback counts as exposed.
"""
return host.strip().lower() in {"127.0.0.1", "::1", "localhost"}
@@ -425,11 +418,9 @@ def _can_bind_port(port: int, host: str = _DEFAULT_HOST) -> bool:
actually attempts the bind that langgraph dev would attempt, then
closes immediately.
Binds the *literal* ``host`` — not ``_probe_host(host)`` — precisely
because this must replicate the server's own bind. Probing loopback
while the server will claim ``0.0.0.0`` gives false confidence: another
process holding a single non-loopback interface would let our probe
succeed and the real bind fail.
Binds the *literal* ``host`` — not ``_probe_host(host)`` — because this
must replicate the server's own bind: a loopback probe can succeed while
the real wildcard bind still fails on another interface's conflict.
"""
import socket as _socket
@@ -613,9 +604,9 @@ def start_langgraph_dev(
(``CustomSandboxBackend`` derives its workspace root from cwd via
``paths.WORKSPACE_ROOT``). Defaults to ``Path.cwd()``.
port: TCP port to bind. Defaults to 6174 (Kaprekar's constant).
host: Network interface to bind. Defaults to all interfaces. SECURITY:
this exposes an unauthenticated API whose agent can run shell
commands — pass ``127.0.0.1`` on untrusted networks.
host: Network interface to bind. Defaults to loopback. SECURITY:
widening this exposes an unauthenticated API whose agent can run
shell commands — only pass ``0.0.0.0`` on trusted networks.
file_persistence: When True (default), langgraph dev writes its full
``.langgraph_api/`` cache so async-task / Store / scheduler state
survives subprocess restarts. Set False to suppress periodic