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.
This commit is contained in:
Nan Gao 2026-08-01 02:35:02 +02:00 committed by GitHub
parent 5b7ada0cac
commit 2a143dced6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 199 additions and 4 deletions

View File

@ -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

View File

@ -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
```

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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: >

View File

@ -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"