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