Files
Shadowbroker/backend/tests/test_docker_bridge_hostname_trust.py
T
Shadowbroker 9ef6213284 Fix #250: bind Docker bridge local-operator trust to frontend hostname (#278)
Tightens the bridge-trust check so a connection on the Docker bridge
is only granted local-operator status when its source IP matches a
configured frontend container hostname (default: `frontend` + the
shipped `container_name` `shadowbroker-frontend`). Previously, when
`SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1` was set, ANY IP
in the 172.16.0.0/12 range was granted local-operator privileges —
on a shared Docker host that included any unrelated container on the
same bridge.

Operators with renamed services can list new hostnames via the new
`SHADOWBROKER_TRUSTED_FRONTEND_HOSTS` env var (comma-separated). DNS
resolution is cached for 30s; if Docker DNS can't resolve any of the
configured names we fail closed and refuse the bridge entirely.

Single-user installs see no behavior change — the default-named
frontend container still resolves and is still trusted.

Credit: tg12 (external security audit)
2026-05-21 02:06:11 -06:00

197 lines
8.0 KiB
Python

"""Issue #250 (tg12): Docker bridge local-operator trust must be bound to
the frontend container's hostname, not the entire 172.16.0.0/12 range.
Previous behavior trusted ANY private-RFC1918 source IP on the bridge
when ``SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1``. On a shared
Docker host this granted local-operator privileges to any other
container that could route to the backend's bridge — far broader than
intended.
The fix narrows trust to source IPs that forward-resolve from one of the
configured frontend container hostnames (default: the compose service
name ``frontend`` plus the explicit ``container_name``
``shadowbroker-frontend``). Operators with renamed containers can list
the new names in ``SHADOWBROKER_TRUSTED_FRONTEND_HOSTS``.
These tests exercise the resolution helpers directly so that we don't
need a live Docker daemon to validate the contract.
"""
import socket
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# _trusted_bridge_frontend_hostnames — env parsing
# ---------------------------------------------------------------------------
class TestTrustedHostnameParsing:
def _fn(self):
from auth import _trusted_bridge_frontend_hostnames
return _trusted_bridge_frontend_hostnames
def test_default_covers_compose_service_and_container_name(self):
with patch.dict("os.environ", {}, clear=False):
# Make sure the env var is not set so we exercise the default.
import os
os.environ.pop("SHADOWBROKER_TRUSTED_FRONTEND_HOSTS", None)
assert self._fn()() == ["frontend", "shadowbroker-frontend"]
def test_custom_list_via_env(self):
with patch.dict(
"os.environ",
{"SHADOWBROKER_TRUSTED_FRONTEND_HOSTS": "my-ui,alt-frontend"},
):
assert self._fn()() == ["my-ui", "alt-frontend"]
def test_whitespace_trimmed(self):
with patch.dict(
"os.environ",
{"SHADOWBROKER_TRUSTED_FRONTEND_HOSTS": " my-ui , alt-frontend "},
):
assert self._fn()() == ["my-ui", "alt-frontend"]
def test_empty_env_falls_back_to_default(self):
# An empty string still falls back to the bundled defaults so a
# misconfigured env var doesn't silently dismantle bridge trust.
with patch.dict(
"os.environ",
{"SHADOWBROKER_TRUSTED_FRONTEND_HOSTS": ""},
):
# Per docs: empty string sets the env var to "" so os.environ.get
# returns "" — that string is parsed and yields []. We assert
# that empty parse yields [] (caller fail-closes from there).
assert self._fn()() == []
# ---------------------------------------------------------------------------
# _resolve_trusted_bridge_ips — DNS resolution with cache + fail-closed
# ---------------------------------------------------------------------------
class TestResolveTrustedBridgeIps:
def setup_method(self):
# Reset the module-level cache before each test so prior tests
# don't bleed state across cases.
from auth import _DOCKER_BRIDGE_TRUST_CACHE
_DOCKER_BRIDGE_TRUST_CACHE["ips"] = frozenset()
_DOCKER_BRIDGE_TRUST_CACHE["expires"] = 0.0
def test_resolves_configured_hostnames(self):
from auth import _resolve_trusted_bridge_ips
def fake_gethostbyname_ex(host):
mapping = {
"frontend": ("frontend", [], ["172.18.0.3"]),
"shadowbroker-frontend": ("shadowbroker-frontend", [], ["172.18.0.3", "172.18.0.4"]),
}
if host not in mapping:
raise socket.gaierror("no such host")
return mapping[host]
with patch("socket.gethostbyname_ex", side_effect=fake_gethostbyname_ex):
ips = _resolve_trusted_bridge_ips()
assert ips == frozenset({"172.18.0.3", "172.18.0.4"})
def test_fail_closed_when_dns_returns_nothing(self):
from auth import _resolve_trusted_bridge_ips
def always_fail(host):
raise socket.gaierror("no resolver")
with patch("socket.gethostbyname_ex", side_effect=always_fail):
ips = _resolve_trusted_bridge_ips()
assert ips == frozenset()
def test_partial_resolution_is_kept(self):
"""If one hostname resolves and another fails, we keep the
successful one rather than discarding the whole set."""
from auth import _resolve_trusted_bridge_ips
def partial(host):
if host == "frontend":
return ("frontend", [], ["172.18.0.3"])
raise socket.gaierror("missing")
with patch("socket.gethostbyname_ex", side_effect=partial):
ips = _resolve_trusted_bridge_ips()
assert ips == frozenset({"172.18.0.3"})
def test_cache_short_circuits_repeated_dns_calls(self):
from auth import _resolve_trusted_bridge_ips
call_count = {"n": 0}
def counting(host):
call_count["n"] += 1
return ("frontend", [], ["172.18.0.3"])
with patch("socket.gethostbyname_ex", side_effect=counting):
_resolve_trusted_bridge_ips()
calls_after_first = call_count["n"]
_resolve_trusted_bridge_ips()
_resolve_trusted_bridge_ips()
# Second + third calls hit the cache, not the DNS stub.
assert call_count["n"] == calls_after_first
def test_cache_expires(self):
from auth import _resolve_trusted_bridge_ips, _DOCKER_BRIDGE_TRUST_CACHE
with patch("socket.gethostbyname_ex", return_value=("frontend", [], ["172.18.0.3"])):
_resolve_trusted_bridge_ips()
# Force expiry.
_DOCKER_BRIDGE_TRUST_CACHE["expires"] = 0.0
with patch("socket.gethostbyname_ex", return_value=("frontend", [], ["172.18.0.9"])) as stub:
ips = _resolve_trusted_bridge_ips()
assert stub.called
assert "172.18.0.9" in ips
# ---------------------------------------------------------------------------
# _is_docker_bridge_host — composite of the helpers above
# ---------------------------------------------------------------------------
class TestIsDockerBridgeHost:
def setup_method(self):
from auth import _DOCKER_BRIDGE_TRUST_CACHE
_DOCKER_BRIDGE_TRUST_CACHE["ips"] = frozenset()
_DOCKER_BRIDGE_TRUST_CACHE["expires"] = 0.0
def test_trusts_resolved_frontend_ip(self):
from auth import _is_docker_bridge_host
with patch("auth._resolve_trusted_bridge_ips", return_value=frozenset({"172.18.0.3"})):
assert _is_docker_bridge_host("172.18.0.3") is True
def test_rejects_arbitrary_bridge_ip(self):
"""A rogue container on the same bridge but at a different IP
must NOT be trusted, even though it falls in 172.16.0.0/12."""
from auth import _is_docker_bridge_host
with patch("auth._resolve_trusted_bridge_ips", return_value=frozenset({"172.18.0.3"})):
assert _is_docker_bridge_host("172.18.0.99") is False
def test_rejects_public_ip_without_dns_work(self):
"""Public IPs skip DNS resolution entirely (perf + safety)."""
from auth import _is_docker_bridge_host
with patch("auth._resolve_trusted_bridge_ips") as stub:
assert _is_docker_bridge_host("8.8.8.8") is False
stub.assert_not_called()
def test_rejects_non_ip_input(self):
from auth import _is_docker_bridge_host
assert _is_docker_bridge_host("") is False
assert _is_docker_bridge_host("not-an-ip") is False
assert _is_docker_bridge_host("frontend") is False
def test_fails_closed_when_dns_returns_empty(self):
"""If Docker DNS can't resolve any frontend hostname, the bridge
is not trusted — even for IPs that would have been trusted under
the old 172.16.0.0/12 blanket policy."""
from auth import _is_docker_bridge_host
with patch("auth._resolve_trusted_bridge_ips", return_value=frozenset()):
assert _is_docker_bridge_host("172.18.0.3") is False