From 2a143dced6adb80190b07c7db3ca79bff3a853ad Mon Sep 17 00:00:00 2001 From: Nan Gao Date: Sat, 1 Aug 2026 02:35:02 +0200 Subject: [PATCH] fix(docker): bind the published entry port to loopback by default (#4618) README documents DeerFlow as deployed by default "in a local trusted environment (accessible only via the 127.0.0.1 loopback interface)", but both compose files published nginx as `"${PORT:-2026}:2026"`, which Docker binds to 0.0.0.0 and [::]. The shipped artifact did not match its own documented default, so running it on a LAN or cloud host produced a wider surface than the docs implied without the operator changing anything -- and the agent can execute commands. Publish as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` in both compose files, so the default matches the documented model while operators who front the stack with their own TLS/auth can still widen it via BIND_HOST. The Gateway keeps binding 0.0.0.0:8001 inside the container (nginx reaches it over the compose network) and its port stays unpublished, so the published nginx port is the entire external surface. BREAKING CHANGE: a deployment that relied on the previous 0.0.0.0 default becomes unreachable from other hosts after this upgrade. Set BIND_HOST=0.0.0.0 in .env to restore it, after putting authentication in front and completing first-run setup. Also: - .env.example documents BIND_HOST and PORT with the reasoning. - deploy.sh reports the address the stack actually bound and, when it is not loopback, tells the operator to complete first-run setup immediately. It reads BIND_HOST/PORT from .env via a new read_dotenv_value helper following compose precedence; the shell does not source .env, so reading the environment alone would have reported "loopback only" for a stack .env had exposed. The pre-existing ${PORT} summary line had the same defect and is fixed with it. - test_compose_default_bind_host.py pins the loopback default, that BIND_HOST stays overridable, and that no service in either compose file publishes a port without an explicit bind address, so a later addition cannot drift back to 0.0.0.0 unnoticed. --- .env.example | 8 ++ AGENTS.md | 10 ++ README.md | 11 ++ .../tests/test_compose_default_bind_host.py | 112 ++++++++++++++++++ docker/docker-compose-dev.yaml | 5 +- docker/docker-compose.yaml | 7 +- scripts/deploy.sh | 50 +++++++- 7 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_compose_default_bind_host.py diff --git a/.env.example b/.env.example index 3f66d0ae6..3f27fc7c9 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,14 @@ INFOQUEST_API_KEY=your-infoquest-api-key # Leave unset when using the unified nginx endpoint, e.g. http://localhost:2026. # GATEWAY_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +# Host interface the Docker stack publishes its entry port on. Defaults to +# 127.0.0.1 (loopback only), matching the local-trusted-environment deployment +# model documented in README.md -- the agent can execute commands. +# Set 0.0.0.0 only when the host is protected by your own TLS/auth front door +# or firewall, and complete first-run setup before it becomes reachable. +# BIND_HOST=0.0.0.0 +# PORT=2026 + # Optional: # FIRECRAWL_API_KEY=your-firecrawl-api-key # VOLCENGINE_API_KEY=your-volcengine-api-key diff --git a/AGENTS.md b/AGENTS.md index c9aab70db..1580d4c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,16 @@ to the Gateway's LangGraph runtime, rewriting it to Gateway's native `/api/*` ro other `/api/*` go straight to the Gateway REST routers. See [backend/AGENTS.md](backend/AGENTS.md) for the runtime and router detail. +Both compose files publish that entry as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"` +— **loopback by default**, matching the README's documented deployment model. A bare +`"${PORT}:2026"` binds `0.0.0.0`, which does not. +Nginx itself listens `default_server` on IPv4+IPv6 and the +Gateway binds `0.0.0.0:8001` inside the container on purpose — both are container- +internal; the published nginx port is the entire external surface, and the Gateway's +`8001` is deliberately not published. Any new published port needs an explicit bind +address; `backend/tests/test_compose_default_bind_host.py` pins this for every service +in both compose files. + ## Repository Map ``` diff --git a/README.md b/README.md index e8891daef..976463959 100644 --- a/README.md +++ b/README.md @@ -1136,6 +1136,17 @@ DeerFlow has key high-privilege capabilities including **system command executio - **Unauthorized illegal invocation**: Agent functionality could be discovered by unauthorized third parties or malicious internet scanners, triggering bulk unauthorized requests that execute high-risk operations such as system commands and file read/write, potentially causing serious security consequences. - **Compliance and legal risks**: If the agent is illegally invoked to conduct cyberattacks, data theft, or other illegal activities, it may result in legal liability and compliance risks. +### Deployment Defaults + +The Docker stack publishes its entry port on `127.0.0.1` only, matching the +local-trusted-environment model described above. To reach it from another +machine, set `BIND_HOST` in `.env` (e.g. `BIND_HOST=0.0.0.0`) — and only after +putting the security measures below in place. + +**Complete first-run setup before the host becomes reachable.** A fresh +instance has no accounts yet, so create the admin account through `/setup` +immediately after starting any deployment that is not loopback-only. + ### Security Recommendations **Note: We strongly recommend deploying DeerFlow in a local trusted network environment.** If you need cross-device or cross-network deployment, you must implement strict security measures, such as: diff --git a/backend/tests/test_compose_default_bind_host.py b/backend/tests/test_compose_default_bind_host.py new file mode 100644 index 000000000..1064bb53a --- /dev/null +++ b/backend/tests/test_compose_default_bind_host.py @@ -0,0 +1,112 @@ +"""Regression test for the Docker Compose default published bind address. + +``README.md`` documents DeerFlow as being deployed by default "in a local +trusted environment (accessible only via the 127.0.0.1 loopback interface)", +but the shipped compose files published the nginx entry as +``"${PORT:-2026}:2026"``, which Docker binds to ``0.0.0.0`` (and ``[::]``). The +shipped artifact therefore did not match its own documented default, and an +operator running it on a LAN or cloud host got a wider surface than the docs +implied without changing anything. + +The Gateway itself binds ``0.0.0.0`` inside the container on purpose (nginx has +to reach it over the compose network) and its port is deliberately not +published, so the published nginx port is the whole external surface. This test +pins the loopback default there while keeping it overridable for operators who +intentionally expose the stack behind their own TLS/auth front door. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +COMPOSE_PATHS = { + "prod": REPO_ROOT / "docker" / "docker-compose.yaml", + "dev": REPO_ROOT / "docker" / "docker-compose-dev.yaml", +} + +EXPECTED_NGINX_PORT_MAPPING = "${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026" + + +def _published_ports(compose_path: Path) -> dict[str, list[str]]: + """Return {service_name: [port mapping, ...]} for every published port.""" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + published: dict[str, list[str]] = {} + for service_name, service in (compose.get("services") or {}).items(): + ports = service.get("ports") if isinstance(service, dict) else None + if not ports: + continue + published[service_name] = [str(entry) for entry in ports] + return published + + +@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS)) +def test_nginx_entry_defaults_to_loopback(variant: str): + """With BIND_HOST unset, the entry port must bind 127.0.0.1, not 0.0.0.0.""" + published = _published_ports(COMPOSE_PATHS[variant]) + + assert published.get("nginx") == [EXPECTED_NGINX_PORT_MAPPING], f"{variant} compose must publish nginx as {EXPECTED_NGINX_PORT_MAPPING!r}; got: {published.get('nginx')!r}" + + +@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS)) +def test_no_service_publishes_on_all_interfaces(variant: str): + """No compose service may publish a port without an explicit bind address. + + A bare ``"HOST:CONTAINER"`` mapping binds every interface. Any port added + later must either stay internal to the compose network or opt in to the + same ``BIND_HOST`` default. + """ + offenders: list[str] = [] + for service_name, mappings in _published_ports(COMPOSE_PATHS[variant]).items(): + for mapping in mappings: + # A bind address is present only when the mapping has three + # colon-separated parts (``ADDR:HOST:CONTAINER``). Variable + # substitutions such as ``${PORT:-2026}`` also contain colons, so + # count separators outside ``${...}`` instead of splitting naively. + if _bind_address(mapping) is None: + offenders.append(f"{service_name}: {mapping}") + + assert not offenders, f"{variant} compose publishes ports on all interfaces (add a bind address): {offenders}" + + +@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS)) +def test_bind_address_remains_overridable(variant: str): + """Operators fronting the stack themselves must be able to widen the bind.""" + mapping = _published_ports(COMPOSE_PATHS[variant])["nginx"][0] + + assert _bind_address(mapping) == "${BIND_HOST:-127.0.0.1}", f"{variant} compose must keep the bind address overridable via BIND_HOST; got: {mapping!r}" + + +def _bind_address(mapping: str) -> str | None: + """Return the bind-address segment of a compose port mapping, if any. + + Splits on ``:`` at nesting depth zero so ``${PORT:-2026}`` is treated as a + single segment rather than two. + """ + segments: list[str] = [] + current: list[str] = [] + depth = 0 + index = 0 + while index < len(mapping): + char = mapping[index] + if mapping.startswith("${", index): + depth += 1 + current.append("${") + index += 2 + continue + if char == "}" and depth > 0: + depth -= 1 + elif char == ":" and depth == 0: + segments.append("".join(current)) + current = [] + index += 1 + continue + current.append(char) + index += 1 + segments.append("".join(current)) + + # ADDR:HOST:CONTAINER -> bound; HOST:CONTAINER or CONTAINER -> unbound. + return segments[0] if len(segments) >= 3 else None diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index 6ff1d9cb3..35a9dd9e1 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -92,8 +92,11 @@ services: nginx: image: nginx:alpine container_name: deer-flow-nginx + # Loopback-only by default; see the note in docker-compose.yaml. Override + # with BIND_HOST when you deliberately need the dev stack reachable from + # another machine. ports: - - "2026:2026" + - "${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf.template:ro command: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 461d8ec3a..3744d12f2 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -45,8 +45,13 @@ services: nginx: image: nginx:alpine container_name: deer-flow-nginx + # Loopback-only by default: DeerFlow's agent can execute commands, so the + # documented default deployment is a local trusted environment. A bare + # "${PORT}:2026" would bind 0.0.0.0 instead, which does not match that. + # Set BIND_HOST=0.0.0.0 only behind your own TLS/auth front door, and + # complete first-run setup before the host becomes reachable. ports: - - "${PORT:-2026}:2026" + - "${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf.template:ro command: > diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 56697ce04..a07db701c 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -73,6 +73,37 @@ load_uv_extras_from_dotenv() { load_uv_extras_from_dotenv +# Read one key from $ENV_FILE the way compose --env-file interpolates it, so the +# final summary reports the values the stack actually came up with. The shell +# does not source $ENV_FILE, so reading these from the environment alone would +# report "loopback only" for a stack that .env exposed to the network. +read_dotenv_value() { + local key="$1" + local line="" + local value="" + + # An exported shell variable wins, matching compose precedence. + if [ -n "${!key+x}" ]; then + printf '%s' "${!key}" + return 0 + fi + + [ -f "$ENV_FILE" ] || return 0 + + line="$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$ENV_FILE" | tail -n 1 || true)" + [ -n "$line" ] || return 0 + + value="${line#*=}" + value="${value%$'\r'}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} + # ── Colors ──────────────────────────────────────────────────────────────────── GREEN='\033[0;32m' @@ -373,11 +404,26 @@ echo "==========================================" echo " DeerFlow is running!" echo "==========================================" echo "" -echo " 🌐 Application: http://localhost:${PORT:-2026}" -echo " 📡 API Gateway: http://localhost:${PORT:-2026}/api/*" +RESOLVED_PORT="$(read_dotenv_value PORT)" +RESOLVED_PORT="${RESOLVED_PORT:-2026}" +RESOLVED_BIND_HOST="$(read_dotenv_value BIND_HOST)" +RESOLVED_BIND_HOST="${RESOLVED_BIND_HOST:-127.0.0.1}" + +echo " 🌐 Application: http://localhost:${RESOLVED_PORT}" +echo " 📡 API Gateway: http://localhost:${RESOLVED_PORT}/api/*" echo " 🤖 Runtime: Gateway embedded" echo " API: /api/langgraph/* → Gateway" echo "" +if [ "$RESOLVED_BIND_HOST" = "127.0.0.1" ] || [ "$RESOLVED_BIND_HOST" = "::1" ] || [ "$RESOLVED_BIND_HOST" = "localhost" ]; then + echo " 🔒 Bound to ${RESOLVED_BIND_HOST} — reachable from this machine only." + echo " To expose it, set BIND_HOST in .env, put TLS/auth in front, and" + echo " create the admin account before the host becomes reachable." +else + echo " ⚠️ Bound to ${RESOLVED_BIND_HOST} — reachable from the network." + echo " Open http://localhost:${RESOLVED_PORT} and complete first-run" + echo " setup now, before anyone else reaches this host." +fi +echo "" echo " Manage:" echo " make down — stop and remove containers" echo " make docker-logs — view logs"