feat(auth): forged browser requests are refused (#5031)
#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:
@@ -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
|
||||
Reference in New Issue
Block a user