From 7f531fc309a2bc0fbf0590ac9807c7a3e014ea0c Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 16 Sep 2026 02:02:09 +0000 Subject: [PATCH] CSRF header check now covers /login (exemption removed); login.html sends X-Aniweb --- server/app.py | 6 ++++-- static/login.html | 2 +- tests/test_api.py | 10 +++++++++- tests/test_browser.py | 4 ++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/server/app.py b/server/app.py index b059965..18db9e6 100644 --- a/server/app.py +++ b/server/app.py @@ -158,8 +158,10 @@ async def csrf_header(request, call_next): # 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": + # unauthenticated requests still get 302/401 first. /login is covered too + # now (was exempt): forced-login from an attacker's cross-site form is + # dead on arrival, and login.html sends the header like everything else. + 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/static/login.html b/static/login.html index 30fbcbb..78b72ab 100644 --- a/static/login.html +++ b/static/login.html @@ -49,7 +49,7 @@ const next = new URLSearchParams(location.search).get("next"); async function login() { $("err").textContent = ""; const r = await fetch(BASE + "login", {method: "POST", - headers: {"Content-Type": "application/json"}, + headers: {"Content-Type": "application/json", "X-Aniweb": "1"}, body: JSON.stringify({username: $("user").value, password: $("pw").value})}); if (r.ok) { location.href = next ? BASE + next.replace(/^\/+/, "") : BASE; diff --git a/tests/test_api.py b/tests/test_api.py index 6dfb8d6..dea87ba 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -297,8 +297,16 @@ 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 included (was + # exempt): forced-login from an attacker's cross-site form is dead. jar = login("admin", "adminpw12345", base) + r = urllib.request.Request(base + "/login", method="POST", + data=json.dumps({"username": "admin", "password": "x12345678"}).encode(), + headers={"Content-Type": "application/json"}) # no X-Aniweb on purpose + try: + assert urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)).open(r).status == 403 + except urllib.error.HTTPError as e: + assert e.code == 403, e.code r = urllib.request.Request(base + "/logout", method="POST") try: assert urllib.request.build_opener( diff --git a/tests/test_browser.py b/tests/test_browser.py index 6a28e26..3c055d8 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -114,7 +114,7 @@ async def _history_page_and_delete(): op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) op.open(urllib.request.Request(BASE + "/login", data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(), - headers={"Content-Type": "application/json"}, method="POST")) + headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST")) for n in range(1, 6): # 5 entries: makes the home "history →" link render e = {"title": f"Hist {n}", "slug_id": f"hist-{n}", "season_id": n, "ep_id": n, "lang": "eng", "ep": n, "pos": 5, "dur": 600} @@ -212,7 +212,7 @@ async def _recent_searches(): op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) op.open(urllib.request.Request(BASE + "/login", data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(), - headers={"Content-Type": "application/json"}, method="POST")) + headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST")) op.open(urllib.request.Request(BASE + "/api/searches", data=json.dumps({"q": "chainsaw"}).encode(), headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))