fix(guard): scope the internal-mesh whitelist to loopback + docker bridge

The WAF/IP-ban/rate-limit exemption whitelisted all of RFC1918, so any
LAN client reaching the host-published nginx port resolved to its real
192.168.x.x and rode the exemption. Only the docker agent mesh needs it:
keep 127.0.0.1/::1 + 172.16.0.0/12, drop 10.0.0.0/8 and 192.168.0.0/16.
New middleware tests drive the real XFF resolution path (forwarded LAN
client blocked, direct bridge peer exempt); the remaining ceiling
(host-proxied traffic resolving to loopback/bridge-gateway under
depth-1 XFF trust) is documented instead of claimed away.
This commit is contained in:
Renn F
2026-07-22 03:30:32 +02:00
parent 34a4950918
commit 07bdef3db2
4 changed files with 70 additions and 15 deletions
-1
View File
@@ -1 +0,0 @@
/data/workspaces/roboco-api/ux_ui/ux-dev-1/motion/node_modules
+28 -13
View File
@@ -1,4 +1,4 @@
"""RoboCo HTTP security layer — fastapi-guard 7.2.2 / guard-core 3.3.0.
"""RoboCo HTTP security layer — fastapi-guard 7.3.0 / guard-core 3.5.0.
A ``SecurityMiddleware`` + per-route decorator layer, gated by
``settings.guard_enabled`` (default off). Importing this module is always safe:
@@ -319,22 +319,37 @@ def _redis_url() -> str:
return f"redis://{settings.redis_host}:{settings.redis_port}/0"
# RFC1918 + loopback: the internal agent mesh. Agents reach the orchestrator
# DIRECTLY on the docker bridge (172.x → roboco-orchestrator:8000, no nginx
# hop), HMAC-authenticated — the guard's WAF/threat-ban is for the EXTERNAL
# attack surface arriving through nginx, not for authenticated internal
# traffic. Without this the guard IP-banned agent containers the moment it
# went active (2026-07-20): one journal/note body tripping a signature banned
# the whole container's IP, wedging every subsequent verb (dm, i_am_idle, ...).
# Robust against XFF spoofing: trusted_proxy_depth=1 means the effective IP for
# an nginx-forwarded request is the real client (public, non-matching), so an
# external attacker cannot spoof themselves into this range.
# Loopback + docker's bridge pool: the internal agent mesh ONLY. Agents reach
# the orchestrator DIRECTLY on the docker bridge (172.x →
# roboco-orchestrator:8000, no nginx hop), HMAC-authenticated — the guard's
# WAF/IP-ban/rate-limit is for the EXTERNAL attack surface arriving through
# nginx, not for authenticated internal traffic. Without this the guard
# IP-banned agent containers the moment it went active (2026-07-20): one
# journal/note body tripping a signature banned the whole container's IP,
# wedging every subsequent verb (dm, i_am_idle, ...).
#
# Deliberately NOT 10.0.0.0/8 or 192.168.0.0/16: those also cover any real LAN
# client hitting nginx, not just the docker mesh. With trusted_proxy_depth=1,
# nginx forwards a LAN client's own real IP via XFF (extract_client_ip peels
# it correctly) — so a genuine 192.168.x.x browser would skip WAF/ban/rate-
# limit right alongside actual agent traffic. 172.16.0.0/12 is docker's
# default bridge address-pool range: neither compose file pins an explicit
# `subnet:` for roboco_default/roboco_data, so this has to cover whatever
# docker allocates them.
#
# Known ceiling: this can't tell a real docker-bridge peer apart from
# host-loopback/NAT'd traffic landing on the same address family. A request
# proxied through the host (e.g. Tailscale Serve terminating on
# 127.0.0.1:3000) still resolves, after nginx's one XFF hop, to loopback or
# the bridge gateway IP — both inside this range — so it still rides the
# exemption. A second XFF hop ahead of nginx (Tailscale Serve prepends the
# tailnet peer's real IP before nginx appends its own) is silently lost:
# trusted_proxy_depth=1 always peels the RIGHTMOST XFF entry, which is the
# hop nginx itself recorded, not the original tailnet client.
_INTERNAL_NETWORKS = [
"127.0.0.1",
"::1",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
]
+11 -1
View File
@@ -212,10 +212,20 @@ def test_internal_agent_mesh_is_whitelisted() -> None:
(2026-07-20 incident) and wedged every subsequent gateway verb."""
cfg = security.build_security_config()
assert cfg.whitelist is not None
for net in ("127.0.0.1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"):
for net in ("127.0.0.1", "::1", "172.16.0.0/12"):
assert net in cfg.whitelist
def test_internal_mesh_whitelist_excludes_full_rfc1918() -> None:
"""10.0.0.0/8 and 192.168.0.0/16 cover any real LAN client hitting nginx,
not just the docker mesh — an nginx-forwarded 192.168.x.x browser must NOT
ride the same exemption as authenticated agent traffic."""
cfg = security.build_security_config()
assert cfg.whitelist is not None
for net in ("10.0.0.0/8", "192.168.0.0/16"):
assert net not in cfg.whitelist
def test_guard_whitelist_appends_emergency_extras(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+31
View File
@@ -170,6 +170,37 @@ class TestActiveModeStillBlocksThreats:
assert resp.status_code != HTTPStatus.OK
_DOCKER_BRIDGE_PEER = "172.18.0.5"
_BENIGN_BODY = {"description": "add a login button"}
class TestNginxForwardedClientIP:
"""The internal-mesh whitelist is docker-bridge/loopback only (not full
RFC1918) — so extract_client_ip's real resolution, not just CIDR
membership, decides who rides the exemption. A trusted-proxy peer with no
XFF (the real agent-mesh shape) resolves to itself and stays exempt; the
same peer forwarding a LAN client's IP via XFF resolves to that real
client IP, which must NOT be exempt."""
def test_docker_bridge_peer_without_xff_is_whitelisted(self) -> None:
with _client(_guarded_app(passive=False, ip=_DOCKER_BRIDGE_PEER)) as client:
resp = client.post("/task", json=_BENIGN_BODY)
assert resp.status_code == HTTPStatus.OK
def test_nginx_forwarded_lan_client_is_not_whitelisted(self) -> None:
"""nginx (the docker-bridge peer) forwards a genuine 192.168.x.x LAN
client via X-Forwarded-For; trusted_proxy_depth=1 makes guard resolve
the real LAN IP (not the nginx peer), which the narrowed whitelist no
longer covers."""
with _client(_guarded_app(passive=False, ip=_DOCKER_BRIDGE_PEER)) as client:
resp = client.post(
"/task",
json=_BENIGN_BODY,
headers={"X-Forwarded-For": "192.168.1.50"},
)
assert resp.status_code != HTTPStatus.OK
class TestDecoyPaths:
"""Surface N: scanner/decoy URL paths are detected by the WAF url-path scan.