mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
co-authored by
Backend Developer 1
Backend Documenter
roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
parent
628b925f40
commit
d829979b6a
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [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
|
||||
|
||||
### 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.
|
||||
|
||||
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
@@ -608,12 +608,15 @@ def build_security_config() -> SecurityConfig:
|
||||
"""Assemble roboco's global guard config from settings (behind nginx)."""
|
||||
return SecurityConfig(
|
||||
# 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=[
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
],
|
||||
trusted_proxy_depth=1,
|
||||
trust_x_forwarded_proto=True,
|
||||
|
||||
+84
-21
@@ -8095,12 +8095,7 @@ class TaskService(BaseService):
|
||||
return None
|
||||
|
||||
if cancellation_note:
|
||||
task.dev_notes = (
|
||||
f"{task.dev_notes}\n{cancellation_note}"
|
||||
if task.dev_notes
|
||||
else cancellation_note
|
||||
)
|
||||
await self.session.flush()
|
||||
await self._append_cancel_note(task, cancellation_note)
|
||||
|
||||
# Cancel all descendants first (children, grandchildren, etc.)
|
||||
# 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
|
||||
# gate from silently orphaning.) Non-validation errors propagate.
|
||||
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_now: list[TaskTable] = []
|
||||
for descendant in descendants:
|
||||
@@ -8153,8 +8194,17 @@ class TaskService(BaseService):
|
||||
task_id=str(task_id),
|
||||
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)
|
||||
cancelled_now.append(task)
|
||||
await self._abandon_work_session_for_task(task, reason="task cancelled")
|
||||
@@ -8163,21 +8213,27 @@ class TaskService(BaseService):
|
||||
await self.session.flush()
|
||||
await self._alert_coroner_of_cancel(task)
|
||||
|
||||
# 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. Prune for the whole subtree (root + all descendants);
|
||||
# idempotent for already-terminal descendants whose edges may have
|
||||
# been pruned before — a second prune just finds no matching edge.
|
||||
async def _prune_cancelled_dependencies(
|
||||
self,
|
||||
task_id: UUID,
|
||||
descendants: list[TaskTable],
|
||||
) -> None:
|
||||
"""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)):
|
||||
if cancelled_id is not None:
|
||||
await self._unblock_dependents(cancelled_id)
|
||||
|
||||
# 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.
|
||||
async def _audit_orphaned_acs(
|
||||
self,
|
||||
task_id: UUID,
|
||||
task: TaskTable,
|
||||
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)
|
||||
if orphaned:
|
||||
self.log.warning(
|
||||
@@ -8191,8 +8247,17 @@ class TaskService(BaseService):
|
||||
# schema change; upgrade to a real field if a caller needs it
|
||||
# across a request boundary.
|
||||
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(
|
||||
self._index_lifecycle_event_background(
|
||||
task_id=task_id,
|
||||
@@ -8209,8 +8274,6 @@ class TaskService(BaseService):
|
||||
self._background_tasks.add(bg_task)
|
||||
bg_task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
return task
|
||||
|
||||
async def _detect_orphaned_parent_acs(
|
||||
self, cancelled: list[TaskTable]
|
||||
) -> list[str]:
|
||||
|
||||
@@ -333,13 +333,18 @@ def test_full_walk_when_marker_absent(
|
||||
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)
|
||||
marker = tmp_path / ".git" / "roboco-owned"
|
||||
assert not marker.exists()
|
||||
|
||||
# A real pass: chown to uid 1000 fails under the test's real (non-root)
|
||||
# uid, exactly like a rootless/userns host — so no marker should land.
|
||||
# Simulate a rootless/userns host where chown is rejected — the marker
|
||||
# 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)
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
@@ -20,14 +20,17 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from guard import SecurityMiddleware
|
||||
from guard.adapters import StarletteGuardRequest
|
||||
from guard.lifespan import make_lifespan
|
||||
from guard_core.utils import extract_client_ip, is_ip_allowed
|
||||
from roboco import security
|
||||
from starlette.requests import Request
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -218,3 +221,48 @@ class TestDecoyPaths:
|
||||
with _client(_guarded_app(passive=True)) as client:
|
||||
resp = client.get("/.git/config")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user