From 74081a85a6827b013b4f41ca6a7f9e4d9b62069e Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Thu, 30 Apr 2026 11:40:28 +0800 Subject: [PATCH] [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 --- backend/docs/CONFIGURATION.md | 2 + .../community/aio_sandbox/local_backend.py | 49 ++++++- .../tests/test_aio_sandbox_local_backend.py | 120 +++++++++++++++++- 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 3c7fe8dd7..f87fdd236 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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`. +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 Configure the skills directory for specialized workflows: diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py index 15cbe3b78..92d933d89 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/local_backend.py @@ -127,6 +127,48 @@ def _format_container_command_for_log(cmd: list[str]) -> str: 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): """Backend that manages sandbox containers locally using Docker or Apple Container. @@ -465,12 +507,17 @@ class LocalContainerBackend(SandboxBackend): if self._runtime == "docker": 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( [ "--rm", "-d", "-p", - f"{port}:8080", + port_mapping, "--name", container_name, ] diff --git a/backend/tests/test_aio_sandbox_local_backend.py b/backend/tests/test_aio_sandbox_local_backend.py index d74786682..333c3eb53 100644 --- a/backend/tests/test_aio_sandbox_local_backend.py +++ b/backend/tests/test_aio_sandbox_local_backend.py @@ -2,7 +2,13 @@ import logging import os 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(): @@ -116,3 +122,115 @@ def test_start_container_logs_redacted_env_values(monkeypatch, caplog): assert "NORMAL=" in log_output assert "secret-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"