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:
@@ -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:
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user