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
This commit is contained in:
1 parent
3e5c7062ab
commit
882263d276
2 files changed
+27
-7
No files matched your search
+6
-4
@@ -146,10 +146,12 @@ def _session_user(request) -> str | None:
|
|||||||
async def csrf_header(request, call_next):
|
async def csrf_header(request, call_next):
|
||||||
# ponytail: a cross-site form POST (top-level navigation) does carry the
|
# ponytail: a cross-site form POST (top-level navigation) does carry the
|
||||||
# SameSite=Lax cookie, so <form action=.../account/delete> from a
|
# SameSite=Lax cookie, so <form action=.../account/delete> from a
|
||||||
# malicious page would work; forms can't set a custom header, the SPA
|
# malicious page would work; forms can't set a custom header, the SPA and
|
||||||
# always sends it. Runs *after* require_auth (registered earlier), so
|
# the login page both send one. /login is covered too: login needs no
|
||||||
# unauthenticated requests still get 302/401 first.
|
# cookie, so an exempt /login let a cross-site forced-login POST swap a
|
||||||
if request.method in ("POST", "DELETE") and request.url.path != "/login":
|
# 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":
|
if request.headers.get("x-aniweb") != "1":
|
||||||
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|||||||
+21
-3
@@ -297,7 +297,8 @@ def test_self_delete(base=BASE):
|
|||||||
|
|
||||||
def test_csrf_header_required(base=BASE):
|
def test_csrf_header_required(base=BASE):
|
||||||
# mutation routes without the X-Aniweb header -> 403 (a cross-site form
|
# 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)
|
jar = login("admin", "adminpw12345", base)
|
||||||
r = urllib.request.Request(base + "/logout", method="POST")
|
r = urllib.request.Request(base + "/logout", method="POST")
|
||||||
try:
|
try:
|
||||||
@@ -320,9 +321,26 @@ def test_stream_token_host_allowlist(base=BASE):
|
|||||||
assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400
|
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 <form>
|
||||||
|
# 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):
|
def test_lockout_per_user(base=BASE):
|
||||||
# a throwaway name: the lockout is per-username and in-memory, and bob is
|
# a throwaway name: the buckets are in-memory and per-username, and bob
|
||||||
# also used by the browser tests (a locked-out bob breaks their logins)
|
# 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)
|
codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base)
|
||||||
for _ in range(6)]
|
for _ in range(6)]
|
||||||
assert codes[:5] == [401] * 5 and codes[5] == 429, codes
|
assert codes[:5] == [401] * 5 and codes[5] == 429, codes
|
||||||
|
|||||||
Reference in new issue
Block a user