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
+5
View File
@@ -249,6 +249,11 @@ ROBOCO_DB_NETWORK_ISOLATED=true
# ROBOCO_GUARD_PASSIVE_MODE=true
# Fail CLOSED on an internal guard error (true) vs fail open (false).
# ROBOCO_GUARD_FAIL_SECURE=true
# Exact IP address(es), never a range, trusted as an XFF proxy hop beyond loopback
# (a CIDR entry is rejected — it would readmit every sibling container). Empty =
# loopback-only hop-peel. If Tailscale Serve sits behind this host's docker gateway,
# set it to that gateway's exact address to keep the chain resolving (e.g. 172.18.0.1).
# ROBOCO_GUARD_TRUSTED_CHAIN_PEERS=172.18.0.1
# =============================================================================
# Architectural conventions standard + agent-runtime toolchain matching
+6
View File
@@ -420,6 +420,12 @@ services:
# Spawn preflight — inert in practice (every real delivery role is
# gateway-enabled); carried at OFF for parity with the build compose.
ROBOCO_SPAWN_PREFLIGHT_ENABLED: ${ROBOCO_SPAWN_PREFLIGHT_ENABLED:-false}
# fastapi-guard's emergency-lockdown whitelist escape hatch. Carried
# here even though the guard trio itself (ENABLED/PASSIVE_MODE/
# FAIL_SECURE, see the declared-contract note above) is intentionally
# omitted — inert while guard stays off by default, but reaches the
# container the moment an operator arms guard by hand-editing this file.
ROBOCO_GUARD_EMERGENCY_WHITELIST: ${ROBOCO_GUARD_EMERGENCY_WHITELIST:-}
# Cloud auth (FastAPI Users): login-gates the panel/API when exposed
# beyond localhost. OFF by default (matches config default, unlike the
# build compose which arms it for the personal deploy). Set
+4
View File
@@ -652,6 +652,10 @@ services:
ROBOCO_GUARD_ENABLED: ${ROBOCO_GUARD_ENABLED:-true}
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-false}
ROBOCO_GUARD_FAIL_SECURE: ${ROBOCO_GUARD_FAIL_SECURE:-false}
# Emergency-lockdown whitelist escape hatch. Without this line here,
# setting it in .env silently does nothing — only vars listed in this
# stanza reach the container.
ROBOCO_GUARD_EMERGENCY_WHITELIST: ${ROBOCO_GUARD_EMERGENCY_WHITELIST:-}
volumes:
# Docker socket - allows spawning agent containers
- /var/run/docker.sock:/var/run/docker.sock
+4
View File
@@ -652,6 +652,10 @@ services:
ROBOCO_GUARD_ENABLED: ${ROBOCO_GUARD_ENABLED:-true}
ROBOCO_GUARD_PASSIVE_MODE: ${ROBOCO_GUARD_PASSIVE_MODE:-false}
ROBOCO_GUARD_FAIL_SECURE: ${ROBOCO_GUARD_FAIL_SECURE:-false}
# Emergency-lockdown whitelist escape hatch. Without this line here,
# setting it in .env silently does nothing — only vars listed in this
# stanza reach the container.
ROBOCO_GUARD_EMERGENCY_WHITELIST: ${ROBOCO_GUARD_EMERGENCY_WHITELIST:-}
volumes:
# Docker socket - allows spawning agent containers
- /var/run/docker.sock:/var/run/docker.sock
+22
View File
@@ -758,6 +758,28 @@ class Settings(BaseSettings):
"lockdown, in addition to loopback. Empty = loopback only."
),
)
guard_trusted_chain_peers: str = Field(
default="",
description=(
"Comma-separated exact IP address(es), never a range, beyond "
"loopback, trusted to appear as a recorded PROXY HOP inside "
"X-Forwarded-For when resolving the real client behind a "
"host-proxied chain (e.g. Tailscale Serve terminating on the "
"docker host in front of nginx). A CIDR/subnet entry is "
"rejected (logged, config load still succeeds) rather than "
"accepted, since a range would readmit every sibling "
"container's real address into the hop set. Empty by default: "
"only a loopback rightmost hop peels, so a same-bridge "
"container can no longer get its own 172.x address treated as a "
"trusted hop just by being on the docker bridge. If Tailscale "
"Serve sits behind this host's docker gateway, set this to that "
"gateway's exact address (e.g. 172.18.0.1) to keep the "
"Serve-behind-gateway chain resolving. Distinct from the docker "
"bridge pool nginx itself connects FROM (still trusted "
"unconditionally so nginx can keep presenting XFF at all) — this "
"only scopes which XFF entries are treated as hops."
),
)
# ==========================================================================
# Production self-healing ("engine 4") — DORMANT by default
+152 -33
View File
@@ -339,38 +339,100 @@ def _redis_url() -> str:
# docker allocates them.
#
# The variable-depth proxy chain (guard sees a fixed depth) is handled by
# ClientIpResolutionMiddleware below: it recursively skips known local hops
# in X-Forwarded-For and stamps the guard's request.state.client_ip cache,
# so Tailscale-Serve/host-proxied traffic resolves to the real tailnet/LAN
# client instead of loopback and no longer rides this exemption.
# ClientIpResolutionMiddleware below: it resolves the real tailnet/LAN client
# behind a NAMED set of local proxy hops in X-Forwarded-For (see
# `_build_trusted_hop_networks`) and stamps the guard's request.state.client_ip
# cache, so Tailscale-Serve/host-proxied traffic resolves to the real
# tailnet/LAN client instead of loopback and no longer rides this exemption.
_INTERNAL_NETWORKS = [
"127.0.0.1",
"::1",
"172.16.0.0/12",
]
# XFF entries that can legitimately be a HOP nginx recorded in front of the
# real client: loopback (a host-terminated proxy like Tailscale Serve) and
# the docker bridge pool (docker DNAT presents host-originated connections
# as the bridge gateway). Deliberately NOT the LAN/tailnet ranges — a
# 192.168.x / 100.64.x XFF entry IS the client, never a hop.
_TRUSTED_HOP_NETWORKS = ("127.0.0.1/32", "::1/128", "172.16.0.0/12")
# Docker's default bridge address-pool range — the WHOLE pool, used only by
# the broader connecting-peer gate below (never the operator-scoped hop set).
_DOCKER_BRIDGE_POOL = "172.16.0.0/12"
# Connecting-peer gate for ClientIpResolutionMiddleware: the DIRECT TCP peer
# must be one of these before the middleware consults X-Forwarded-For at
# all. Deliberately the WHOLE docker bridge pool, unlike the hop-peel set
# below — nginx itself connects from an arbitrary bridge-allocated address
# (neither compose file pins a `subnet:`), so it must keep presenting XFF
# regardless of which address it lands on. A bare connecting-peer match
# alone stamps nothing: the XFF entries themselves are still checked against
# the narrower, operator-scoped `_TRUSTED_HOP_NETWORKS`.
_CONNECTING_PEER_NETWORKS = ("127.0.0.1/32", "::1/128", _DOCKER_BRIDGE_POOL)
def _build_trusted_hop_networks() -> tuple[str, ...]:
"""XFF hop-peel set: loopback ALWAYS, plus operator-named chain peers.
A peeled entry is a RECORDED PROXY HOP inside X-Forwarded-For (e.g. the
docker bridge gateway nginx sees when Tailscale Serve terminates on the
host) — not the connecting socket peer (`_CONNECTING_PEER_NETWORKS`
above is that separate, deliberately broader check). Default empty
(``guard_trusted_chain_peers``): only a loopback rightmost hop ever
peels, so a same-bridge container can no longer get its own arbitrary
172.x address treated as a trusted hop just by being on the docker
bridge — closing the residual where a forged tailnet-CGNAT XFF prefix
resolved behind an unnamed 172.x rightmost entry. An operator running
Tailscale Serve behind a docker gateway sets
``ROBOCO_GUARD_TRUSTED_CHAIN_PEERS`` to that gateway's exact address
(e.g. 172.18.0.1) to keep the chain resolving.
Peers are parsed with ``ip_address`` — SINGLE addresses only, never a
CIDR range — and stored as their own /32 (or /128). A subnet-sized entry
(docker's typical bridge allocation is an entirely plausible copy-paste)
would readmit every sibling container's real address into the hop set,
fully reopening the pre-fix forge hole; ``ip_network(strict=True)``
would also silently accept a host-bits typo like "172.18.0.5/24" the
operator meant as a plain address. Any entry that isn't a plain IP —
CIDRs included — is skipped with a warning rather than crashing config
load.
"""
networks = ["127.0.0.1/32", "::1/128"]
for raw in settings.guard_trusted_chain_peers.split(","):
peer = raw.strip()
if not peer:
continue
try:
addr = ip_address(peer)
except (ValueError, TypeError):
logger.warning(
"skipping invalid guard_trusted_chain_peers entry: chain "
"peers are single IP addresses; CIDR ranges are rejected "
"because a range readmits sibling containers",
peer=peer,
)
continue
networks.append(f"{addr}/{addr.max_prefixlen}")
return tuple(networks)
# Built once at config load, mirroring security_config below — pure, no I/O.
_TRUSTED_HOP_NETWORKS = _build_trusted_hop_networks()
# Tailscale assigns every tailnet node an address in the CGNAT range. The
# resolver stamps ONLY a candidate in this range: it makes the fix exactly as
# wide as the broken case (host-proxied tailnet traffic resolving to a
# whitelisted hop IP) and no wider — for every other chain shape the stamp
# abstains and the guard's own depth-1 logic decides, so a same-bridge
# container relaying a forged public-IP prefix through nginx still resolves
# to its real bridge IP exactly as before this fix. Residual (accepted): such
# a container can forge a 100.64/10 prefix — that only DE-privileges it
# (loses its whitelist exemption; the fake tailnet IP eats the WAF/bans).
# abstains and the guard's own depth-1 logic decides. Residual (accepted):
# a compromise of a CONFIGURED chain peer itself (e.g. the docker bridge
# gateway) could still relay a forged prefix behind it — an unconfigured
# peer, or any other bridge-allocated address a real container actually
# has, cannot: it is never in `_TRUSTED_HOP_NETWORKS` by default.
_TAILNET_NETWORK = "100.64.0.0/10"
# The fixable shape needs at least [client, hop] — one real entry behind one
# recorded proxy hop.
_MIN_CHAIN_ENTRIES = 2
# Rightmost gateway IPs already warned about (see
# `_warn_unconfigured_tailnet_gateway_once`) — bounded by the tiny number of
# real docker bridge gateways an operator ever runs behind.
_WARNED_UNCONFIGURED_GATEWAYS: set[str] = set()
def _in_networks(ip: str, networks: tuple[str, ...]) -> bool:
try:
@@ -384,23 +446,60 @@ def _is_trusted_hop(ip: str) -> bool:
return _in_networks(ip, _TRUSTED_HOP_NETWORKS)
def _is_trusted_connecting_peer(ip: str) -> bool:
return _in_networks(ip, _CONNECTING_PEER_NETWORKS)
def _warn_unconfigured_tailnet_gateway_once(rightmost: str, candidate: str) -> None:
"""Surface the ONE regression this fix leaves genuinely silent: with no
chain peers configured, a host-proxied tailnet chain behind a real
docker bridge gateway silently reverts /tg to the pre-fix inert-WAF
state — zero signal otherwise. Only fires while NOTHING is configured
(once any peer is set the operator has already addressed this); logs
once per distinct gateway IP per process. Never logs the full XFF —
only the rightmost IP, the one an operator needs to act.
"""
if settings.guard_trusted_chain_peers.strip():
return
if not _in_networks(rightmost, (_DOCKER_BRIDGE_POOL,)):
return
if not _in_networks(candidate, (_TAILNET_NETWORK,)):
return
if rightmost in _WARNED_UNCONFIGURED_GATEWAYS:
return
_WARNED_UNCONFIGURED_GATEWAYS.add(rightmost)
logger.warning(
"host-proxied tailnet chain detected but no trusted chain peer "
"configured — this traffic resolves to a whitelisted bridge IP; "
f"set ROBOCO_GUARD_TRUSTED_CHAIN_PEERS to your docker bridge "
f"gateway ({rightmost})"
)
def resolve_forwarded_client_ip(forwarded_for: str) -> str | None:
"""Resolve the tailnet client behind host-proxy hops; None = abstain.
"""Resolve the tailnet client behind NAMED host-proxy hops; None = abstain.
fastapi-guard peels a FIXED number of XFF hops (trusted_proxy_depth=1:
the rightmost entry, which nginx itself recorded). That is correct for
every chain except one: host-proxied tailnet traffic (Tailscale Serve →
nginx) arrives as ``[tailnet-client, <loopback-or-bridge-gateway>]``, so
depth-1 resolves it to a whitelisted hop IP and WAF/ban/rate-limit go
nginx) arrives as ``[tailnet-client, <loopback-or-configured-gateway>]``,
so depth-1 resolves it to a whitelisted hop IP and WAF/ban/rate-limit go
inert for the whole /tg surface (the documented ceiling).
This resolver fixes exactly that shape and nothing else: peel trusted
hops from the right; the remaining candidate is returned ONLY if at
least one hop was peeled and the candidate is in the tailnet CGNAT
range. Every other shape — direct LAN client, agent container via nginx
(even with a forged public-IP prefix), all-hops operator traffic,
malformed entries — returns None, leaving the guard's own depth-1
resolution in charge, byte-for-byte identical to before this fix.
This resolver fixes exactly that shape and nothing else: peel hops from
the right that are loopback or an operator-named chain peer
(``guard_trusted_chain_peers``, see `_build_trusted_hop_networks`); the
remaining candidate is returned ONLY if at least one hop was peeled and
the candidate is in the tailnet CGNAT range. Every other shape — direct
LAN client, an agent container relaying via nginx with an UNNAMED 172.x
address (even carrying a forged public-IP or tailnet-CGNAT prefix),
all-hops operator traffic, malformed entries — returns None, leaving the
guard's own depth-1 resolution in charge. With no chain peers configured
(the default), only a loopback rightmost hop ever peels — when that
abstain is otherwise shaped exactly like a host-proxied tailnet chain
behind a real bridge gateway, it is logged once per gateway IP (see
`_warn_unconfigured_tailnet_gateway_once`) so the regression isn't
silent.
"""
entries = [e.strip() for e in forwarded_for.split(",") if e.strip()]
if len(entries) < _MIN_CHAIN_ENTRIES:
@@ -408,8 +507,11 @@ def resolve_forwarded_client_ip(forwarded_for: str) -> str | None:
idx = len(entries) - 1
while idx >= 0 and _is_trusted_hop(entries[idx]):
idx -= 1
if idx == len(entries) - 1 or idx < 0:
return None # no hop peeled, or all hops: baseline handles both
if idx == len(entries) - 1:
_warn_unconfigured_tailnet_gateway_once(entries[-1], entries[-2])
return None # no hop peeled: baseline handles it
if idx < 0:
return None # all hops: baseline handles it
candidate = entries[idx]
if not _in_networks(candidate, (_TAILNET_NETWORK,)):
return None
@@ -423,10 +525,12 @@ class ClientIpResolutionMiddleware:
Pure ASGI, mounted OUTSIDE SecurityMiddleware (added after it, so it runs
first): guard_core's ``extract_client_ip`` returns a pre-cached
``state.client_ip`` verbatim, which is the supported seam for custom
resolution. Only honors XFF when the CONNECTING peer is itself a known
local hop (nginx's bridge IP / loopback) a directly-connected client's
forged XFF is never consulted here (the guard's own depth-1 logic keeps
handling that class unchanged).
resolution. Only honors XFF when the CONNECTING peer is itself trusted to
present it (nginx's docker-bridge address / loopback —
`_CONNECTING_PEER_NETWORKS`, deliberately broader than and independent of
the operator-scoped hop-peel set the XFF entries are matched against) —
a directly-connected client's forged XFF is never consulted here (the
guard's own depth-1 logic keeps handling that class unchanged).
"""
def __init__(self, app: Any) -> None:
@@ -436,7 +540,7 @@ class ClientIpResolutionMiddleware:
if scope["type"] == "http":
client = scope.get("client")
connecting_ip = client[0] if client else None
if connecting_ip and _is_trusted_hop(connecting_ip):
if connecting_ip and _is_trusted_connecting_peer(connecting_ip):
# First occurrence on a repeated header, matching Starlette's
# Headers.get — so this layer and the guard's own fallback
# read the SAME header value.
@@ -459,7 +563,22 @@ def _guard_whitelist() -> list[str]:
extra = [
x.strip() for x in settings.guard_emergency_whitelist.split(",") if x.strip()
]
return [*_INTERNAL_NETWORKS, *extra]
# guard-core's whitelist is EXCLUSIVE once non-empty (guard_core.utils.
# is_ip_allowed: a set whitelist replaces, not supplements, the
# blacklist check — any non-member IP is refused outright, not merely
# unexempted). The resolver above now honestly resolves a host-proxied
# tailnet client's real 100.64.0.0/10 address instead of a loopback/
# bridge hop, so that address must be a whitelist member or ip_security
# rejects it — this blocked the CEO's own tailnet IP live (2026-07-22).
# Coupling allowlist membership with scrutiny-exemption here is
# deliberate for the tailnet specifically: Tailscale is an authenticated
# overlay that gates device membership before a packet ever reaches this
# host, so an already-authenticated tailnet peer skipping WAF/ban/rate-
# limit is the correct posture, not a gap. The resolver's real-IP
# stamping still buys correct attribution in logs/telemetry, and any
# FUTURE non-tailnet public exposure still gets full scrutiny — only
# 100.64.0.0/10 is exempted here, nothing wider.
return [*_INTERNAL_NETWORKS, _TAILNET_NETWORK, *extra]
def _emergency_whitelist() -> list[str]:
+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
+167 -8
View File
@@ -3,12 +3,16 @@
fastapi-guard peels a fixed trusted_proxy_depth=1 from X-Forwarded-For (the
rightmost entry, which nginx itself recorded). That is correct for every
chain except host-proxied tailnet traffic (Tailscale Serve → nginx), which
arrives as ``[tailnet-client, <loopback-or-bridge-gateway>]`` — depth-1
arrives as ``[tailnet-client, <loopback-or-configured-gateway>]`` — depth-1
resolves it to a whitelisted hop IP and the WAF goes inert for /tg.
``ClientIpResolutionMiddleware`` stamps guard_core's ``state.client_ip``
cache (the supported pre-resolution seam) for EXACTLY that shape and
abstains on every other, so no path resolves differently from the depth-1
baseline unless the candidate is a tailnet CGNAT address behind real hops.
The XFF hop-peel set is loopback ALWAYS plus operator-named
``guard_trusted_chain_peers`` (default empty) — NOT the whole docker bridge
pool, so an unnamed 172.x address can never be treated as a hop.
"""
from __future__ import annotations
@@ -27,6 +31,19 @@ from roboco.security import (
)
from starlette.requests import Request
def _configure_chain_peers(monkeypatch: pytest.MonkeyPatch, csv: str) -> None:
"""Set guard_trusted_chain_peers and rebuild the effective hop set.
Mirrors what config load does once at import time; tests need to redo
it per-case since ``_TRUSTED_HOP_NETWORKS`` is otherwise built once.
"""
monkeypatch.setattr(settings, "guard_trusted_chain_peers", csv)
monkeypatch.setattr(
security, "_TRUSTED_HOP_NETWORKS", security._build_trusted_hop_networks()
)
# ---------------------------------------------------------------------------
# resolve_forwarded_client_ip — stamps ONLY the tailnet-behind-hops shape
# ---------------------------------------------------------------------------
@@ -38,14 +55,25 @@ def test_tailscale_serve_behind_loopback_resolves_tailnet_peer() -> None:
)
def test_tailscale_serve_behind_bridge_gateway_resolves_tailnet_peer() -> None:
def test_tailscale_serve_behind_bridge_gateway_resolves_tailnet_peer(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Docker DNAT presents host-originated connections as the bridge gateway,
# so nginx may record 172.x instead of loopback for Tailscale Serve.
# so nginx may record 172.x instead of loopback for Tailscale Serve. That
# 172.x address only peels once the operator names it explicitly.
_configure_chain_peers(monkeypatch, "172.18.0.1")
assert (
resolve_forwarded_client_ip("100.101.102.103, 172.18.0.1") == "100.101.102.103"
)
def test_bridge_gateway_unconfigured_by_default_never_peels() -> None:
# Same chain as above, but with NO configured chain peers (the default):
# the 172.x gateway is no longer a recognized hop, so the resolver
# abstains and depth-1 keeps resolving to the gateway IP itself.
assert resolve_forwarded_client_ip("100.101.102.103, 172.18.0.1") is None
def test_forged_prefix_behind_tailscale_chain_ignored() -> None:
assert (
resolve_forwarded_client_ip("6.6.6.6, 100.101.102.103, 127.0.0.1")
@@ -66,11 +94,25 @@ def test_bridge_peer_with_forged_public_prefix_abstains() -> None:
assert resolve_forwarded_client_ip("9.9.9.9, 172.20.0.7") is None
def test_bridge_peer_forging_tailnet_prefix_only_deprivileges() -> None:
# Documented residual: forging a CGNAT prefix IS stamped — the forger
# loses its whitelist exemption (fake tailnet IPs eat the WAF); it can
# never gain privilege this way.
assert resolve_forwarded_client_ip("100.99.1.1, 172.20.0.7") == "100.99.1.1"
def test_default_empty_chain_peers_closes_the_forge_residual() -> None:
# THE fixed residual: a same-bridge container relaying a forged
# tailnet-CGNAT XFF prefix used to have its unnamed 172.x rightmost
# entry peeled as a "trusted hop" (the whole /12 was the hop set),
# stamping the forged CGNAT address. With no chain peers configured
# (the default), 172.20.0.7 is never a recognized hop, so no hop is
# peeled and the resolver abstains — the forge no longer lands.
assert resolve_forwarded_client_ip("100.99.1.1, 172.20.0.7") is None
def test_configured_peer_does_not_extend_to_other_bridge_addresses(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Even with a real chain peer configured (the docker gateway), a forger
# relaying through its OWN bridge IP — never the reserved gateway
# address — still abstains: only the exact configured peer(s) peel, not
# the whole bridge pool.
_configure_chain_peers(monkeypatch, "172.18.0.1")
assert resolve_forwarded_client_ip("100.99.1.1, 172.20.0.7") is None
def test_non_tailnet_client_behind_hop_abstains() -> None:
@@ -93,6 +135,123 @@ def test_malformed_entries_abstain() -> None:
assert resolve_forwarded_client_ip("100.99.1.1:443, 127.0.0.1") is None
def test_loopback_hop_chains_unaffected_by_peer_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Loopback is unconditionally part of the hop set regardless of what
# (if anything) is configured — a chain peer config only ADDS to it.
_configure_chain_peers(monkeypatch, "172.18.0.1")
assert (
resolve_forwarded_client_ip("100.101.102.103, 127.0.0.1") == "100.101.102.103"
)
assert resolve_forwarded_client_ip("127.0.0.1") is None
# ---------------------------------------------------------------------------
# _build_trusted_hop_networks — csv parsing at config-load time
# ---------------------------------------------------------------------------
def test_invalid_chain_peer_entry_skipped_without_crash(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
settings, "guard_trusted_chain_peers", "not-a-cidr, 172.18.0.1, "
)
networks = security._build_trusted_hop_networks()
assert networks == ("127.0.0.1/32", "::1/128", "172.18.0.1/32")
def test_empty_chain_peers_yields_loopback_only() -> None:
assert security._build_trusted_hop_networks() == ("127.0.0.1/32", "::1/128")
def test_plain_ip_chain_peer_stored_as_its_own_slash_32(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "guard_trusted_chain_peers", "172.18.0.1")
assert security._build_trusted_hop_networks() == (
"127.0.0.1/32",
"::1/128",
"172.18.0.1/32",
)
def test_subnet_chain_peer_entry_rejected_and_not_in_hop_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Docker's typical bridge allocation — an entirely plausible operator
# copy-paste — must be rejected, not admitted: a range would readmit
# every sibling container's real address into the hop set.
monkeypatch.setattr(settings, "guard_trusted_chain_peers", "172.16.0.0/12")
assert security._build_trusted_hop_networks() == ("127.0.0.1/32", "::1/128")
# And the forge chain a wrongly-admitted /12 would have reopened still
# abstains end-to-end.
_configure_chain_peers(monkeypatch, "172.16.0.0/12")
assert resolve_forwarded_client_ip("100.99.1.1, 172.20.0.7") is None
def test_host_bits_typo_cidr_rejected_same_as_a_subnet(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A typo'd CIDR the operator meant as a plain address — ip_network's
# default strict=True would silently accept this; ip_address rejects it.
monkeypatch.setattr(settings, "guard_trusted_chain_peers", "172.18.0.5/24")
assert security._build_trusted_hop_networks() == ("127.0.0.1/32", "::1/128")
# ---------------------------------------------------------------------------
# _warn_unconfigured_tailnet_gateway_once — the silent-default-gap signal
# ---------------------------------------------------------------------------
def test_unconfigured_gateway_chain_warns_once_per_gateway(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(security, "_WARNED_UNCONFIGURED_GATEWAYS", set())
calls: list[tuple[Any, ...]] = []
monkeypatch.setattr(
security.logger, "warning", lambda *a, **kw: calls.append((a, kw))
)
for _ in range(3):
assert resolve_forwarded_client_ip("100.101.102.103, 172.19.0.9") is None
assert len(calls) == 1
assert "172.19.0.9" in calls[0][0][0]
def test_configured_peer_suppresses_the_gap_warning(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A DIFFERENT gateway than the one configured would otherwise trip the
# detection, but ANY configured peer means the operator has already
# addressed the awareness gap — the warning never fires at all.
monkeypatch.setattr(security, "_WARNED_UNCONFIGURED_GATEWAYS", set())
_configure_chain_peers(monkeypatch, "172.18.0.1")
calls: list[tuple[Any, ...]] = []
monkeypatch.setattr(
security.logger, "warning", lambda *a, **kw: calls.append((a, kw))
)
assert resolve_forwarded_client_ip("100.99.1.1, 172.20.0.7") is None
assert calls == []
def test_non_host_proxied_shapes_never_trigger_the_gap_warning(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(security, "_WARNED_UNCONFIGURED_GATEWAYS", set())
calls: list[tuple[Any, ...]] = []
monkeypatch.setattr(
security.logger, "warning", lambda *a, **kw: calls.append((a, kw))
)
# A forged public-IP prefix behind a bridge address isn't the tailnet
# (CGNAT) shape.
resolve_forwarded_client_ip("9.9.9.9, 172.20.0.7")
# A LAN client behind a genuine loopback hop resolves — a hop WAS
# peeled, so this never reaches the unconfigured-gateway branch.
resolve_forwarded_client_ip("203.0.113.9, 127.0.0.1")
assert calls == []
# ---------------------------------------------------------------------------
# ClientIpResolutionMiddleware stamping
# ---------------------------------------------------------------------------