mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
fix(sandbox): default to loopback bind on Docker Desktop for DooD sandboxes (#5446)
* fix(sandbox): default to loopback bind on Docker Desktop for DooD sandboxes (#5445) * fix(sandbox): memoize desktop detection and clarify bind host docstring (#5445) * fix(sandbox): latch desktop detection on success only to permit retry on transient failure (#5445) * fix(sandbox): restrict desktop loopback bind to local DooD hostnames (#5445) * fix(sandbox): add Desktop legacy aliases and parametrize DooD host tests (#5445)
This commit is contained in:
parent
d8d110c637
commit
f2857ad3ff
@ -901,7 +901,7 @@ require supply-chain pinning.
|
||||
|
||||
#### Sandbox container network exposure and hardening
|
||||
|
||||
The sandbox HTTP API (`/v1/shell/*` and friends) has no authentication: anyone who can reach a published sandbox port can execute arbitrary commands in that sandbox. For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox port to `127.0.0.1` so it is not exposed on other host interfaces. For Docker-outside-of-Docker deployments that connect through `host.docker.internal`, the port is bound to the address that hostname actually resolves to — the daemon's `host-gateway-ip` mapping (customizable, possibly IPv6) — so the published port and the address the gateway connects to always match, and the port is no longer published on external network interfaces (previously it was bound to `0.0.0.0`). If resolution fails, the Docker default bridge gateway (via `docker network inspect bridge`, falling back to `172.17.0.1`) is used as a best-effort bind and a warning is logged. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address; setting it to `0.0.0.0` restores the legacy broad bind, which re-exposes the unauthenticated exec API on every interface and should be paired with an external firewall.
|
||||
The sandbox HTTP API (`/v1/shell/*` and friends) has no authentication: anyone who can reach a published sandbox port can execute arbitrary commands in that sandbox. For bare-metal Docker sandbox runs that use localhost, DeerFlow binds the sandbox port to `127.0.0.1` so it is not exposed on other host interfaces. For Docker-outside-of-Docker deployments that connect through `host.docker.internal`, the port is bound to the address that hostname actually resolves to — the daemon's `host-gateway-ip` mapping (customizable, possibly IPv6) — so the published port and the address the gateway connects to always match, and the port is no longer published on external network interfaces (previously it was bound to `0.0.0.0`). On Docker Desktop, resolving `host.docker.internal` yields an internal VM gateway address that the host OS cannot bind; because Docker Desktop forwards `host.docker.internal` to host loopback, DeerFlow defaults to `127.0.0.1` for `host.docker.internal` on Desktop daemons. Custom non-loopback sandbox hosts continue to bind their resolved address. If resolution fails, the Docker default bridge gateway (via `docker network inspect bridge`, falling back to `172.17.0.1`) is used as a best-effort bind and a warning is logged. Set `DEER_FLOW_SANDBOX_BIND_HOST` explicitly if your deployment needs a different bind address; setting it to `0.0.0.0` restores the legacy broad bind, which re-exposes the unauthenticated exec API on every interface and should be paired with an external firewall.
|
||||
|
||||
Local Docker sandbox containers are also hardened by default: all Linux capabilities are dropped (`--cap-drop=ALL`) except a five-capability compatibility allowlist — `CHOWN`, `FOWNER`, `SETUID`, `SETGID`, and `DAC_OVERRIDE` — while privilege escalation across exec stays blocked with `no-new-privileges` and CPU/memory/PID resources are bounded. `CHOWN`/`SETUID`/`SETGID` support the runtime user handoff and `DAC_OVERRIDE` supports the root nginx master's writes to gem-owned logs. `FOWNER` is specifically required by the newer AIO 1.11.x startup path (regression-tested against the recommended 1.11.0 image), which runs `chmod /run/user/1000` after capabilities are dropped. Images that do not perform that `chmod` do not need `FOWNER`; DeerFlow deliberately does not guess a smaller set from mutable tags, digests, or arbitrary custom images, so the default compatibility allowlist remains version-agnostic.
|
||||
|
||||
@ -909,7 +909,7 @@ A custom image that is already fully initialized as a non-root user and needs no
|
||||
|
||||
| Environment variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `DEER_FLOW_SANDBOX_BIND_HOST` | loopback / bridge gateway (see above) | Host interface for the sandbox `-p` publish. Must be an IP literal (bare or bracketed IPv6) or a hostname, which is resolved to an address first — Docker publish specs do not accept hostnames. `0.0.0.0` restores the legacy broad bind (risky). |
|
||||
| `DEER_FLOW_SANDBOX_BIND_HOST` | loopback (localhost or Docker Desktop with `host.docker.internal`) / host-gateway-ip / bridge gateway | Host interface for the sandbox `-p` publish. Must be an IP literal (bare or bracketed IPv6) or a hostname, which is resolved to an address first — Docker publish specs do not accept hostnames. `0.0.0.0` restores the legacy broad bind (risky). |
|
||||
| `DEER_FLOW_SANDBOX_SECCOMP_UNCONFINED` | on | The shipped AIO image's Chromium browser does not start under Docker's default seccomp profile (see the upstream agent-infra sandbox FAQ), so `seccomp=unconfined` remains the default. Set to `0` to run with the built-in profile — passed explicitly as `seccomp=builtin`, so a daemon configured with a different default cannot weaken the opt-out — and only for images verified to start and pass browser checks with it. |
|
||||
| `DEER_FLOW_SANDBOX_IMAGE_STARTUP_CAPS` | on | Keeps the five-capability compatibility set (`CHOWN`/`FOWNER`/`SETUID`/`SETGID`/`DAC_OVERRIDE`). `FOWNER` specifically covers the newer AIO 1.11.x startup `chmod /run/user/1000` path (tested with 1.11.0); images without that step do not need `FOWNER`, but DeerFlow does not infer per-image capability subsets from tags/digests/custom images. Set to `0` only for images that need none of the five — the switch drops the entire set. |
|
||||
| `DEER_FLOW_SANDBOX_SECCOMP_PROFILE` | unset | Path to a custom seccomp profile (e.g. a restricted, Chromium-compatible one built from Docker's default plus the namespace syscalls Chromium needs). Takes precedence over the unconfined default. |
|
||||
|
||||
@ -276,6 +276,45 @@ def _docker_bridge_gateway_ip() -> str | None:
|
||||
return candidate
|
||||
|
||||
|
||||
_DOCKER_SERVER_IS_DESKTOP: bool | None = None
|
||||
|
||||
|
||||
def _docker_server_is_desktop() -> bool:
|
||||
"""Detect Desktop from the daemon, including a Linux DooD Gateway."""
|
||||
global _DOCKER_SERVER_IS_DESKTOP
|
||||
if _DOCKER_SERVER_IS_DESKTOP is not None:
|
||||
return _DOCKER_SERVER_IS_DESKTOP
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info", "--format", "{{json .OperatingSystem}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("Could not identify the Docker server platform; assuming non-Desktop: %s", exc)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
logger.warning("Could not identify the Docker server platform; assuming non-Desktop: %s", (result.stderr or "").strip())
|
||||
return False
|
||||
raw = (result.stdout or "").strip()
|
||||
try:
|
||||
operating_system = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
operating_system = raw
|
||||
is_desktop = isinstance(operating_system, str) and "docker desktop" in operating_system.lower()
|
||||
_DOCKER_SERVER_IS_DESKTOP = is_desktop
|
||||
return is_desktop
|
||||
|
||||
|
||||
def _clear_docker_desktop_cache() -> None:
|
||||
global _DOCKER_SERVER_IS_DESKTOP
|
||||
_DOCKER_SERVER_IS_DESKTOP = None
|
||||
|
||||
|
||||
_docker_server_is_desktop.cache_clear = _clear_docker_desktop_cache # type: ignore[attr-defined]
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@ -290,15 +329,21 @@ def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str |
|
||||
the address the sandbox host itself resolves to: ``host.docker.internal``
|
||||
follows the daemon's ``host-gateway-ip`` mapping (customizable, possibly
|
||||
IPv6), so resolving it yields exactly where the gateway will connect —
|
||||
the published port and the advertised sandbox URL always match. Only
|
||||
when resolution fails does the default bridge gateway serve as a
|
||||
best-effort fallback (with a warning). Operators that genuinely need the
|
||||
old broad bind (e.g. remote clients connecting to the sandbox API
|
||||
directly) can restore it with ``DEER_FLOW_SANDBOX_BIND_HOST=0.0.0.0`` —
|
||||
that re-exposes an unauthenticated shell endpoint and should be paired
|
||||
with an external firewall. 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.
|
||||
the published port and the advertised sandbox URL always match. On
|
||||
Docker Desktop, resolving ``host.docker.internal`` yields an internal VM
|
||||
gateway address that the host OS cannot bind, so Desktop daemons default
|
||||
to host loopback (127.0.0.1) for ``host.docker.internal``; Desktop forwards
|
||||
``host.docker.internal`` to host loopback automatically. Custom non-loopback
|
||||
sandbox hosts on Desktop daemons continue to bind their resolved address.
|
||||
Only when resolution fails does the
|
||||
default bridge gateway serve as a best-effort fallback (with a warning).
|
||||
Operators that genuinely need the old broad bind (e.g. remote clients
|
||||
connecting to the sandbox API directly) can restore it with
|
||||
``DEER_FLOW_SANDBOX_BIND_HOST=0.0.0.0`` — that re-exposes an
|
||||
unauthenticated shell endpoint and should be paired with an external
|
||||
firewall. 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", "").strip()
|
||||
if explicit_bind:
|
||||
@ -328,6 +373,17 @@ def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str |
|
||||
logger.debug("Docker sandbox bind: 127.0.0.1 (loopback default)")
|
||||
return "127.0.0.1"
|
||||
|
||||
if _docker_server_is_desktop() and host.strip().rstrip(".").lower() in (
|
||||
"host.docker.internal",
|
||||
"gateway.docker.internal",
|
||||
"docker.for.mac.host.internal",
|
||||
"docker.for.mac.localhost",
|
||||
"docker.for.win.host.internal",
|
||||
"docker.for.win.localhost",
|
||||
):
|
||||
logger.debug("Docker sandbox bind: 127.0.0.1 (Docker Desktop host loopback)")
|
||||
return "127.0.0.1"
|
||||
|
||||
resolved = _resolve_sandbox_host_address(host)
|
||||
if resolved:
|
||||
logger.debug(
|
||||
@ -748,25 +804,7 @@ class LocalContainerBackend(SandboxBackend):
|
||||
|
||||
def _docker_server_is_desktop(self) -> bool:
|
||||
"""Detect Desktop from the daemon, including a Linux DooD Gateway."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info", "--format", "{{json .OperatingSystem}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("Could not identify the Docker server platform; Desktop synthetic DNS answers remain disabled: %s", exc)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
logger.warning("Could not identify the Docker server platform; Desktop synthetic DNS answers remain disabled: %s", (result.stderr or "").strip())
|
||||
return False
|
||||
raw = (result.stdout or "").strip()
|
||||
try:
|
||||
operating_system = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
operating_system = raw
|
||||
return isinstance(operating_system, str) and "docker desktop" in operating_system.lower()
|
||||
return _docker_server_is_desktop()
|
||||
|
||||
def _docker_has_managed_sandboxes(self) -> bool:
|
||||
"""Keep using Docker while this prefix still has managed sandboxes.
|
||||
|
||||
@ -11,6 +11,7 @@ import pytest
|
||||
from deerflow.community.aio_sandbox.local_backend import (
|
||||
LocalContainerBackend,
|
||||
_ContainerInspection,
|
||||
_docker_server_is_desktop,
|
||||
_format_container_command_for_log,
|
||||
_format_container_mount,
|
||||
_NetworkInspection,
|
||||
@ -185,9 +186,36 @@ def test_docker_desktop_detection_uses_daemon_operating_system(monkeypatch, oper
|
||||
assert cmd == ["docker", "info", "--format", "{{json .OperatingSystem}}"]
|
||||
return SimpleNamespace(stdout=operating_system, stderr="", returncode=0)
|
||||
|
||||
_docker_server_is_desktop.cache_clear()
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
|
||||
assert backend._docker_server_is_desktop() is expected
|
||||
try:
|
||||
assert backend._docker_server_is_desktop() is expected
|
||||
finally:
|
||||
_docker_server_is_desktop.cache_clear()
|
||||
|
||||
|
||||
def test_docker_desktop_detection_retries_after_transient_failure(monkeypatch):
|
||||
"""A transient probe failure is not cached; subsequent call can detect Desktop."""
|
||||
attempts = 0
|
||||
|
||||
def fake_run(cmd, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return SimpleNamespace(stdout="", stderr="daemon starting", returncode=1)
|
||||
return SimpleNamespace(stdout='"Docker Desktop"', stderr="", returncode=0)
|
||||
|
||||
_docker_server_is_desktop.cache_clear()
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
|
||||
try:
|
||||
assert _docker_server_is_desktop() is False
|
||||
assert _docker_server_is_desktop() is True
|
||||
assert _docker_server_is_desktop() is True
|
||||
assert attempts == 2
|
||||
finally:
|
||||
_docker_server_is_desktop.cache_clear()
|
||||
|
||||
|
||||
def test_darwin_open_keeps_docker_to_reconcile_restricted_sandbox(monkeypatch):
|
||||
@ -651,6 +679,10 @@ def test_resolve_docker_bind_host_follows_host_gateway_mapping_for_dood(monkeypa
|
||||
"""The bind follows what host.docker.internal actually resolves to."""
|
||||
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
||||
lambda host: "192.168.64.1",
|
||||
@ -659,10 +691,68 @@ def test_resolve_docker_bind_host_follows_host_gateway_mapping_for_dood(monkeypa
|
||||
assert _resolve_docker_bind_host() == "192.168.64.1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sandbox_host",
|
||||
[
|
||||
"host.docker.internal",
|
||||
"gateway.docker.internal",
|
||||
"Host.Docker.Internal.",
|
||||
"docker.for.mac.host.internal",
|
||||
"docker.for.win.localhost",
|
||||
],
|
||||
)
|
||||
def test_resolve_docker_bind_host_uses_loopback_on_docker_desktop(monkeypatch, sandbox_host):
|
||||
"""Docker Desktop cannot bind to internal VM gateway IPs, so default to 127.0.0.1."""
|
||||
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", sandbox_host)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
||||
lambda host: "192.168.65.254",
|
||||
)
|
||||
|
||||
assert _resolve_docker_bind_host() == "127.0.0.1"
|
||||
|
||||
|
||||
def test_resolve_docker_bind_host_preserves_custom_host_on_docker_desktop(monkeypatch):
|
||||
"""Custom non-loopback sandbox host on Docker Desktop binds the resolved address."""
|
||||
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "desktop-box")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
||||
lambda host: "192.0.2.55",
|
||||
)
|
||||
|
||||
assert _resolve_docker_bind_host() == "192.0.2.55"
|
||||
|
||||
|
||||
def test_resolve_docker_bind_host_explicit_override_precedes_desktop_detection(monkeypatch):
|
||||
"""Explicit DEER_FLOW_SANDBOX_BIND_HOST takes precedence even on Docker Desktop."""
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_BIND_HOST", "192.0.2.10")
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: True,
|
||||
)
|
||||
|
||||
assert _resolve_docker_bind_host() == "192.0.2.10"
|
||||
|
||||
|
||||
def test_resolve_docker_bind_host_brackets_ipv6_host_gateway(monkeypatch):
|
||||
"""An IPv6 host-gateway mapping binds the bracketed IPv6 address."""
|
||||
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
||||
lambda host: "[fd00::1]",
|
||||
@ -715,6 +805,10 @@ def test_resolve_docker_bind_host_rejects_unresolvable_hostname_override(monkeyp
|
||||
def test_resolve_docker_bind_host_uses_discovered_bridge_gateway_when_resolution_fails(monkeypatch):
|
||||
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
||||
lambda host: None,
|
||||
@ -730,6 +824,10 @@ def test_resolve_docker_bind_host_uses_discovered_bridge_gateway_when_resolution
|
||||
def test_resolve_docker_bind_host_falls_back_to_static_bridge_gateway(monkeypatch):
|
||||
monkeypatch.delenv("DEER_FLOW_SANDBOX_BIND_HOST", raising=False)
|
||||
monkeypatch.setenv("DEER_FLOW_SANDBOX_HOST", "host.docker.internal")
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._docker_server_is_desktop",
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deerflow.community.aio_sandbox.local_backend._resolve_sandbox_host_address",
|
||||
lambda host: None,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user