mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-24 08:55:59 +00:00
[security] fix(sandbox): bind local Docker ports to loopback (#2633)
* fix(sandbox): bind local Docker ports to loopback * fix(sandbox): preserve IPv6 loopback Docker binds * fix(sandbox): log Docker bind host selection
This commit is contained in:
@@ -259,6 +259,8 @@ sandbox:
|
|||||||
|
|
||||||
When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` values in the agent prompt so the agent can discover and operate on mounted directories directly instead of assuming everything must live under `/mnt/user-data`.
|
When you configure `sandbox.mounts`, DeerFlow exposes those `container_path` values in the agent prompt so the agent can discover and operate on mounted directories directly instead of assuming everything must live under `/mnt/user-data`.
|
||||||
|
|
||||||
|
For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox HTTP port to `127.0.0.1` by default so it is not exposed on every host interface. Docker-outside-of-Docker deployments that connect through `host.docker.internal` keep the broad legacy bind for compatibility. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address.
|
||||||
|
|
||||||
### Skills
|
### Skills
|
||||||
|
|
||||||
Configure the skills directory for specialized workflows:
|
Configure the skills directory for specialized workflows:
|
||||||
|
|||||||
@@ -127,6 +127,48 @@ def _format_container_command_for_log(cmd: list[str]) -> str:
|
|||||||
return shlex.join(cmd)
|
return shlex.join(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sandbox_host(host: str) -> str:
|
||||||
|
return host.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ipv6_loopback_sandbox_host(host: str) -> bool:
|
||||||
|
return _normalize_sandbox_host(host) in {"::1", "[::1]"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_loopback_sandbox_host(host: str) -> bool:
|
||||||
|
return _normalize_sandbox_host(host) in {"", "localhost", "127.0.0.1", "::1", "[::1]"}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str | None = None) -> str:
|
||||||
|
"""Choose the host interface for legacy Docker ``-p`` sandbox publishing.
|
||||||
|
|
||||||
|
Bare-metal/local runs talk to sandboxes through localhost and should not
|
||||||
|
expose the sandbox HTTP API on every host interface. Docker-outside-of-
|
||||||
|
Docker deployments commonly use ``host.docker.internal`` from another
|
||||||
|
container; keep their legacy broad bind unless operators opt into a
|
||||||
|
narrower bind with ``DEER_FLOW_SANDBOX_BIND_HOST``. When operators choose
|
||||||
|
an IPv6 loopback sandbox host, bind Docker to IPv6 loopback as well so the
|
||||||
|
advertised sandbox URL and published socket use the same address family.
|
||||||
|
"""
|
||||||
|
explicit_bind = bind_host if bind_host is not None else os.environ.get("DEER_FLOW_SANDBOX_BIND_HOST")
|
||||||
|
if explicit_bind is not None:
|
||||||
|
explicit_bind = explicit_bind.strip()
|
||||||
|
if explicit_bind:
|
||||||
|
logger.debug("Docker sandbox bind: %s (explicit bind host override)", explicit_bind)
|
||||||
|
return explicit_bind
|
||||||
|
|
||||||
|
host = sandbox_host if sandbox_host is not None else os.environ.get("DEER_FLOW_SANDBOX_HOST", "localhost")
|
||||||
|
if _is_ipv6_loopback_sandbox_host(host):
|
||||||
|
logger.debug("Docker sandbox bind: [::1] (IPv6 loopback sandbox host)")
|
||||||
|
return "[::1]"
|
||||||
|
if _is_loopback_sandbox_host(host):
|
||||||
|
logger.debug("Docker sandbox bind: 127.0.0.1 (loopback default)")
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
logger.debug("Docker sandbox bind: 0.0.0.0 (non-loopback sandbox host compatibility)")
|
||||||
|
return "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
class LocalContainerBackend(SandboxBackend):
|
class LocalContainerBackend(SandboxBackend):
|
||||||
"""Backend that manages sandbox containers locally using Docker or Apple Container.
|
"""Backend that manages sandbox containers locally using Docker or Apple Container.
|
||||||
|
|
||||||
@@ -465,12 +507,17 @@ class LocalContainerBackend(SandboxBackend):
|
|||||||
if self._runtime == "docker":
|
if self._runtime == "docker":
|
||||||
cmd.extend(["--security-opt", "seccomp=unconfined"])
|
cmd.extend(["--security-opt", "seccomp=unconfined"])
|
||||||
|
|
||||||
|
if self._runtime == "docker":
|
||||||
|
port_mapping = f"{_resolve_docker_bind_host()}:{port}:8080"
|
||||||
|
else:
|
||||||
|
port_mapping = f"{port}:8080"
|
||||||
|
|
||||||
cmd.extend(
|
cmd.extend(
|
||||||
[
|
[
|
||||||
"--rm",
|
"--rm",
|
||||||
"-d",
|
"-d",
|
||||||
"-p",
|
"-p",
|
||||||
f"{port}:8080",
|
port_mapping,
|
||||||
"--name",
|
"--name",
|
||||||
container_name,
|
container_name,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from deerflow.community.aio_sandbox.local_backend import LocalContainerBackend, _format_container_command_for_log, _format_container_mount, _redact_container_command_for_log
|
from deerflow.community.aio_sandbox.local_backend import (
|
||||||
|
LocalContainerBackend,
|
||||||
|
_format_container_command_for_log,
|
||||||
|
_format_container_mount,
|
||||||
|
_redact_container_command_for_log,
|
||||||
|
_resolve_docker_bind_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths():
|
def test_format_container_mount_uses_mount_syntax_for_docker_windows_paths():
|
||||||
@@ -116,3 +122,115 @@ def test_start_container_logs_redacted_env_values(monkeypatch, caplog):
|
|||||||
assert "NORMAL=<redacted>" in log_output
|
assert "NORMAL=<redacted>" in log_output
|
||||||
assert "secret-value" not in log_output
|
assert "secret-value" not in log_output
|
||||||
assert "visible-value" not in log_output
|
assert "visible-value" not in log_output
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_start_container_command(monkeypatch, backend: LocalContainerBackend, runtime: str = "docker") -> list[str]:
|
||||||
|
monkeypatch.setattr(backend, "_runtime", runtime)
|
||||||
|
captured_cmd: list[str] = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
captured_cmd.extend(cmd)
|
||||||
|
return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("subprocess.run", fake_run)
|
||||||
|
backend._start_container("sandbox-test", 18080)
|
||||||
|
return captured_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_docker_bind_host_defaults_loopback_for_localhost(monkeypatch):
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_HOST", raising=False)
|
||||||
|
|
||||||
|
assert _resolve_docker_bind_host() == "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_docker_bind_host_keeps_dood_compatibility(monkeypatch):
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||||
|
|
||||||
|
assert _resolve_docker_bind_host() == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_docker_bind_host_uses_ipv6_loopback_for_ipv6_sandbox_host(monkeypatch):
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]")
|
||||||
|
|
||||||
|
assert _resolve_docker_bind_host() == "[::1]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_docker_bind_host_logs_selected_bind_reason(caplog):
|
||||||
|
with caplog.at_level(logging.DEBUG, logger="deerflow.community.aio_sandbox.local_backend"):
|
||||||
|
assert _resolve_docker_bind_host(sandbox_host="localhost", bind_host="") == "127.0.0.1"
|
||||||
|
|
||||||
|
messages = "\n".join(record.getMessage() for record in caplog.records)
|
||||||
|
assert "Docker sandbox bind: 127.0.0.1 (loopback default)" in messages
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_docker_bind_host_allows_explicit_override(monkeypatch):
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "localhost")
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.0.2.10")
|
||||||
|
|
||||||
|
assert _resolve_docker_bind_host() == "192.0.2.10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_container_binds_local_docker_port_to_loopback_by_default(monkeypatch):
|
||||||
|
backend = LocalContainerBackend(
|
||||||
|
image="sandbox:latest",
|
||||||
|
base_port=8080,
|
||||||
|
container_prefix="sandbox",
|
||||||
|
config_mounts=[],
|
||||||
|
environment={},
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_HOST", raising=False)
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||||
|
|
||||||
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
||||||
|
|
||||||
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "127.0.0.1:18080:8080"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_container_keeps_broad_bind_for_dood_sandbox_host(monkeypatch):
|
||||||
|
backend = LocalContainerBackend(
|
||||||
|
image="sandbox:latest",
|
||||||
|
base_port=8080,
|
||||||
|
container_prefix="sandbox",
|
||||||
|
config_mounts=[],
|
||||||
|
environment={},
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||||
|
|
||||||
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
||||||
|
|
||||||
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "0.0.0.0:18080:8080"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_container_binds_ipv6_sandbox_host_to_ipv6_loopback(monkeypatch):
|
||||||
|
backend = LocalContainerBackend(
|
||||||
|
image="sandbox:latest",
|
||||||
|
base_port=8080,
|
||||||
|
container_prefix="sandbox",
|
||||||
|
config_mounts=[],
|
||||||
|
environment={},
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "[::1]")
|
||||||
|
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||||
|
|
||||||
|
captured_cmd = _capture_start_container_command(monkeypatch, backend)
|
||||||
|
|
||||||
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "[::1]:18080:8080"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_container_keeps_apple_container_port_format(monkeypatch):
|
||||||
|
backend = LocalContainerBackend(
|
||||||
|
image="sandbox:latest",
|
||||||
|
base_port=8080,
|
||||||
|
container_prefix="sandbox",
|
||||||
|
config_mounts=[],
|
||||||
|
environment={},
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "127.0.0.1")
|
||||||
|
|
||||||
|
captured_cmd = _capture_start_container_command(monkeypatch, backend, runtime="container")
|
||||||
|
|
||||||
|
assert captured_cmd[captured_cmd.index("-p") + 1] == "18080:8080"
|
||||||
|
|||||||
Reference in New Issue
Block a user