fix(guard): operator-scoped XFF hop peers + tailnet allowlist (live-incident fix) (#650)

Two coupled hardenings from the chain-peers adversarial rounds plus the
root-cause fix for the live post-deploy incident where the CEO was
blocked from the panel ('IP not allowed: 100.x.x.x').

Hop peel-set: the whole docker bridge pool leaves the XFF hop set — hops
are now loopback plus operator-named single addresses only
(ROBOCO_GUARD_TRUSTED_CHAIN_PEERS, plain IPs; CIDR entries rejected with
a warning because a range readmits sibling containers). Default-empty
closes the CGNAT-forge residual outright; a gateway-fronted Tailscale
Serve deploy sets its real gateway IP, and a rate-limited detection log
names exactly that IP when an unconfigured host-proxied tailnet chain is
seen, so the silent-regression shape is observable. The connecting-peer
gate (may nginx present XFF at all) deliberately keeps the broad bridge
pool — different check, unchanged.

Incident root cause: guard-core's whitelist is an EXCLUSIVE allowlist
(any non-member is refused), so honestly resolving the tailnet client IP
made ip_security reject the CEO. The tailnet CGNAT range joins
_guard_whitelist() deliberately: Tailscale authenticates device
membership before a packet arrives, real-IP stamping still buys correct
attribution, and any future non-tailnet exposure keeps full scrutiny.
Both compose files now pass ROBOCO_GUARD_EMERGENCY_WHITELIST through to
the orchestrator (the operator escape hatch previously did nothing in a
compose deploy).

NAS is running ROBOCO_GUARD_PASSIVE_MODE=true as interim mitigation —
flip back to false when this deploys. 66 tests.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-23 00:05:29 +02:00
committed by GitHub
co-authored by Renn F
parent da4d9b333d
commit 3806317aa7
8 changed files with 372 additions and 43 deletions
+12 -2
View File
@@ -209,10 +209,20 @@ def test_internal_agent_mesh_is_whitelisted() -> None:
"""Agents reach the orchestrator directly on the docker bridge, HMAC-
authenticated; the guard's threat-ban is for the external surface. Without
this the guard IP-banned agent containers the moment it went active
(2026-07-20 incident) and wedged every subsequent gateway verb."""
(2026-07-20 incident) and wedged every subsequent gateway verb.
The tailnet CGNAT range (100.64.0.0/10) rides the same whitelist:
guard-core's whitelist is EXCLUSIVE once non-empty (any non-member IP is
refused, not merely unexempted), and the resolver now honestly resolves
a host-proxied tailnet client to its real 100.64.0.0/10 address instead
of a loopback/bridge hop — omitting it here blocked the CEO's own
tailnet IP live (2026-07-22 incident). Tailscale is an authenticated
overlay gating device membership before a packet arrives, so coupling
allowlisting with scrutiny-exemption is the deliberate posture for this
one range."""
cfg = security.build_security_config()
assert cfg.whitelist is not None
for net in ("127.0.0.1", "::1", "172.16.0.0/12"):
for net in ("127.0.0.1", "::1", "172.16.0.0/12", "100.64.0.0/10"):
assert net in cfg.whitelist