From e17a49c0f026c2ad85f6178046fcf001e7d64d7d Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:58:31 -0700
Subject: [PATCH 1/2] fix(docker-entrypoint): support VPN sandbox networking
and target hostname mappings
STRIX_DOCKER_SANDBOX_NETWORK=host attaches the host network, drops
published ports, sets STRIX_CAIDO_LISTEN_ADDRESS so the entrypoint binds
the proxy to 127.0.0.1, and resolves that fixed port on the daemon
loopback. Any other value attaches the named network and uses the
container address there; leaving it unset keeps the published localhost
port. STRIX_DOCKER_EXTRA_HOSTS merges a JSON map of hostnames to literal
IPv4 or IPv6 addresses into the sandbox hosts entries, keeps
host.docker.internal reserved for host-gateway, and rejects invalid
JSON, empty names, and non-IP values before the container is created.
The configuration notes that host mode follows a VPN only with a local
Linux Docker engine and that port 48080 allows one host-mode scan per
daemon.
Fixes #1402
---
containers/docker-entrypoint.sh | 7 +-
docs/advanced/configuration.mdx | 19 ++
strix/runtime/docker_client.py | 116 +++++++++++-
tests/test_docker_client_network.py | 267 ++++++++++++++++++++++++++++
4 files changed, 401 insertions(+), 8 deletions(-)
create mode 100644 tests/test_docker_client_network.py
diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh
index e4f472d62..e1ab0b4f5 100644
--- a/containers/docker-entrypoint.sh
+++ b/containers/docker-entrypoint.sh
@@ -38,7 +38,12 @@ if [ -n "${STRIX_CAIDO_ALLOWED_DOMAINS:-}" ]; then
done
fi
-caido-cli --listen 0.0.0.0:${CAIDO_PORT} \
+# Bridge mode publishes this port on the host loopback. Host networking sets
+# STRIX_CAIDO_LISTEN_ADDRESS=127.0.0.1 so --allow-guests is not reachable on
+# every host interface. Unset keeps the bridge listener.
+CAIDO_LISTEN_ADDRESS="${STRIX_CAIDO_LISTEN_ADDRESS:-0.0.0.0}"
+
+caido-cli --listen "${CAIDO_LISTEN_ADDRESS}:${CAIDO_PORT}" \
--allow-guests \
--no-logging \
--no-open \
diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index 29b003ecf..673502ba8 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -175,6 +175,25 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th
Runtime backend for the sandbox environment.
+
+ Docker network for the sandbox. Leave unset to use the default bridge and publish the Caido port on `127.0.0.1`. Set to `host` when the target is reachable only through a VPN route on a Linux host running a local Docker engine. Any other value is the name of a Docker network to attach.
+
+
+
+ Extra sandbox hosts entries, as a JSON object of hostname to a literal IPv4 or IPv6 address (for example `{"app.htb":"10.10.11.42","v6.htb":"fd00::20"}`). Entries merge with the reserved `host.docker.internal` → `host-gateway` mapping. Empty hostnames, values that are not IP addresses, and replacing `host.docker.internal` are rejected. The host `/etc/hosts` file is not inherited.
+
+
+The sandbox does not copy `/etc/hosts` from the machine running Docker. On a Linux host with a local Docker engine and the VPN already connected, map the lab hostname explicitly:
+
+```bash
+export STRIX_DOCKER_SANDBOX_NETWORK=host
+export STRIX_DOCKER_EXTRA_HOSTS='{"app.htb":"10.10.11.42"}'
+```
+
+`host` shares the Docker engine's network namespace. Use it with a Docker engine on the same Linux machine as Strix. Docker Desktop runs the engine in a virtual machine, so this does not follow a VPN connected on macOS or Windows. A remote daemon (`DOCKER_HOST`) applies host networking on that other machine, while Strix still resolves Caido at `127.0.0.1` on the machine running the CLI.
+
+Host mode binds Caido to the fixed port `48080`, so only one host-mode scan can run on a daemon at a time. The default bridge network publishes that container port to a free localhost port for each scan.
+
## Sandbox Configuration
diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py
index 969267d53..a10221400 100644
--- a/strix/runtime/docker_client.py
+++ b/strix/runtime/docker_client.py
@@ -3,7 +3,7 @@ NET_ADMIN/NET_RAW capabilities + host-gateway.
The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for
extending ``create_kwargs`` before ``containers.create`` is called. We subclass
-and reimplement the method body verbatim from the SDK source, with three
+and reimplement the method body verbatim from the SDK source, with five
deltas:
1. Drop the SDK's ``entrypoint=["tail"]`` override; supply ``["tail", "-f",
@@ -15,6 +15,12 @@ deltas:
other raw-socket tools).
3. Add ``host.docker.internal`` → host-gateway to ``extra_hosts`` so the
agent can reach host-served apps.
+4. Honor ``STRIX_DOCKER_SANDBOX_NETWORK``. ``host`` omits published ports,
+ tells the entrypoint to bind Caido to loopback, and resolves exposed
+ ports on the daemon loopback. Any other name attaches that network and
+ resolves the container IP. Unset keeps the SDK published-port path.
+5. Merge ``STRIX_DOCKER_EXTRA_HOSTS`` (a JSON hostname-to-IP object) into
+ ``extra_hosts``. ``host.docker.internal`` stays reserved for host-gateway.
Pinned to ``openai-agents==0.14.6``. Bumping the SDK requires
re-merging the parent body. Track upstream for an injection hook.
@@ -23,6 +29,8 @@ re-merging the parent body. Track upstream for an injection hook.
from __future__ import annotations
import contextlib
+import ipaddress
+import json
import logging
import os
import uuid
@@ -52,6 +60,13 @@ logger = logging.getLogger(__name__)
_SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK"
+_EXTRA_HOSTS_ENV = "STRIX_DOCKER_EXTRA_HOSTS"
+_HOST_GATEWAY_HOSTNAME = "host.docker.internal"
+_HOST_NETWORK_NAME = "host"
+_DAEMON_LOOPBACK = "127.0.0.1"
+# Read by containers/docker-entrypoint.sh. Set only for host networking so the
+# unauthenticated Caido listener stays on loopback instead of 0.0.0.0.
+_CAIDO_LISTEN_ADDRESS_ENV = "STRIX_CAIDO_LISTEN_ADDRESS"
def _sandbox_network() -> str | None:
@@ -59,11 +74,95 @@ def _sandbox_network() -> str | None:
return value or None
+def _apply_host_network_listener(create_kwargs: dict[str, Any]) -> None:
+ """Point the entrypoint at loopback when the sandbox uses host networking.
+
+ ``docker-entrypoint.sh`` otherwise runs ``caido-cli --listen 0.0.0.0:48080``
+ with guest access enabled. Host networking would expose that proxy on every
+ host interface. Named and bridge networks keep the bridge listener.
+ """
+ raw_environment = create_kwargs.get("environment")
+ if isinstance(raw_environment, dict):
+ environment: dict[str, Any] = dict(cast("dict[str, Any]", raw_environment))
+ else:
+ environment = {}
+ environment[_CAIDO_LISTEN_ADDRESS_ENV] = _DAEMON_LOOPBACK
+ create_kwargs["environment"] = environment
+
+
def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None:
network = _sandbox_network()
- if network:
- create_kwargs["network"] = network
- create_kwargs.pop("ports", None)
+ if not network:
+ return
+ create_kwargs["network"] = network
+ create_kwargs.pop("ports", None)
+ if network == _HOST_NETWORK_NAME:
+ _apply_host_network_listener(create_kwargs)
+
+
+def _extra_host_mappings() -> dict[str, str]:
+ """Hostname → literal IP entries from ``STRIX_DOCKER_EXTRA_HOSTS``.
+
+ Unset or blank leaves the built-in host-gateway mapping untouched. A
+ malformed value raises before the container is created.
+ """
+ raw = os.environ.get(_EXTRA_HOSTS_ENV, "").strip()
+ if not raw:
+ return {}
+
+ try:
+ parsed: object = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ValueError(
+ f"{_EXTRA_HOSTS_ENV} must be a JSON object mapping hostnames to "
+ f"IP addresses ({exc.msg})"
+ ) from exc
+
+ if not isinstance(parsed, dict):
+ got = "array" if isinstance(parsed, list) else type(parsed).__name__
+ raise TypeError(
+ f"{_EXTRA_HOSTS_ENV} must be a JSON object mapping hostnames to IP addresses, got {got}"
+ )
+
+ mappings: dict[str, str] = {}
+ for name, address in cast("dict[object, object]", parsed).items():
+ hostname = _extra_hostname(name)
+ mappings[hostname] = _extra_host_address(hostname, address)
+ return mappings
+
+
+def _extra_hostname(name: object) -> str:
+ if not isinstance(name, str):
+ raise TypeError(f"{_EXTRA_HOSTS_ENV} hostnames must be strings")
+ hostname = name.strip()
+ if not hostname:
+ raise ValueError(f"{_EXTRA_HOSTS_ENV} contains an empty hostname")
+ if hostname == _HOST_GATEWAY_HOSTNAME:
+ raise ValueError(
+ f"{_EXTRA_HOSTS_ENV} cannot replace the reserved {_HOST_GATEWAY_HOSTNAME} mapping"
+ )
+ return hostname
+
+
+def _extra_host_address(hostname: str, address: object) -> str:
+ if not isinstance(address, str):
+ raise TypeError(
+ f"{_EXTRA_HOSTS_ENV} value for {hostname!r} must be an IPv4 or IPv6 address string"
+ )
+ candidate = address.strip()
+ try:
+ ipaddress.ip_address(candidate)
+ except ValueError as exc:
+ raise ValueError(
+ f"{_EXTRA_HOSTS_ENV} value for {hostname!r} is not a literal IPv4 or IPv6 address"
+ ) from exc
+ return candidate
+
+
+def _apply_extra_hosts(create_kwargs: dict[str, Any]) -> None:
+ extra_hosts = create_kwargs.setdefault("extra_hosts", {})
+ extra_hosts[_HOST_GATEWAY_HOSTNAME] = "host-gateway"
+ extra_hosts.update(_extra_host_mappings())
def _apply_resource_limits(create_kwargs: dict[str, Any]) -> None:
@@ -142,6 +241,11 @@ class StrixDockerSandboxSession(DockerSandboxSession):
cause=e,
) from e
+ # Host mode has no container IP. The entrypoint binds Caido to the
+ # daemon loopback on the requested port.
+ if self.sandbox_network == _HOST_NETWORK_NAME:
+ return ExposedPortEndpoint(host=_DAEMON_LOOPBACK, port=port, tls=False)
+
attrs = getattr(self._container, "attrs", {}) or {}
networks = attrs.get("NetworkSettings", {}).get("Networks", {})
endpoint = networks.get(self.sandbox_network) or {}
@@ -229,9 +333,7 @@ class StrixDockerSandboxClient(DockerSandboxClient):
if cap not in cap_add:
cap_add.append(cap)
- extra_hosts = create_kwargs.setdefault("extra_hosts", {})
- extra_hosts["host.docker.internal"] = "host-gateway"
-
+ _apply_extra_hosts(create_kwargs)
_apply_sandbox_network(create_kwargs)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
diff --git a/tests/test_docker_client_network.py b/tests/test_docker_client_network.py
new file mode 100644
index 000000000..c3ccf32c5
--- /dev/null
+++ b/tests/test_docker_client_network.py
@@ -0,0 +1,267 @@
+"""Sandbox network override and explicit hostname mappings.
+
+Host networking cannot use the SDK's published-port resolver: Docker reports
+no container IP, and published ports are omitted. ``STRIX_DOCKER_EXTRA_HOSTS``
+adds target names the sandbox does not inherit from the host hosts file.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from agents.sandbox.errors import ExposedPortUnavailableError
+from agents.sandbox.manifest import Environment, Manifest
+from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions, DockerSandboxSession
+from agents.sandbox.types import ExposedPortEndpoint
+from docker import errors as docker_errors
+
+from strix.runtime.docker_client import StrixDockerSandboxClient, StrixDockerSandboxSession
+
+
+_IMAGE = "strix-sandbox:test"
+_CAIDO_PORT = 48080
+_GATEWAY = {"host.docker.internal": "host-gateway"}
+
+
+def _clear_network_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("STRIX_DOCKER_SANDBOX_NETWORK", raising=False)
+ monkeypatch.delenv("STRIX_DOCKER_EXTRA_HOSTS", raising=False)
+
+
+def _client() -> tuple[StrixDockerSandboxClient, MagicMock, MagicMock]:
+ docker = MagicMock()
+ container = MagicMock()
+ container.id = "container-1"
+ container.short_id = "container-1"
+ docker.containers.create.return_value = container
+ return StrixDockerSandboxClient(docker), docker, container
+
+
+def _created_kwargs(docker: MagicMock) -> dict[str, Any]:
+ kwargs: dict[str, Any] = docker.containers.create.call_args.kwargs
+ return kwargs
+
+
+async def _create_session(
+ monkeypatch: pytest.MonkeyPatch,
+ network: str | None,
+ *,
+ manifest: Manifest | None = None,
+) -> tuple[Any, MagicMock, MagicMock]:
+ _clear_network_env(monkeypatch)
+ if network is not None:
+ monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network)
+ client, docker, container = _client()
+ session = await client.create(
+ options=DockerSandboxClientOptions(_IMAGE, (_CAIDO_PORT,)),
+ manifest=manifest,
+ )
+ return session, docker, container
+
+
+@pytest.mark.asyncio
+async def test_default_network_publishes_localhost_and_keeps_entrypoint(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _clear_network_env(monkeypatch)
+ client, docker, _container = _client()
+
+ await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
+
+ kwargs = _created_kwargs(docker)
+ docker.images.get.assert_called_with(_IMAGE)
+ docker.images.pull.assert_not_called()
+ assert kwargs["ports"] == {f"{_CAIDO_PORT}/tcp": ("127.0.0.1", None)}
+ assert kwargs["command"] == ["tail", "-f", "/dev/null"]
+ assert "entrypoint" not in kwargs
+ assert kwargs["cap_add"] == ["NET_ADMIN", "NET_RAW"]
+ assert kwargs["extra_hosts"] == _GATEWAY
+ assert "network" not in kwargs
+ assert kwargs["environment"] is None
+
+
+@pytest.mark.asyncio
+async def test_host_network_resolves_loopback_without_container_ip(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ manifest = Manifest(environment=Environment(value={"SCAN_TARGET": "app.htb"}))
+ session, docker, container = await _create_session(monkeypatch, "host", manifest=manifest)
+
+ kwargs = _created_kwargs(docker)
+ assert "ports" not in kwargs
+ assert kwargs["network"] == "host"
+ assert kwargs["environment"] == {
+ "SCAN_TARGET": "app.htb",
+ "STRIX_CAIDO_LISTEN_ADDRESS": "127.0.0.1",
+ }
+ assert kwargs["extra_hosts"] == _GATEWAY
+ assert type(session._inner) is StrixDockerSandboxSession
+
+ container.attrs = {
+ "NetworkSettings": {
+ "Networks": {"host": {"IPAddress": "", "GlobalIPv6Address": ""}},
+ }
+ }
+ endpoint = await session.resolve_exposed_port(_CAIDO_PORT)
+ assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=_CAIDO_PORT, tls=False)
+ container.reload.assert_called()
+
+ container.reload.side_effect = docker_errors.APIError("reload failed")
+ with pytest.raises(ExposedPortUnavailableError) as exc_info:
+ await session.resolve_exposed_port(_CAIDO_PORT)
+ assert exc_info.value.context["detail"] == "container_reload_failed"
+
+
+@pytest.mark.asyncio
+async def test_named_network_resolves_ipv4_and_ipv6(monkeypatch: pytest.MonkeyPatch) -> None:
+ session, docker, container = await _create_session(monkeypatch, "lab")
+
+ kwargs = _created_kwargs(docker)
+ assert kwargs["network"] == "lab"
+ assert "ports" not in kwargs
+ assert kwargs["environment"] == {}
+ assert type(session._inner) is StrixDockerSandboxSession
+ assert session._inner.sandbox_network == "lab"
+
+ container.attrs = {
+ "NetworkSettings": {"Networks": {"lab": {"IPAddress": "172.18.0.8"}}},
+ }
+ assert await session.resolve_exposed_port(_CAIDO_PORT) == ExposedPortEndpoint(
+ host="172.18.0.8", port=_CAIDO_PORT, tls=False
+ )
+
+ container.attrs = {
+ "NetworkSettings": {
+ "Networks": {"lab": {"IPAddress": "", "GlobalIPv6Address": "fd00::8"}},
+ }
+ }
+ assert await session.resolve_exposed_port(_CAIDO_PORT) == ExposedPortEndpoint(
+ host="[fd00::8]", port=_CAIDO_PORT, tls=False
+ )
+
+
+@pytest.mark.asyncio
+async def test_named_network_missing_address_and_reload_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ session, _docker, container = await _create_session(monkeypatch, "lab")
+ container.attrs = {"NetworkSettings": {"Networks": {"lab": {"IPAddress": ""}}}}
+
+ with pytest.raises(ExposedPortUnavailableError) as missing:
+ await session.resolve_exposed_port(_CAIDO_PORT)
+ assert missing.value.context["detail"] == "container_not_on_network"
+
+ container.reload.side_effect = docker_errors.APIError("reload failed")
+ with pytest.raises(ExposedPortUnavailableError) as failed:
+ await session.resolve_exposed_port(_CAIDO_PORT)
+ assert failed.value.context["detail"] == "container_reload_failed"
+
+
+@pytest.mark.parametrize("network", [None, "", " "])
+@pytest.mark.asyncio
+async def test_unset_network_keeps_sdk_published_port_resolver(
+ monkeypatch: pytest.MonkeyPatch,
+ network: str | None,
+) -> None:
+ session, docker, container = await _create_session(monkeypatch, network)
+
+ kwargs = _created_kwargs(docker)
+ assert "network" not in kwargs
+ assert kwargs["ports"] == {f"{_CAIDO_PORT}/tcp": ("127.0.0.1", None)}
+ assert type(session._inner) is DockerSandboxSession
+ container.attrs = {
+ "NetworkSettings": {
+ "Ports": {f"{_CAIDO_PORT}/tcp": [{"HostIp": "127.0.0.1", "HostPort": "35123"}]},
+ "Networks": {},
+ }
+ }
+ endpoint = await session.resolve_exposed_port(_CAIDO_PORT)
+ assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=35123, tls=False)
+
+
+@pytest.mark.parametrize("network", [None, "host"])
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ('{"app.htb":"10.10.11.42"}', {"app.htb": "10.10.11.42"}),
+ (
+ '{"app.htb":"10.10.11.42","db.htb":"10.10.11.7","v6.htb":"fd00::10"}',
+ {"app.htb": "10.10.11.42", "db.htb": "10.10.11.7", "v6.htb": "fd00::10"},
+ ),
+ ],
+)
+@pytest.mark.asyncio
+async def test_extra_hosts_merge_with_gateway(
+ monkeypatch: pytest.MonkeyPatch,
+ network: str | None,
+ raw: str,
+ expected: dict[str, str],
+) -> None:
+ _clear_network_env(monkeypatch)
+ if network is not None:
+ monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network)
+ monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
+ client, docker, _container = _client()
+
+ await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
+
+ assert _created_kwargs(docker)["extra_hosts"] == {**_GATEWAY, **expected}
+
+
+@pytest.mark.parametrize("raw", [None, "", " "])
+@pytest.mark.asyncio
+async def test_blank_extra_hosts_keep_gateway_mapping(
+ monkeypatch: pytest.MonkeyPatch,
+ raw: str | None,
+) -> None:
+ _clear_network_env(monkeypatch)
+ if raw is not None:
+ monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
+ client, docker, _container = _client()
+
+ await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
+
+ assert _created_kwargs(docker)["extra_hosts"] == _GATEWAY
+
+
+@pytest.mark.parametrize(
+ ("raw", "exc_type", "match"),
+ [
+ ("{", ValueError, r"STRIX_DOCKER_EXTRA_HOSTS must be a JSON object"),
+ ('["app.htb"]', TypeError, r"got array"),
+ (
+ '{"app.htb": 1}',
+ TypeError,
+ r"value for 'app\.htb' must be an IPv4 or IPv6 address string",
+ ),
+ (
+ '{"app.htb": "not-an-ip"}',
+ ValueError,
+ r"value for 'app\.htb' is not a literal IPv4 or IPv6 address",
+ ),
+ ('{"": "10.0.0.1"}', ValueError, r"empty hostname"),
+ ('{" ": "10.0.0.1"}', ValueError, r"empty hostname"),
+ (
+ '{"host.docker.internal": "10.0.0.1"}',
+ ValueError,
+ r"cannot replace the reserved host\.docker\.internal mapping",
+ ),
+ ],
+)
+@pytest.mark.asyncio
+async def test_extra_hosts_rejected_before_container_create(
+ monkeypatch: pytest.MonkeyPatch,
+ raw: str,
+ exc_type: type[Exception],
+ match: str,
+) -> None:
+ _clear_network_env(monkeypatch)
+ monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
+ client, docker, _container = _client()
+
+ with pytest.raises(exc_type, match=match):
+ await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
+
+ docker.containers.create.assert_not_called()
From 412c6f3a621655040c3d06944f82b7ce5ddf3ea7 Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Tue, 20 Oct 2026 18:46:50 -0700
Subject: [PATCH 2/2] fix(docker): reject host mode on images that bind Caido
to 0.0.0.0
A cached sandbox image built before the entrypoint honored
STRIX_CAIDO_LISTEN_ADDRESS still starts Caido on 0.0.0.0:48080 with
--allow-guests, so enabling STRIX_DOCKER_SANDBOX_NETWORK=host on it
would expose guest access on every host interface. The Dockerfile now
labels the image strix.sandbox.caido-loopback=true, and host mode
checks that label on the image before creating the container. An image
without it raises a ValueError instead of starting. The configuration
docs note the requirement.
The reserved host.docker.internal check in STRIX_DOCKER_EXTRA_HOSTS now
compares case-insensitively, since hostname lookup is case-insensitive
and a mixed-case key could otherwise add a second entry for the
reserved name. Tests cover the missing-label rejection and a mixed-case
reserved hostname.
Refs #1402
---
containers/Dockerfile | 1 +
docs/advanced/configuration.mdx | 2 ++
strix/runtime/docker_client.py | 26 +++++++++++++++++++++---
tests/test_docker_client_network.py | 31 ++++++++++++++++++++++++++---
4 files changed, 54 insertions(+), 6 deletions(-)
diff --git a/containers/Dockerfile b/containers/Dockerfile
index cabb6b39e..3291db1e1 100644
--- a/containers/Dockerfile
+++ b/containers/Dockerfile
@@ -269,4 +269,5 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER pentester
WORKDIR /workspace
+LABEL strix.sandbox.caido-loopback="true"
ENTRYPOINT ["docker-entrypoint.sh"]
diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx
index 673502ba8..2ac54967c 100644
--- a/docs/advanced/configuration.mdx
+++ b/docs/advanced/configuration.mdx
@@ -194,6 +194,8 @@ export STRIX_DOCKER_EXTRA_HOSTS='{"app.htb":"10.10.11.42"}'
Host mode binds Caido to the fixed port `48080`, so only one host-mode scan can run on a daemon at a time. The default bridge network publishes that container port to a free localhost port for each scan.
+Host mode requires image label `strix.sandbox.caido-loopback=true`. That label marks an entrypoint that honors `STRIX_CAIDO_LISTEN_ADDRESS` and binds Caido to loopback. A cached older image is rejected instead of listening on every host interface.
+
## Sandbox Configuration
diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py
index a10221400..acb3a428b 100644
--- a/strix/runtime/docker_client.py
+++ b/strix/runtime/docker_client.py
@@ -67,6 +67,7 @@ _DAEMON_LOOPBACK = "127.0.0.1"
# Read by containers/docker-entrypoint.sh. Set only for host networking so the
# unauthenticated Caido listener stays on loopback instead of 0.0.0.0.
_CAIDO_LISTEN_ADDRESS_ENV = "STRIX_CAIDO_LISTEN_ADDRESS"
+_CAIDO_LOOPBACK_LABEL = "strix.sandbox.caido-loopback"
def _sandbox_network() -> str | None:
@@ -90,10 +91,29 @@ def _apply_host_network_listener(create_kwargs: dict[str, Any]) -> None:
create_kwargs["environment"] = environment
-def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None:
+def _require_host_network_image(docker_client: Any, image: str) -> None:
+ """Reject host mode when a cached image may still bind Caido on 0.0.0.0."""
+ try:
+ attrs = docker_client.images.get(image).attrs
+ except docker_errors.ImageNotFound:
+ attrs = None
+ config = attrs.get("Config") if isinstance(attrs, dict) else None
+ labels = config.get("Labels") if isinstance(config, dict) else None
+ if isinstance(labels, dict) and labels.get(_CAIDO_LOOPBACK_LABEL) == "true":
+ return
+ raise ValueError(
+ f"{_SANDBOX_NETWORK_ENV}=host requires label {_CAIDO_LOOPBACK_LABEL}=true "
+ f"so {_CAIDO_LISTEN_ADDRESS_ENV} is honored. Cached older images bind "
+ "Caido to 0.0.0.0 with guest access."
+ )
+
+
+def _apply_sandbox_network(create_kwargs: dict[str, Any], docker_client: Any, image: str) -> None:
network = _sandbox_network()
if not network:
return
+ if network == _HOST_NETWORK_NAME:
+ _require_host_network_image(docker_client, image)
create_kwargs["network"] = network
create_kwargs.pop("ports", None)
if network == _HOST_NETWORK_NAME:
@@ -137,7 +157,7 @@ def _extra_hostname(name: object) -> str:
hostname = name.strip()
if not hostname:
raise ValueError(f"{_EXTRA_HOSTS_ENV} contains an empty hostname")
- if hostname == _HOST_GATEWAY_HOSTNAME:
+ if hostname.lower() == _HOST_GATEWAY_HOSTNAME:
raise ValueError(
f"{_EXTRA_HOSTS_ENV} cannot replace the reserved {_HOST_GATEWAY_HOSTNAME} mapping"
)
@@ -334,7 +354,7 @@ class StrixDockerSandboxClient(DockerSandboxClient):
cap_add.append(cap)
_apply_extra_hosts(create_kwargs)
- _apply_sandbox_network(create_kwargs)
+ _apply_sandbox_network(create_kwargs, self.docker_client, image)
_apply_resource_limits(create_kwargs)
_apply_log_limits(create_kwargs)
_apply_run_labels(create_kwargs)
diff --git a/tests/test_docker_client_network.py b/tests/test_docker_client_network.py
index c3ccf32c5..796f85255 100644
--- a/tests/test_docker_client_network.py
+++ b/tests/test_docker_client_network.py
@@ -23,6 +23,7 @@ from strix.runtime.docker_client import StrixDockerSandboxClient, StrixDockerSan
_IMAGE = "strix-sandbox:test"
_CAIDO_PORT = 48080
_GATEWAY = {"host.docker.internal": "host-gateway"}
+_CAIDO_LOOPBACK_LABEL = "strix.sandbox.caido-loopback"
def _clear_network_env(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -30,12 +31,18 @@ def _clear_network_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("STRIX_DOCKER_EXTRA_HOSTS", raising=False)
-def _client() -> tuple[StrixDockerSandboxClient, MagicMock, MagicMock]:
+def _client(
+ *, caido_loopback: bool = False
+) -> tuple[StrixDockerSandboxClient, MagicMock, MagicMock]:
docker = MagicMock()
container = MagicMock()
container.id = "container-1"
container.short_id = "container-1"
docker.containers.create.return_value = container
+ if caido_loopback:
+ image = MagicMock()
+ image.attrs = {"Config": {"Labels": {_CAIDO_LOOPBACK_LABEL: "true"}}}
+ docker.images.get.return_value = image
return StrixDockerSandboxClient(docker), docker, container
@@ -53,7 +60,7 @@ async def _create_session(
_clear_network_env(monkeypatch)
if network is not None:
monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network)
- client, docker, container = _client()
+ client, docker, container = _client(caido_loopback=network == "host")
session = await client.create(
options=DockerSandboxClientOptions(_IMAGE, (_CAIDO_PORT,)),
manifest=manifest,
@@ -114,6 +121,19 @@ async def test_host_network_resolves_loopback_without_container_ip(
assert exc_info.value.context["detail"] == "container_reload_failed"
+@pytest.mark.asyncio
+async def test_host_network_rejects_image_without_caido_loopback_label(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _clear_network_env(monkeypatch)
+ monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", "host")
+ client, docker, _container = _client()
+ docker.images.get.return_value.attrs = {"Config": {"Labels": None}}
+ with pytest.raises(ValueError, match="caido-loopback"):
+ await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
+ docker.containers.create.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_named_network_resolves_ipv4_and_ipv6(monkeypatch: pytest.MonkeyPatch) -> None:
session, docker, container = await _create_session(monkeypatch, "lab")
@@ -203,7 +223,7 @@ async def test_extra_hosts_merge_with_gateway(
if network is not None:
monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network)
monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw)
- client, docker, _container = _client()
+ client, docker, _container = _client(caido_loopback=network == "host")
await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,))
@@ -248,6 +268,11 @@ async def test_blank_extra_hosts_keep_gateway_mapping(
ValueError,
r"cannot replace the reserved host\.docker\.internal mapping",
),
+ (
+ '{"HOST.Docker.Internal": "10.0.0.1"}',
+ ValueError,
+ r"cannot replace the reserved host\.docker\.internal mapping",
+ ),
],
)
@pytest.mark.asyncio