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
+25
-4
@@ -11,7 +11,7 @@ import time
|
||||
from pathlib import Path
|
||||
from base64 import urlsafe_b64encode, urlsafe_b64decode
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import quote, urljoin
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
||||
@@ -57,6 +57,19 @@ def _lock(user: str, pw: str) -> str:
|
||||
return "bad"
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_header(request, call_next):
|
||||
# ponytail: a cross-site form POST (top-level navigation) does carry the
|
||||
# SameSite=Lax cookie, so <form action=.../account/delete> 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":
|
||||
if request.headers.get("x-aniweb") != "1":
|
||||
return JSONResponse({"detail": "forbidden"}, status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_auth(request, call_next):
|
||||
if (request.url.path, request.method) in _PUBLIC:
|
||||
@@ -140,8 +153,9 @@ def _load_hist(user: str):
|
||||
|
||||
def _save_hist(user: str, entries):
|
||||
HIST_DIR.mkdir(exist_ok=True)
|
||||
# ponytail: unguarded read-modify-write per file, fine at this scale
|
||||
(HIST_DIR / f"{user}.json").write_text(json.dumps(entries, indent=1))
|
||||
# ponytail: unguarded read-modify-write per file, fine at this scale;
|
||||
# atomic_write keeps a crash/race from corrupting the file
|
||||
users.atomic_write(HIST_DIR / f"{user}.json", json.dumps(entries, indent=1))
|
||||
|
||||
|
||||
@app.get("/api/history")
|
||||
@@ -202,7 +216,7 @@ def _load_searches(user: str):
|
||||
|
||||
def _save_searches(user: str, items):
|
||||
SEARCH_DIR.mkdir(exist_ok=True)
|
||||
(SEARCH_DIR / f"{user}.json").write_text(json.dumps(items, indent=1))
|
||||
users.atomic_write(SEARCH_DIR / f"{user}.json", json.dumps(items, indent=1))
|
||||
|
||||
|
||||
@app.get("/api/searches")
|
||||
@@ -305,6 +319,13 @@ _STREAM_MAX = 24 # segments ~1-2MB -> <= ~48MB
|
||||
def stream(token: str):
|
||||
# ponytail: buffers whole response in memory; .ts segments are small
|
||||
url = _url(token)
|
||||
# ponytail: token is plain base64, so allowlist the host — otherwise any
|
||||
# logged-in user could use this as a GET proxy to arbitrary URLs (SSRF).
|
||||
# Verified 2026-08: master, variant and segment URLs all live on
|
||||
# hls.anidb.app; if the CDN moves, this 400s loudly and the list updates.
|
||||
p = urlparse(url)
|
||||
if p.scheme != "https" or p.netloc != "hls.anidb.app":
|
||||
raise HTTPException(400, "bad stream token")
|
||||
hit = _STREAM_CACHE.get(url)
|
||||
if hit:
|
||||
_STREAM_CACHE.move_to_end(url)
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ def _master_variants(master_url):
|
||||
# cached public API (TTLs in seconds)
|
||||
search = lambda q: _cached(f"search:{q.lower()}", 300, _search, q)
|
||||
details = lambda s: _cached(f"details:{s}", 3600, _details, s)
|
||||
episodes = lambda i: _cached(f"episodes:{i}", 3600, _episodes, i)
|
||||
episodes = lambda i: _cached(f"episodes:{i}", 300, _episodes, i) # 5m: airing shows gain eps
|
||||
languages = lambda e: _cached(f"languages:{e}", 3600, _languages, e)
|
||||
master_m3u8 = lambda e, l="eng": _cached(f"master:{e}:{l}", 3600, _master_m3u8, e, l)
|
||||
master_variants = lambda u: _cached(f"variants:{u}", 3600, _master_variants, u)
|
||||
|
||||
+13
-4
@@ -51,12 +51,20 @@ def load() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
"""tmp + os.replace: a crash mid-write leaves the previous file intact
|
||||
(in-place writes corrupted the file -> load() returned {} -> accounts
|
||||
or history silently vanished)."""
|
||||
tmp = path.parent / (path.name + ".tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def save(users: dict):
|
||||
DATA.mkdir(exist_ok=True)
|
||||
# ponytail: mode 0600 on create; won't tighten an existing file
|
||||
fd = os.open(USERS, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(users, f, indent=1)
|
||||
atomic_write(USERS, json.dumps(users, indent=1))
|
||||
|
||||
|
||||
def add(username: str, password: str):
|
||||
@@ -90,6 +98,7 @@ def remove(username: str):
|
||||
users.pop(u, None)
|
||||
save(users)
|
||||
(DATA / "history" / f"{u}.json").unlink(missing_ok=True)
|
||||
(DATA / "searches" / f"{u}.json").unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user