From 882263d27644341bd266b5f92603b7e9d9b5bdde Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 16 Sep 2026 02:41:25 +0000 Subject: [PATCH] login POST now requires the X-Aniweb header: cross-site forced-login (attacker-form session swap) dies at the 403; login.html sends the header like the SPA --- server/app.py | 10 ++++++---- tests/test_api.py | 24 +++++++++++++++++++++--- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/server/app.py b/server/app.py index 7ad9d59..efd10e9 100644 --- a/server/app.py +++ b/server/app.py @@ -146,10 +146,12 @@ def _session_user(request) -> str | None: async def csrf_header(request, call_next): # ponytail: a cross-site form POST (top-level navigation) does carry the # SameSite=Lax cookie, so
from a - # malicious page would work; forms can't set a custom header, the SPA - # always sends it. Runs *after* require_auth (registered earlier), so - # unauthenticated requests still get 302/401 first. - if request.method in ("POST", "DELETE") and request.url.path != "/login": + # malicious page would work; forms can't set a custom header, the SPA and + # the login page both send one. /login is covered too: login needs no + # cookie, so an exempt /login let a cross-site forced-login POST swap a + # visitor's session onto the attacker's account. Runs *after* require_auth + # (registered earlier), so unauthenticated GETs still 302/401 first. + if request.method in ("POST", "DELETE"): if request.headers.get("x-aniweb") != "1": return JSONResponse({"detail": "forbidden"}, status_code=403) return await call_next(request) diff --git a/tests/test_api.py b/tests/test_api.py index 407e198..deada4c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -297,7 +297,8 @@ def test_self_delete(base=BASE): def test_csrf_header_required(base=BASE): # mutation routes without the X-Aniweb header -> 403 (a cross-site form - # POST can't set it; the SPA always sends it). /login is exempt. + # POST can't set it; the SPA always sends it). /login is covered too now + # (see test_login_requires_custom_header — forced-login was the hole). jar = login("admin", "adminpw12345", base) r = urllib.request.Request(base + "/logout", method="POST") try: @@ -320,9 +321,26 @@ def test_stream_token_host_allowlist(base=BASE): assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400 +def test_login_requires_custom_header(base=BASE): + # forced-login CSRF: /login was the one POST route exempt from the custom + # header check, and login needs no cookie — so any cross-site + # POST could silently sign a visiting browser into the attacker's account. + # /login now demands the header like every other mutation; a cross-site + # form cannot set it, so the whole attack class dies at the 403. + r = urllib.request.Request(base + "/login", method="POST", + data=json.dumps({"username": "admin", + "password": "adminpw12345"}).encode(), + headers={"Content-Type": "application/json"}) + try: + urllib.request.build_opener().open(r) + raise AssertionError("POST /login without X-Aniweb must be 403") + except urllib.error.HTTPError as e: + assert e.code == 403, e.code + + def test_lockout_per_user(base=BASE): - # a throwaway name: the lockout is per-username and in-memory, and bob is - # also used by the browser tests (a locked-out bob breaks their logins) + # a throwaway name: the buckets are in-memory and per-username, and bob + # is also used by the browser tests (a locked-out bob breaks their logins) codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base) for _ in range(6)] assert codes[:5] == [401] * 5 and codes[5] == 429, codes