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:
jozamudi committed 2026-08-23 23:22:09 +00:00
1 parent d39ee838ff
commit aae5857dba
8 files changed
+122 -32

No files matched your search

+25 -4
View File
@@ -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)