hermes-agent/tests/hermes_cli/test_csrf_sec_fetch_guard.py
Ben cde893cce6 feat(dashboard-auth): add Sec-Fetch-Site CSRF guard on mutating /api routes
Credential-free, browser-asserted CSRF defense that applies in both auth
regimes. Rejects a PRESENT hostile Sec-Fetch-Site (cross-site/same-site)
on POST/PUT/PATCH/DELETE under /api/*; fails open on an absent header so
non-browser clients (curl, NAS probe, desktop) are unaffected. Reads stay
CORS-covered (mutations-only scope, plan Q2).

This is the replacement for the legacy _SESSION_TOKEN's only load-bearing
job, installed BEFORE the token is removed so there's never a window with
neither defense.
2026-06-17 09:55:43 +10:00

137 lines
5.4 KiB
Python

"""Sec-Fetch-Site CSRF guard on mutating /api/* routes.
The guard replaces the legacy ``_SESSION_TOKEN``'s only robust
contribution — blocking drive-by CSRF from a web page the user visits —
with a credential-free, browser-asserted check that applies in BOTH auth
regimes. ``Sec-Fetch-Site`` is a forbidden header name (JS cannot forge
it), so a cross-origin page cannot spoof ``same-origin``.
Scope decision (plan Q2): mutating methods only. Reads are already
neutralised by the CORSMiddleware (localhost-only origin regex,
allow_credentials off), which prevents a foreign origin from reading any
``/api/*`` response body.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def loopback_client():
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# A real state-changing route. The CSRF guard runs BEFORE auth, so the
# blocked cases 403 regardless of token; the allowed cases carry a valid
# token so a non-403 proves the guard let them through to auth+handler.
_MUTATING_ROUTE = "/api/providers/validate"
@pytest.mark.parametrize("sfs", ["cross-site", "same-site"])
def test_cross_origin_mutation_blocked(loopback_client, sfs):
r = loopback_client.post(
_MUTATING_ROUTE,
headers={
"X-Hermes-Session-Token": web_server._SESSION_TOKEN,
"Sec-Fetch-Site": sfs,
},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code == 403
assert r.json().get("error") == "cross_origin_blocked"
@pytest.mark.parametrize("sfs", ["same-origin", "none"])
def test_same_origin_mutation_allowed(loopback_client, sfs):
r = loopback_client.post(
_MUTATING_ROUTE,
headers={
"X-Hermes-Session-Token": web_server._SESSION_TOKEN,
"Sec-Fetch-Site": sfs,
},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
# Reaches the handler (any non-403): the CSRF guard let it through.
assert r.status_code != 403
def test_absent_header_fails_open(loopback_client):
"""Non-browser clients (curl, NAS probe, desktop) send no
Sec-Fetch-Site and must NOT be blocked."""
r = loopback_client.post(
_MUTATING_ROUTE,
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code != 403
def test_cross_site_get_not_blocked(loopback_client):
"""Reads are CORS-covered, not CSRF-guarded (mutations-only scope)."""
r = loopback_client.get(
"/api/status", headers={"Sec-Fetch-Site": "cross-site"}
)
assert r.status_code == 200
def test_guard_applies_in_gated_mode():
"""The guard is mode-agnostic: a cross-site mutation from an
AUTHENTICATED session is still blocked in gated mode by the CSRF guard.
A cookieless gated request 401s at the cookie gate before the CSRF
guard runs (Starlette runs last-registered-middleware outermost, so
the auth gate is outer). To prove the CSRF guard actually fires in
gated mode we must carry a valid session cookie so the request gets
past the gate and reaches the guard, which then 403s the cross-site
mutation.
"""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
clear_providers()
provider = StubAuthProvider()
register_provider(provider)
web_server.app.state.auth_required = True
web_server.app.state.bound_host = "fly-app.fly.dev"
try:
# Mint a real session via the stub's login round trip.
start = provider.start_login(redirect_uri="https://fly-app.fly.dev/auth/callback")
state = start.cookie_payload["hermes_session_pkce"].split("state=")[1].split(";")[0]
verifier = start.cookie_payload["hermes_session_pkce"].split("verifier=")[1]
session = provider.complete_login(
code="stub_code", state=state, code_verifier=verifier,
redirect_uri="https://fly-app.fly.dev/auth/callback",
)
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
client.cookies.set(SESSION_AT_COOKIE, session.access_token)
r = client.post(
_MUTATING_ROUTE,
headers={"Sec-Fetch-Site": "cross-site"},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code == 403
assert r.json().get("error") == "cross_origin_blocked"
finally:
clear_providers()
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host