harden: stream-token host allowlist (SSRF), CSRF header on mutating routes, atomic JSON writes
- /stream/{token}: tokens are plain base64; allowlist https://hls.anidb.app
so a session can't use the proxy for arbitrary URLs (new test)
- X-Aniweb:1 required on POST/DELETE except /login — SameSite=Lax is
still sent on cross-site form POSTs, which could hit /account/delete
(new test; SPA + tests send the header)
- tmp+os.replace writes for users/history/searches: a crash mid-write
could corrupt the JSON -> load() returned {} -> silent data loss
- resume positions now read from full history (?all=1), not top-5
- pagehide bound once; savePos reads currentEp (no stale closures)
- account delete also removes the searches file; episodes TTL 5m
- ani-web.service: supervised restart (not installed; see header)
- gitignore pi-session dumps + .pytest_cache
This commit is contained in:
1 parent
d39ee838ff
commit
aae5857dba
8 files changed
+122
-32
No files matched your search
+36
-3
@@ -25,7 +25,7 @@ def no_redirect(method, path, data=None, base=BASE):
|
||||
r = urllib.request.Request(base + path,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"})
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"})
|
||||
try:
|
||||
resp = opener.open(r)
|
||||
return resp.getcode(), resp.headers
|
||||
@@ -40,7 +40,8 @@ def no_redirect_get(path, base=BASE):
|
||||
def req(method, path, data=None, jar=None, base=BASE):
|
||||
r = urllib.request.Request(base + path,
|
||||
data=json.dumps(data).encode() if data is not None else None,
|
||||
method=method, headers={"Content-Type": "application/json"})
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"})
|
||||
if jar is not None:
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||
try:
|
||||
@@ -168,14 +169,42 @@ def test_recent_searches(base=BASE):
|
||||
|
||||
def test_self_delete(base=BASE):
|
||||
jar = login("dave", "davepw12345", base)
|
||||
# give dave a searches file too: account delete must remove it
|
||||
assert req("POST", "/api/searches", {"q": "dave-search"}, jar=jar, base=base) == 200
|
||||
assert req("POST", "/account/delete", jar=jar, base=base) == 200
|
||||
assert "dave" not in json.load(open(_users_file()))
|
||||
assert not os.path.exists(os.path.join(os.environ["ANIWEB_DATA"], "searches", "dave.json"))
|
||||
# old session cookie is dead immediately (middleware re-checks existence)
|
||||
assert req("GET", "/api/me", jar=jar, base=base) == 401
|
||||
jar2 = CJ()
|
||||
assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401
|
||||
|
||||
|
||||
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.
|
||||
jar = login("admin", "adminpw12345", base)
|
||||
r = urllib.request.Request(base + "/logout", method="POST")
|
||||
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
|
||||
# with the header (what the SPA sends) it works
|
||||
assert req("POST", "/logout", jar=jar, base=base) == 200
|
||||
|
||||
|
||||
def test_stream_token_host_allowlist(base=BASE):
|
||||
# tokens are plain base64: a crafted token must not let a logged-in user
|
||||
# turn the server into a GET proxy for arbitrary URLs
|
||||
import base64
|
||||
jar = login("alice", "alicepw12345", base)
|
||||
bad = base64.urlsafe_b64encode(b"https://evil.example/x.m3u8").decode()
|
||||
assert req("GET", f"/stream/{bad}", jar=jar, base=base) == 400
|
||||
bad2 = base64.urlsafe_b64encode(b"http://hls.anidb.app/x").decode() # non-https
|
||||
assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400
|
||||
|
||||
|
||||
def test_lockout_per_user(base=BASE):
|
||||
# must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob,
|
||||
# admin unaffected
|
||||
@@ -187,8 +216,12 @@ def test_lockout_per_user(base=BASE):
|
||||
|
||||
# --- helpers ---
|
||||
|
||||
import os # noqa: E402
|
||||
|
||||
|
||||
def _json(method, path, jar=None, base=BASE):
|
||||
r = urllib.request.Request(base + path, method=method)
|
||||
r = urllib.request.Request(base + path, method=method,
|
||||
headers={"X-Aniweb": "1"})
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) if jar else urllib.request
|
||||
try:
|
||||
resp = opener.open(r)
|
||||
|
||||
@@ -120,7 +120,7 @@ async def _history_page_and_delete():
|
||||
"lang": "eng", "ep": n, "pos": 5, "dur": 600}
|
||||
op.open(urllib.request.Request(BASE + "/api/history",
|
||||
data=json.dumps(e).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
@@ -190,7 +190,7 @@ async def _recent_searches():
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
op.open(urllib.request.Request(BASE + "/api/searches",
|
||||
data=json.dumps({"q": "chainsaw"}).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST"))
|
||||
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(args=LAUNCH_ARGS)
|
||||
@@ -268,7 +268,7 @@ async def _episode_watch_state():
|
||||
e1_id = await page.evaluate("document.querySelector('#eps .ep').dataset.ep")
|
||||
await page.evaluate(
|
||||
"(id) => fetch('api/history', {method: 'POST',"
|
||||
" headers: {'Content-Type': 'application/json'},"
|
||||
" headers: {'Content-Type': 'application/json', 'X-Aniweb': '1'},"
|
||||
" body: JSON.stringify({title: 'Chainsaw Man', slug_id: 'chainsaw-man-922',"
|
||||
" season_id: 922, ep_id: +id, lang: 'eng', ep: 1, pos: 2350, dur: 2400})})",
|
||||
e1_id)
|
||||
|
||||
Reference in new issue
Block a user