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)
|
||||
|
||||
Reference in new issue
Block a user