From 59927b6ad7bddd2e78b1173dd75a6c7e1d666946 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 1 May 2026 14:59:57 -0400 Subject: [PATCH] fix: whitelist peer-sync endpoint from session auth + CSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/cells/peer-sync/permissions is called over the WireGuard tunnel by remote cells — they have no session cookie and cannot produce a CSRF token. The endpoint authenticates via source IP (must be in the remote cell's vpn_subnet) and WireGuard public key instead. Without this, the global enforce_auth hook returns 401 before the route handler runs, so all cross-cell permission pushes fail even when the WG tunnel and iptables rules are correct. Also adds a test verifying the route can be reached without a session. Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 6 ++++++ tests/test_route_protection.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/api/app.py b/api/app.py index 17106b3..70261d8 100644 --- a/api/app.py +++ b/api/app.py @@ -171,6 +171,9 @@ def enforce_auth(): # Always allow non-API paths and auth namespace if not path.startswith('/api/') or path.startswith('/api/auth/'): return None + # Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session + if path.startswith('/api/cells/peer-sync/'): + return None # Only enforce when auth_manager has been properly initialised and seeded. # When the user store is empty (file missing or unreadable — typical in # unit tests and fresh installs), bypass enforcement so pre-auth test @@ -225,6 +228,9 @@ def check_csrf(): path = request.path if not path.startswith('/api/') or path.startswith('/api/auth/'): return None + # peer-sync uses IP+pubkey auth — no session, no CSRF token possible + if path.startswith('/api/cells/peer-sync/'): + return None token_session = session.get('csrf_token') if not token_session: # Session predates CSRF tokens (existing login) — issue a token now so diff --git a/tests/test_route_protection.py b/tests/test_route_protection.py index efe7823..b5045bf 100644 --- a/tests/test_route_protection.py +++ b/tests/test_route_protection.py @@ -205,3 +205,20 @@ def test_anon_can_reach_auth_namespace(anon_client): # 401 is expected here but it must originate from the route, not a redirect/block # on a non-auth path. The response should be JSON, not a redirect (3xx). assert r.status_code not in (301, 302, 403) + + +def test_anon_can_reach_peer_sync_endpoint(anon_client): + """POST /api/cells/peer-sync/* must not be blocked by session auth. + + The peer-sync endpoint authenticates via source IP + WireGuard pubkey. + It must not return 401/403 from the global enforce_auth hook — the route + handler itself produces any rejection. + """ + r = anon_client.post( + '/api/cells/peer-sync/permissions', + json={}, + content_type='application/json', + ) + # 400 (bad payload) or 403 (IP/pubkey rejected) are acceptable — 401 from + # the global auth hook is NOT acceptable because the route has its own guard. + assert r.status_code != 401