[09d0185a] Backend: fix the roboco-api CI regression (make make quality green on slave) (#808)

* [c2c29256] Fix make quality CI regression on roboco-api slave (#804)

* [c2c29256] fix: mock _chown_entry to simulate chown failure in test_marker_written_only_on_zero_failure_pass

* [c2c29256] docs(changelog): add Unreleased Fixed entry for make quality slave CI regression fix

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [b5bc53ea] Fix security middleware trusted_proxies + run full make quality green (#811)

* [b5bc53ea] fix(security): remove LAN ranges from trusted_proxies so forwarded LAN IPs are rejected

* [b5bc53ea] refactor(task): extract private helpers from cancel() to clear xenon rank C gate

* [b5bc53ea] docs(security): document trusted_proxies/whitelist lockstep invariant and the #811 LAN-IP fix

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [13193140] Strengthen security middleware IP-resolution test + document trusted_proxies invariant (#813)

* [13193140] test(security): add direct IP-resolution unit tests and document trusted_proxies invariant

* [13193140] docs(security): cross-reference trusted_proxies invariant proof tests and inline comment

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

---------

Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
roboco-app[bot]
2026-08-02 11:33:40 +00:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
parent 628b925f40
commit d829979b6a
6 changed files with 164 additions and 27 deletions
+8
View File
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [Unreleased]
### Security
- **Forwarded LAN IPs no longer ride the internal-mesh whitelist (#811).** `trusted_proxies` in `build_security_config` had drifted to include `10.0.0.0/8` and `192.168.0.0/16` — LAN ranges the `_INTERNAL_NETWORKS` whitelist already excluded. With `trusted_proxy_depth=1`, a docker-bridge nginx forwarding `X-Forwarded-For: 192.168.1.50` (a real LAN client) made guard peel the LAN IP as a "trusted hop," fall back to the whitelisted `172.18.x` connecting peer, and return `200 OK` — the narrowed whitelist was never consulted for the LAN IP. `trusted_proxies` now contains only `127.0.0.1`, `::1`, `172.16.0.0/12` (identical to the whitelist), so the forwarded LAN IP resolves as the real client and is blocked; a docker-bridge peer with no XFF still resolves to itself and stays exempt. The two lists are one policy split across two guard-core knobs and must move together.
### Fixed
- **`make quality` slave CI regression unblocked (#804).** `test_marker_written_only_on_zero_failure_pass` assumed `chown` to uid 1000 fails under the test process's own non-root uid — but `_chown_entry` returns `True` when the files are already owned by `_AGENT_UID` (uid 1000, same as the test process), so the expected chown failure never materialized and the marker landed anyway. On CI (running as root) the `chown` to uid 1000 also succeeds, so the test failed there too. The test now mocks `_chown_entry` to return `False` via `monkeypatch`, deterministically simulating a rootless/userns host where `chown` is rejected — preserving the original test intent (marker written only on a zero-failure pass) without relying on the process uid. Test-only change; no production behavior affected.
## [0.28.0] - 2026-07-29 ## [0.28.0] - 2026-07-29
### Added ### Added
@@ -48,3 +48,13 @@ A separate layer targets automated scanners (not agents — agents run on Docker
Agents reach the orchestrator DIRECTLY on the docker bridge (no nginx hop), HMAC-authenticated — the guard's WAF/IP-ban/rate-limit is meant for the EXTERNAL attack surface arriving through nginx, not for that already-authenticated internal traffic. A `whitelist` of loopback (`127.0.0.1`/`::1`) plus docker's default bridge address-pool range (`172.16.0.0/12`) skips WAF/ban/rate-limit checks entirely for requests from those addresses — without it, an ordinary journal/note body tripping a WAF signature would IP-ban the whole agent container, wedging every subsequent verb call (`dm`, `i_am_idle`, ...) behind it. Agents reach the orchestrator DIRECTLY on the docker bridge (no nginx hop), HMAC-authenticated — the guard's WAF/IP-ban/rate-limit is meant for the EXTERNAL attack surface arriving through nginx, not for that already-authenticated internal traffic. A `whitelist` of loopback (`127.0.0.1`/`::1`) plus docker's default bridge address-pool range (`172.16.0.0/12`) skips WAF/ban/rate-limit checks entirely for requests from those addresses — without it, an ordinary journal/note body tripping a WAF signature would IP-ban the whole agent container, wedging every subsequent verb call (`dm`, `i_am_idle`, ...) behind it.
This whitelist is deliberately narrow — NOT the full RFC1918 range. `10.0.0.0/8` and `192.168.0.0/16` are excluded on purpose: those also cover any real LAN client hitting nginx, not just the docker mesh, and with `trusted_proxy_depth=1` a genuine LAN browser's real IP survives the one XFF hop, so including them would let real external traffic skip the WAF right alongside agent traffic. A known ceiling remains: this can't distinguish a real docker-bridge peer from host-loopback/NAT'd traffic landing on the same address family, so a host-proxied chain (e.g. Tailscale Serve terminating on the host before nginx) can still resolve into this range and ride the exemption — see `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` above for the separate mechanism that scopes that specific shape. This whitelist is deliberately narrow — NOT the full RFC1918 range. `10.0.0.0/8` and `192.168.0.0/16` are excluded on purpose: those also cover any real LAN client hitting nginx, not just the docker mesh, and with `trusted_proxy_depth=1` a genuine LAN browser's real IP survives the one XFF hop, so including them would let real external traffic skip the WAF right alongside agent traffic. A known ceiling remains: this can't distinguish a real docker-bridge peer from host-loopback/NAT'd traffic landing on the same address family, so a host-proxied chain (e.g. Tailscale Serve terminating on the host before nginx) can still resolve into this range and ride the exemption — see `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` above for the separate mechanism that scopes that specific shape.
## `trusted_proxies` Must Track the Whitelist
`build_security_config` passes guard-core a second, easily-confused list alongside the whitelist: `trusted_proxies` — the addresses guard treats as *proxy hops* when it walks `X-Forwarded-For` to depth `trusted_proxy_depth`. It is NOT the whitelist (which decides who skips WAF/ban/rate-limit), but the two MUST stay in lockstep, and on this deploy they're identical: `127.0.0.1`, `::1`, `172.16.0.0/12` — loopback plus docker's default bridge pool, the same set as `_INTERNAL_NETWORKS`.
The invariant: **whatever ranges the whitelist excludes, `trusted_proxies` must exclude too.** If `trusted_proxies` ever widens to cover a range the whitelist does not (the bug fixed in #811), a forwarded IP from that range is treated as a proxy *hop* rather than the real client — guard peels it, falls back to the connecting peer, and if that peer is itself whitelisted (a docker-bridge nginx in `172.16.0.0/12`), the request rides the exemption. Concretely: with `10.0.0.0/8` and `192.168.0.0/16` erroneously in `trusted_proxies`, a docker-bridge nginx forwarding `X-Forwarded-For: 192.168.1.50` (a real LAN client) made guard peel `192.168.1.50` as a "trusted hop," resolve the client to the whitelisted `172.18.x` peer, and return `200 OK` — the narrowed whitelist was never consulted for the LAN IP at all. The fix removed both LAN ranges from `trusted_proxies`; now guard resolves `192.168.1.50` as the real client, finds it outside `_INTERNAL_NETWORKS`, and blocks it. The companion case still holds: a docker-bridge peer with no XFF resolves to itself (in `172.16.0.0/12`) and stays exempt.
If you ever narrow or widen the whitelist, apply the same edit to `trusted_proxies` in the same commit — the two lists are one policy, split across two guard-core knobs.
The invariant is anchored in two places so it can't silently drift: an `INVARIANT` comment at the `trusted_proxies` definition in `build_security_config` (`roboco/security.py`) restates the must-mirror-`_INTERNAL_NETWORKS` rule in-line, and two self-contained unit tests in `tests/unit/test_security_middleware.py` prove the boundary directly (no running server) — `test_extract_client_ip_forwarded_lan_not_peeled` shows a docker-bridge peer forwarding `X-Forwarded-For: 192.168.1.50` resolves to that LAN IP (not peeled to the peer) under the narrowed `trusted_proxies`, and `test_is_ip_allowed_rejects_lan_ranges` shows `192.168.1.50` and `10.0.0.5` are both rejected by the `[127.0.0.1, ::1, 172.16.0.0/12]` whitelist. The end-to-end `test_nginx_forwarded_lan_client_is_not_whitelisted` covers the same boundary through the full middleware stack.
+5 -2
View File
@@ -608,12 +608,15 @@ def build_security_config() -> SecurityConfig:
"""Assemble roboco's global guard config from settings (behind nginx).""" """Assemble roboco's global guard config from settings (behind nginx)."""
return SecurityConfig( return SecurityConfig(
# Real client IP behind nginx (single hop) + the docker bridge ranges. # Real client IP behind nginx (single hop) + the docker bridge ranges.
# INVARIANT: trusted_proxies must mirror _INTERNAL_NETWORKS exactly
# (loopback + 172.16/12). Adding LAN ranges (10.0.0.0/8, 192.168.0.0/16)
# here lets guard peel forwarded LAN IPs as trusted hops and fall back to
# the whitelisted docker-bridge peer, bypassing the block — see
# test_nginx_forwarded_lan_client_is_not_whitelisted.
trusted_proxies=[ trusted_proxies=[
"127.0.0.1", "127.0.0.1",
"::1", "::1",
"10.0.0.0/8",
"172.16.0.0/12", "172.16.0.0/12",
"192.168.0.0/16",
], ],
trusted_proxy_depth=1, trusted_proxy_depth=1,
trust_x_forwarded_proto=True, trust_x_forwarded_proto=True,
+84 -21
View File
@@ -8095,12 +8095,7 @@ class TaskService(BaseService):
return None return None
if cancellation_note: if cancellation_note:
task.dev_notes = ( await self._append_cancel_note(task, cancellation_note)
f"{task.dev_notes}\n{cancellation_note}"
if task.dev_notes
else cancellation_note
)
await self.session.flush()
# Cancel all descendants first (children, grandchildren, etc.) # Cancel all descendants first (children, grandchildren, etc.)
# Skip tasks already in terminal states (completed or cancelled). # Skip tasks already in terminal states (completed or cancelled).
@@ -8113,6 +8108,52 @@ class TaskService(BaseService):
# this; the narrow catch + refusal keeps a future per-edge role # this; the narrow catch + refusal keeps a future per-edge role
# gate from silently orphaning.) Non-validation errors propagate. # gate from silently orphaning.) Non-validation errors propagate.
descendants = await self.get_all_descendants(task_id) descendants = await self.get_all_descendants(task_id)
cancelled_count, cancelled_now = await self._cascade_cancel_descendants(
task_id, descendants, agent_role
)
# Validate transition with PM role requirement
await self._cancel_task_self(task, agent_role, cancelled_now)
# Cascade the dependency cleanup the COMPLETE path runs. Without
# this, a task BLOCKED on the cancelled one is never auto-revived
# and the stale id lingers in every dependent's dependency_ids
# forever.
await self._prune_cancelled_dependencies(task_id, descendants)
# Origin fix: a cancelled child may have declared parent_ac_refs that
# no surviving sibling covers, leaving the roll-up gate
# (_parent_acs_covered_envelope) demanding coverage for already-
# finished work once a replacement is delegated. Warn-and-surface
# only — no hard gate; the PM re-declares via declare_coverage.
orphaned = await self._audit_orphaned_acs(task_id, task, cancelled_now)
await self._index_cancel_event(
task_id, task, agent_role, cancelled_count, orphaned
)
return task
async def _append_cancel_note(
self, task: TaskTable, cancellation_note: str
) -> None:
"""Append the cancellation note to ``dev_notes`` and flush."""
task.dev_notes = (
f"{task.dev_notes}\n{cancellation_note}"
if task.dev_notes
else cancellation_note
)
await self.session.flush()
async def _cascade_cancel_descendants(
self,
task_id: UUID,
descendants: list[TaskTable],
agent_role: str,
) -> tuple[int, list[TaskTable]]:
"""Cascade-cancel every non-terminal descendant, refusing the whole
cancel on a role-validation failure so an orphaned subtree is never
left under a cancelled parent."""
cancelled_count = 0 cancelled_count = 0
cancelled_now: list[TaskTable] = [] cancelled_now: list[TaskTable] = []
for descendant in descendants: for descendant in descendants:
@@ -8153,8 +8194,17 @@ class TaskService(BaseService):
task_id=str(task_id), task_id=str(task_id),
cancelled_count=cancelled_count, cancelled_count=cancelled_count,
) )
return cancelled_count, cancelled_now
# Validate transition with PM role requirement async def _cancel_task_self(
self,
task: TaskTable,
agent_role: str,
cancelled_now: list[TaskTable],
) -> None:
"""Validate+set the task itself to CANCELLED, abandon its work
session, close its PR, delete its branch, flush, and alert the
coroner."""
self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role) self._validate_and_set_status(task, TaskStatus.CANCELLED, agent_role)
cancelled_now.append(task) cancelled_now.append(task)
await self._abandon_work_session_for_task(task, reason="task cancelled") await self._abandon_work_session_for_task(task, reason="task cancelled")
@@ -8163,21 +8213,27 @@ class TaskService(BaseService):
await self.session.flush() await self.session.flush()
await self._alert_coroner_of_cancel(task) await self._alert_coroner_of_cancel(task)
# Cascade the dependency cleanup the COMPLETE path runs. Without async def _prune_cancelled_dependencies(
# this, a task BLOCKED on the cancelled one is never auto-revived self,
# and the stale id lingers in every dependent's dependency_ids task_id: UUID,
# forever. Prune for the whole subtree (root + all descendants); descendants: list[TaskTable],
# idempotent for already-terminal descendants whose edges may have ) -> None:
# been pruned before — a second prune just finds no matching edge. """Prune dependency edges for the whole cancelled subtree (root +
all descendants) so blocked dependents auto-revive. Idempotent for
already-terminal descendants whose edges may have been pruned
before a second prune just finds no matching edge."""
for cancelled_id in (task_id, *(getattr(d, "id", None) for d in descendants)): for cancelled_id in (task_id, *(getattr(d, "id", None) for d in descendants)):
if cancelled_id is not None: if cancelled_id is not None:
await self._unblock_dependents(cancelled_id) await self._unblock_dependents(cancelled_id)
# Origin fix: a cancelled child may have declared parent_ac_refs that async def _audit_orphaned_acs(
# no surviving sibling covers, leaving the roll-up gate self,
# (_parent_acs_covered_envelope) demanding coverage for already- task_id: UUID,
# finished work once a replacement is delegated. Warn-and-surface task: TaskTable,
# only — no hard gate; the PM re-declares via declare_coverage. cancelled_now: list[TaskTable],
) -> list[str]:
"""Detect parent ACs orphaned by the cancel, warn + emit audit, and
stash the transient result on the task for same-request callers."""
orphaned = await self._detect_orphaned_parent_acs(cancelled_now) orphaned = await self._detect_orphaned_parent_acs(cancelled_now)
if orphaned: if orphaned:
self.log.warning( self.log.warning(
@@ -8191,8 +8247,17 @@ class TaskService(BaseService):
# schema change; upgrade to a real field if a caller needs it # schema change; upgrade to a real field if a caller needs it
# across a request boundary. # across a request boundary.
task.orphaned_parent_acs = orphaned task.orphaned_parent_acs = orphaned
return orphaned
# Index lifecycle event (fire-and-forget) async def _index_cancel_event(
self,
task_id: UUID,
task: TaskTable,
agent_role: str,
cancelled_count: int,
orphaned: list[str],
) -> None:
"""Fire-and-forget lifecycle-event index for the cancel."""
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._index_lifecycle_event_background( self._index_lifecycle_event_background(
task_id=task_id, task_id=task_id,
@@ -8209,8 +8274,6 @@ class TaskService(BaseService):
self._background_tasks.add(bg_task) self._background_tasks.add(bg_task)
bg_task.add_done_callback(self._background_tasks.discard) bg_task.add_done_callback(self._background_tasks.discard)
return task
async def _detect_orphaned_parent_acs( async def _detect_orphaned_parent_acs(
self, cancelled: list[TaskTable] self, cancelled: list[TaskTable]
) -> list[str]: ) -> list[str]:
@@ -333,13 +333,18 @@ def test_full_walk_when_marker_absent(
assert str(tmp_path / "README.md") in _record_touched assert str(tmp_path / "README.md") in _record_touched
def test_marker_written_only_on_zero_failure_pass(tmp_path: Path) -> None: def test_marker_written_only_on_zero_failure_pass(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_build_workspace(tmp_path) _build_workspace(tmp_path)
marker = tmp_path / ".git" / "roboco-owned" marker = tmp_path / ".git" / "roboco-owned"
assert not marker.exists() assert not marker.exists()
# A real pass: chown to uid 1000 fails under the test's real (non-root) # Simulate a rootless/userns host where chown is rejected — the marker
# uid, exactly like a rootless/userns host — so no marker should land. # must NOT land when any entry's chown fails. Mocking _chown_entry avoids
# relying on the process uid (the test runs as uid 1000 and files are
# already owned by uid 1000, so a real chown would be a no-op success).
monkeypatch.setattr(workspace_module, "_chown_entry", lambda _entry, _st: False)
_ensure_agent_owned(tmp_path) _ensure_agent_owned(tmp_path)
assert not marker.exists() assert not marker.exists()
+49 -1
View File
@@ -20,14 +20,17 @@ from __future__ import annotations
import contextlib import contextlib
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, ClassVar
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from guard import SecurityMiddleware from guard import SecurityMiddleware
from guard.adapters import StarletteGuardRequest
from guard.lifespan import make_lifespan from guard.lifespan import make_lifespan
from guard_core.utils import extract_client_ip, is_ip_allowed
from roboco import security from roboco import security
from starlette.requests import Request
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -218,3 +221,48 @@ class TestDecoyPaths:
with _client(_guarded_app(passive=True)) as client: with _client(_guarded_app(passive=True)) as client:
resp = client.get("/.git/config") resp = client.get("/.git/config")
assert resp.status_code == HTTPStatus.OK assert resp.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Direct unit tests for the IP-resolution path the stale PR-review finding
# keeps questioning. These call guard_core's extract_client_ip / is_ip_allowed
# directly (no running server) to make the security boundary self-evident.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_extract_client_ip_forwarded_lan_not_peeled() -> None:
"""With trusted_proxies narrowed to the docker-bridge/loopback mesh (no
LAN ranges), a docker-bridge peer forwarding a LAN client's IP via
X-Forwarded-For resolves to the real LAN IP — not peeled to the peer."""
scope = {
"type": "http",
"method": "POST",
"path": "/task",
"query_string": b"",
"headers": [(b"x-forwarded-for", b"192.168.1.50")],
"client": ("172.18.0.5", 12345),
}
request = StarletteGuardRequest(Request(scope))
class _Cfg:
trusted_proxies: ClassVar[list[str]] = ["127.0.0.1", "::1", "172.16.0.0/12"]
trusted_proxy_depth = 1
assert await extract_client_ip(request, _Cfg()) == "192.168.1.50"
@pytest.mark.asyncio
async def test_is_ip_allowed_rejects_lan_ranges() -> None:
"""The narrowed whitelist (loopback + docker-bridge only) does NOT cover
RFC1918 LAN ranges, so a resolved LAN client IP is rejected."""
_WHITELIST = ["127.0.0.1", "::1", "172.16.0.0/12"]
class _Cfg:
whitelist: ClassVar[list[str]] = _WHITELIST
blacklist: ClassVar[list[str]] = []
blocked_countries: ClassVar[list[str]] = []
block_cloud_providers: ClassVar[list[str]] = []
assert await is_ip_allowed("192.168.1.50", _Cfg()) is False
assert await is_ip_allowed("10.0.0.5", _Cfg()) is False