"""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"]*>", re.S | re.I) _POST = re.compile(r"""\bmethod\s*=\s*["']?post\b""", re.I) def forms_missing_token(text: str) -> list[int]: """Line numbers of POST forms whose first child is not {{ csrf_field() }}.""" return [text.count("\n", 0, m.start()) + 1 for m in _FORM_TAG.finditer(text) if _POST.search(m.group(0)) and not text[m.end():].lstrip().startswith("{{ csrf_field() }}")] def test_the_form_guard_can_fail(): broken = '\n \n' fixed = '
{{ csrf_field() }}
' assert forms_missing_token(broken) == [1] assert forms_missing_token(fixed) == [] assert forms_missing_token('
') == [] def test_every_post_form_carries_the_token(): files = sorted((_REPO / "steward" / "templates").rglob("*.html")) + \ sorted((_REPO / "plugins").rglob("templates/**/*.html")) assert files missing = [f"{f.relative_to(_REPO)}:{line}" for f in files for line in forms_missing_token(f.read_text())] 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'' 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