feat(auth): forged browser requests are refused (#5031)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 49s
CI / unit (push) Successful in 1m3s
CI / publish (push) Successful in 1m18s

#5031 "Forged-request protection: SameSite cookie, per-session CSRF token
on every non-GET, HTMX header". A page on another site could make a
logged-in browser POST to Steward with the session cookie attached.

- steward/auth/csrf.py: a per-session token, checked in one before_request
  for every POST/PUT/PATCH/DELETE (header X-CSRF-Token or form field
  csrf_token, constant-time compare). The bearer-authenticated host-agent
  ingest is exempt; /mcp never reaches Quart.
- Every POST form renders {{ csrf_field() }}; base.html carries the token
  in a meta tag and adds it to every HTMX request; the dashboard's fetch()
  calls send it.
- The session cookie is SameSite=Lax; login issues a fresh token.
- PUBLIC_BASE_URL is now loaded from Settings at startup (it was only set
  when Settings → General was saved).
- tests/test_csrf.py: refusal, acceptance, rotation, the ingest exemption,
  and a guard that every POST form in the templates carries the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:44:19 -04:00
co-authored by Claude Opus 5.5
parent 1d9ac2b46a
commit 96126b1c83
46 changed files with 272 additions and 90 deletions
+5 -3
View File
@@ -132,6 +132,8 @@ def test_routes_create_list_and_revoke(app):
sess["user_id"] = uid
sess["user_role"] = "operator"
sess["username"] = "route-test"
sess["_csrf"] = "test-token"
csrf = {"X-CSRF-Token": "test-token"}
resp = await client.get("/ai-access/")
assert resp.status_code == 200
@@ -143,12 +145,12 @@ def test_routes_create_list_and_revoke(app):
assert 'value="http://localhost/mcp"' in page and "claude mcp add" in page
resp = await client.post("/ai-access/tokens", form={
"name": "laptop", "role": "admin", "expires_in_days": ""})
"name": "laptop", "role": "admin", "expires_in_days": ""}, headers=csrf)
assert resp.status_code == 400
assert "higher role" in await resp.get_data(as_text=True)
resp = await client.post("/ai-access/tokens", form={
"name": "laptop", "role": "operator", "expires_in_days": "30"})
"name": "laptop", "role": "operator", "expires_in_days": "30"}, headers=csrf)
assert resp.status_code == 200
created = await resp.get_data(as_text=True)
raw = re.search(r'value="(stw_[^"]+)"', created).group(1)
@@ -163,7 +165,7 @@ def test_routes_create_list_and_revoke(app):
assert "laptop" in page and token.prefix in page
assert raw not in page
resp = await client.post(f"/ai-access/tokens/{token.id}/revoke")
resp = await client.post(f"/ai-access/tokens/{token.id}/revoke", headers=csrf)
assert resp.status_code == 302
assert await api_tokens.resolve_token(app, raw) is None
+13 -2
View File
@@ -15,6 +15,13 @@ def make_user(role=UserRole.admin):
)
async def with_csrf(client) -> dict:
"""Seed the session's forged-request token; returns the header to send."""
async with client.session_transaction() as sess:
sess["_csrf"] = "test-token"
return {"X-CSRF-Token": "test-token"}
@pytest.mark.asyncio
async def test_login_page_returns_200(client):
response = await client.get("/login")
@@ -32,9 +39,11 @@ async def test_login_redirects_to_setup_when_no_users(client, app):
@pytest.mark.asyncio
async def test_login_success_redirects_to_dashboard(client, app):
user = make_user()
headers = await with_csrf(client)
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)):
response = await client.post("/login", form={"username": "admin", "password": "password"})
response = await client.post("/login", form={"username": "admin", "password": "password"},
headers=headers)
assert response.status_code == 302
assert "/" in response.headers["Location"]
@@ -42,9 +51,11 @@ async def test_login_success_redirects_to_dashboard(client, app):
@pytest.mark.asyncio
async def test_login_failure_returns_400(client, app):
user = make_user()
headers = await with_csrf(client)
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=user)):
response = await client.post("/login", form={"username": "admin", "password": "wrong"})
response = await client.post("/login", form={"username": "admin", "password": "wrong"},
headers=headers)
assert response.status_code == 400
+81
View File
@@ -0,0 +1,81 @@
"""Forged-request protection: every unsafe browser request carries the
session's token, and every POST form in the templates sends it."""
import pathlib
import re
from unittest.mock import AsyncMock, patch
import pytest
import steward
from steward.auth import csrf
_REPO = pathlib.Path(steward.__file__).parent.parent
_FORM_TAG = re.compile(r"<form\b[^>]*>", re.S | re.I)
_POST = re.compile(r"""\bmethod\s*=\s*["']?post\b""", re.I)
def test_every_post_form_carries_the_token():
files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \
sorted((_REPO / "plugins").rglob("templates/**/*.html"))
missing = []
for f in files:
text = f.read_text()
for m in _FORM_TAG.finditer(text):
if _POST.search(m.group(0)) and \
not text[m.end():].lstrip().startswith("{{ csrf_field() }}"):
line = text.count("\n", 0, m.start()) + 1
missing.append(f"{f.relative_to(_REPO)}:{line}")
assert not missing, "POST forms without {{ csrf_field() }}: " + ", ".join(missing)
@pytest.mark.asyncio
async def test_post_without_token_is_refused(client):
response = await client.post("/login", form={"username": "a", "password": "b"})
assert response.status_code == 400
assert "did not come from Steward" in await response.get_data(as_text=True)
@pytest.mark.asyncio
async def test_post_with_wrong_token_is_refused(client):
async with client.session_transaction() as sess:
sess["_csrf"] = "right"
response = await client.post("/login", form={"username": "a", "password": "b"},
headers={"X-CSRF-Token": "wrong"})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_form_field_token_is_accepted_and_rotated_at_login(client):
from tests.test_auth import make_user
async with client.session_transaction() as sess:
sess["_csrf"] = "before-login"
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=make_user())):
response = await client.post("/login", form={
"username": "admin", "password": "password", "csrf_token": "before-login"})
assert response.status_code == 302
async with client.session_transaction() as sess:
assert sess.get("_csrf") != "before-login"
@pytest.mark.asyncio
async def test_login_page_renders_token_into_form_and_meta(client):
page = await (await client.get("/login")).get_data(as_text=True)
async with client.session_transaction() as sess:
token = sess["_csrf"]
assert f'name="csrf_token" value="{token}"' in page
assert f'<meta name="csrf-token" content="{token}">' in page
@pytest.mark.asyncio
async def test_bearer_ingest_is_exempt(app):
async with app.test_request_context("/plugins/host_agent/ingest", method="POST"):
assert await csrf.check_request() is None
async with app.test_request_context("/hosts/", method="POST"):
assert (await csrf.check_request()).status_code == 400
@pytest.mark.asyncio
async def test_get_needs_no_token(app):
async with app.test_request_context("/hosts/", method="GET"):
assert await csrf.check_request() is None