CSRF header check now covers /login (exemption removed); login.html sends X-Aniweb
This commit is contained in:
1 parent
b70ef403a9
commit
7f531fc309
4 files changed
+16
-6
No files matched your search
+4
-2
@@ -158,8 +158,10 @@ async def csrf_header(request, call_next):
|
|||||||
# 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
|
||||||
# always sends it. Runs *after* require_auth (registered earlier), so
|
# always sends it. Runs *after* require_auth (registered earlier), so
|
||||||
# unauthenticated requests still get 302/401 first.
|
# unauthenticated requests still get 302/401 first. /login is covered too
|
||||||
if request.method in ("POST", "DELETE") and request.url.path != "/login":
|
# 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":
|
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)
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ const next = new URLSearchParams(location.search).get("next");
|
|||||||
async function login() {
|
async function login() {
|
||||||
$("err").textContent = "";
|
$("err").textContent = "";
|
||||||
const r = await fetch(BASE + "login", {method: "POST",
|
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})});
|
body: JSON.stringify({username: $("user").value, password: $("pw").value})});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
location.href = next ? BASE + next.replace(/^\/+/, "") : BASE;
|
location.href = next ? BASE + next.replace(/^\/+/, "") : BASE;
|
||||||
|
|||||||
+9
-1
@@ -297,8 +297,16 @@ 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 included (was
|
||||||
|
# exempt): forced-login from an attacker's cross-site form is dead.
|
||||||
jar = login("admin", "adminpw12345", base)
|
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")
|
r = urllib.request.Request(base + "/logout", method="POST")
|
||||||
try:
|
try:
|
||||||
assert urllib.request.build_opener(
|
assert urllib.request.build_opener(
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async def _history_page_and_delete():
|
|||||||
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
op.open(urllib.request.Request(BASE + "/login",
|
op.open(urllib.request.Request(BASE + "/login",
|
||||||
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
|
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
|
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,
|
e = {"title": f"Hist {n}", "slug_id": f"hist-{n}", "season_id": n, "ep_id": n,
|
||||||
"lang": "eng", "ep": n, "pos": 5, "dur": 600}
|
"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 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
op.open(urllib.request.Request(BASE + "/login",
|
op.open(urllib.request.Request(BASE + "/login",
|
||||||
data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(),
|
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",
|
op.open(urllib.request.Request(BASE + "/api/searches",
|
||||||
data=json.dumps({"q": "chainsaw"}).encode(),
|
data=json.dumps({"q": "chainsaw"}).encode(),
|
||||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||||
|
|||||||
Reference in new issue
Block a user