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:
Dan Caldr 2026-09-17 01:55:22 +02:00 committed by GitHub
parent d8d110c637
commit f2857ad3ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 167 additions and 31 deletions

View File

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

View File

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

View File

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