mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(guard): exempt the internal agent mesh from WAF + IP-ban (#605)
With the guard active on the NAS, a documenter's journal-entry POST body tripped a WAF signature and the guard banned its docker-bridge IP (172.18.0.7) — after which EVERY gateway verb from that agent (dm, i_am_idle, claim_review) was blocked by ip_security, wedging the agent into a respawn loop. Confirmed live: roboco:guard:banned_ips:172.18.0.7 in redis with passive=False. The guard's threat-ban targets the external attack surface arriving via nginx; internal HMAC-authenticated agents reach the orchestrator DIRECTLY on the docker bridge and must not be subject to it. build_security_config now sets whitelist to the RFC1918 + loopback ranges. External traffic keeps its real client IP (XFF, trusted- proxy depth 1 — un-spoofable into a private range), so the WAF still fires on genuine attackers; the middleware tests model that with a public TEST-NET-3 IP. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -50,6 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
- **`/auth/login` no longer 422s (#580).** FastAPI had demoted the db dependency to a query parameter on the login route.
|
||||
- **Task cancellation closes the task's own PR, and the bulk branch sweep spares live dependents (#593).** Every cancel path now best-effort-closes the recorded open PR for the task and its cascaded descendants; the stale-branch sweep excludes branches still recorded by a non-terminal task or serving as a live child's resolved parent branch.
|
||||
- **Release/readiness hardening basket.** The root PR base resolves the project's env ladder instead of literal master; version detection accepts manifest variants and never crashes the sweep; unconfigured social platforms are skipped instead of becoming pending-forever failures (#545); the readiness sweep's CI wait polls the prod rung; and check-runs dedupe per name so a cancelled duplicate can't mask a green run.
|
||||
- **The active guard no longer IP-bans the internal agent mesh.** With the guard active, an agent's journal/note body tripping a WAF signature banned the whole container's docker-bridge IP, and every subsequent gateway verb from that agent (dm, i_am_idle, claim_review) was blocked by ip_security — wedging the agent into a respawn loop (2026-07-20). The guard's WAF/threat-ban is for the external attack surface arriving through nginx; the internal RFC1918/loopback mesh (authenticated agents reaching the orchestrator directly on the docker bridge) is now guard-whitelisted. External traffic still carries the real client IP via X-Forwarded-For (trusted-proxy depth 1, un-spoofable into a private range) and is fully scrutinized.
|
||||
- **The release-proposal bell notification survives its own transaction.** `send_ack_notification` inserted the notification row through a fresh session while the proposal task sat uncommitted in the origination engine's transaction, so the `related_task_id` FK rejected it and the panel-bell ping was silently lost (the Telegram DM, which is DB-free, still went out). The service now accepts the caller's session so the insert joins the same transaction, and the release engine passes it.
|
||||
- **Panel/ops hardening basket.** Session links target the owning task (the `/work-sessions/<id>` route never existed); PM review turns are restart-safe; dialog triggers behind tooltips fire again and dotted composition ids render; `git pull` on a target branch became a hard sync to origin; the `git_provider` migration renumbered to 076 restoring a single Alembic head; CI-watch fixed its own regression on roboco-api (#563); agent commits no longer carry model self-attribution; and cockpit CI assertions became delta-based so the one-process run's row leaks can't flake them (#577, #578).
|
||||
|
||||
|
||||
@@ -319,6 +319,32 @@ 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.
|
||||
_INTERNAL_NETWORKS = [
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
]
|
||||
|
||||
|
||||
def _guard_whitelist() -> list[str]:
|
||||
extra = [
|
||||
x.strip() for x in settings.guard_emergency_whitelist.split(",") if x.strip()
|
||||
]
|
||||
return [*_INTERNAL_NETWORKS, *extra]
|
||||
|
||||
|
||||
def _emergency_whitelist() -> list[str]:
|
||||
extra = [
|
||||
x.strip() for x in settings.guard_emergency_whitelist.split(",") if x.strip()
|
||||
@@ -374,6 +400,10 @@ def build_security_config() -> SecurityConfig:
|
||||
# Flip-on kill switch.
|
||||
emergency_mode=settings.guard_emergency,
|
||||
emergency_whitelist=_emergency_whitelist(),
|
||||
# The internal agent mesh skips all checks (WAF + IP-ban) — see
|
||||
# _INTERNAL_NETWORKS. External traffic via nginx carries the real
|
||||
# client IP (XFF, depth 1) and is still fully scrutinized.
|
||||
whitelist=_guard_whitelist(),
|
||||
exclude_paths=_EXCLUDE_PATHS,
|
||||
security_headers=_SECURITY_HEADERS,
|
||||
threat_ban_config=_THREAT_BAN_CONFIG,
|
||||
|
||||
@@ -200,3 +200,27 @@ def test_enforce_https_always_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
moment the guard went active (2026-07-19 outage)."""
|
||||
monkeypatch.setattr(settings, "environment", "production")
|
||||
assert security.build_security_config().enforce_https is False
|
||||
|
||||
|
||||
# --- the internal agent mesh is exempt from WAF + IP-ban ------------------
|
||||
|
||||
|
||||
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."""
|
||||
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"):
|
||||
assert net in cfg.whitelist
|
||||
|
||||
|
||||
def test_guard_whitelist_appends_emergency_extras(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "guard_emergency_whitelist", "203.0.113.5")
|
||||
cfg = security.build_security_config()
|
||||
assert cfg.whitelist is not None
|
||||
assert "203.0.113.5" in cfg.whitelist
|
||||
assert "172.16.0.0/12" in cfg.whitelist
|
||||
|
||||
@@ -35,8 +35,17 @@ if TYPE_CHECKING:
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
# The internal RFC1918/loopback mesh is guard-whitelisted (authenticated
|
||||
# agents skip the WAF/threat-ban); only external traffic — what nginx forwards
|
||||
# with the real client IP — is scrutinized. So legit/passive tests use the
|
||||
# whitelisted loopback (trusted-agent path) and threat tests use a public
|
||||
# (TEST-NET-3, non-routable) IP to model an external attacker past the
|
||||
# whitelist.
|
||||
_EXTERNAL_IP = "203.0.113.7"
|
||||
|
||||
|
||||
class _InjectClientIP:
|
||||
"""ASGI shim giving the request a valid peer IP (prod is behind nginx)."""
|
||||
"""ASGI shim giving the request a peer IP (prod is behind nginx)."""
|
||||
|
||||
def __init__(self, app: ASGIApp, ip: str = "127.0.0.1") -> None:
|
||||
self.app = app
|
||||
@@ -49,7 +58,7 @@ class _InjectClientIP:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _guarded_app(*, passive: bool) -> _InjectClientIP:
|
||||
def _guarded_app(*, passive: bool, ip: str = "127.0.0.1") -> _InjectClientIP:
|
||||
cfg = security.build_security_config()
|
||||
cfg.passive_mode = passive
|
||||
cfg.enable_redis = False
|
||||
@@ -88,7 +97,7 @@ def _guarded_app(*, passive: bool) -> _InjectClientIP:
|
||||
|
||||
app.state.guard_decorator = deco
|
||||
app.add_middleware(SecurityMiddleware, config=cfg)
|
||||
return _InjectClientIP(app)
|
||||
return _InjectClientIP(app, ip)
|
||||
|
||||
|
||||
def _client(app: _InjectClientIP) -> TestClient:
|
||||
@@ -136,19 +145,19 @@ class TestActiveModeNoFalsePositives:
|
||||
|
||||
class TestActiveModeStillBlocksThreats:
|
||||
def test_prompt_injection_blocked_even_in_excluded_field(self) -> None:
|
||||
with _client(_guarded_app(passive=False)) as client:
|
||||
with _client(_guarded_app(passive=False, ip=_EXTERNAL_IP)) as client:
|
||||
resp = client.post("/task", json={"description": _INJECTION})
|
||||
assert resp.status_code != HTTPStatus.OK
|
||||
|
||||
def test_secret_exfil_blocked_even_in_excluded_field(self) -> None:
|
||||
with _client(_guarded_app(passive=False)) as client:
|
||||
with _client(_guarded_app(passive=False, ip=_EXTERNAL_IP)) as client:
|
||||
resp = client.post(
|
||||
"/commit", json={"message": "my key is sk-ant-abcdefghij0123456789xyz"}
|
||||
)
|
||||
assert resp.status_code != HTTPStatus.OK
|
||||
|
||||
def test_internal_ssrf_blocked_even_in_excluded_field(self) -> None:
|
||||
with _client(_guarded_app(passive=False)) as client:
|
||||
with _client(_guarded_app(passive=False, ip=_EXTERNAL_IP)) as client:
|
||||
resp = client.post(
|
||||
"/research", json={"url": "http://169.254.169.254/latest/meta-data/"}
|
||||
)
|
||||
@@ -156,7 +165,7 @@ class TestActiveModeStillBlocksThreats:
|
||||
|
||||
def test_waf_still_fires_on_non_excluded_field(self) -> None:
|
||||
"""The exclusion is field-scoped: a structured field still gets scanned."""
|
||||
with _client(_guarded_app(passive=False)) as client:
|
||||
with _client(_guarded_app(passive=False, ip=_EXTERNAL_IP)) as client:
|
||||
resp = client.post("/plain", json={"zzq_ref": "'; DROP TABLE x; --"})
|
||||
assert resp.status_code != HTTPStatus.OK
|
||||
|
||||
@@ -170,7 +179,7 @@ class TestDecoyPaths:
|
||||
"""
|
||||
|
||||
def test_decoy_path_blocked_in_active_mode(self) -> None:
|
||||
with _client(_guarded_app(passive=False)) as client:
|
||||
with _client(_guarded_app(passive=False, ip=_EXTERNAL_IP)) as client:
|
||||
resp = client.get("/.git/config")
|
||||
assert resp.status_code != HTTPStatus.OK
|
||||
|
||||
|
||||
Reference in New Issue
Block a user