mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
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:
parent
5b7ada0cac
commit
2a143dced6
@ -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
|
||||
|
||||
10
AGENTS.md
10
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
|
||||
|
||||
```
|
||||
|
||||
11
README.md
11
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:
|
||||
|
||||
112
backend/tests/test_compose_default_bind_host.py
Normal file
112
backend/tests/test_compose_default_bind_host.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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: >
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user