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

+2
View File
@@ -4,3 +4,5 @@ __pycache__/
*.tar.gz *.tar.gz
server/data/ server/data/
server.log server.log
pi-session-*.html
.pytest_cache/
+17
View File
@@ -0,0 +1,17 @@
# Supervised replacement for run.sh's nohup (crash -> auto-restart).
# Install (then stop the run.sh instance: pkill -f 'uvicorn .*app:app'):
# sudo ln -s /home/jozamudi/code/ani-cli-front-end/ani-web.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now ani-web
[Unit]
Description=ani-web (FastAPI anime front-end)
After=network-online.target
[Service]
WorkingDirectory=/home/jozamudi/code/ani-cli-front-end
Environment=ANIWEB_SECURE=1
ExecStart=/home/jozamudi/code/ani-cli-front-end/.venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
+25 -4
View File
@@ -11,7 +11,7 @@ import time
from pathlib import Path from pathlib import Path
from base64 import urlsafe_b64encode, urlsafe_b64decode from base64 import urlsafe_b64encode, urlsafe_b64decode
from collections import OrderedDict 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 import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
@@ -57,6 +57,19 @@ def _lock(user: str, pw: str) -> str:
return "bad" 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") @app.middleware("http")
async def require_auth(request, call_next): async def require_auth(request, call_next):
if (request.url.path, request.method) in _PUBLIC: if (request.url.path, request.method) in _PUBLIC:
@@ -140,8 +153,9 @@ def _load_hist(user: str):
def _save_hist(user: str, entries): def _save_hist(user: str, entries):
HIST_DIR.mkdir(exist_ok=True) HIST_DIR.mkdir(exist_ok=True)
# ponytail: unguarded read-modify-write per file, fine at this scale # ponytail: unguarded read-modify-write per file, fine at this scale;
(HIST_DIR / f"{user}.json").write_text(json.dumps(entries, indent=1)) # 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") @app.get("/api/history")
@@ -202,7 +216,7 @@ def _load_searches(user: str):
def _save_searches(user: str, items): def _save_searches(user: str, items):
SEARCH_DIR.mkdir(exist_ok=True) 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") @app.get("/api/searches")
@@ -305,6 +319,13 @@ _STREAM_MAX = 24 # segments ~1-2MB -> <= ~48MB
def stream(token: str): def stream(token: str):
# ponytail: buffers whole response in memory; .ts segments are small # ponytail: buffers whole response in memory; .ts segments are small
url = _url(token) 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) hit = _STREAM_CACHE.get(url)
if hit: if hit:
_STREAM_CACHE.move_to_end(url) _STREAM_CACHE.move_to_end(url)
+1 -1
View File
@@ -131,7 +131,7 @@ def _master_variants(master_url):
# cached public API (TTLs in seconds) # cached public API (TTLs in seconds)
search = lambda q: _cached(f"search:{q.lower()}", 300, _search, q) search = lambda q: _cached(f"search:{q.lower()}", 300, _search, q)
details = lambda s: _cached(f"details:{s}", 3600, _details, s) 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) 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_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) master_variants = lambda u: _cached(f"variants:{u}", 3600, _master_variants, u)
+13 -4
View File
@@ -51,12 +51,20 @@ def load() -> dict:
return {} 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): def save(users: dict):
DATA.mkdir(exist_ok=True) DATA.mkdir(exist_ok=True)
# ponytail: mode 0600 on create; won't tighten an existing file atomic_write(USERS, json.dumps(users, indent=1))
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)
def add(username: str, password: str): def add(username: str, password: str):
@@ -90,6 +98,7 @@ def remove(username: str):
users.pop(u, None) users.pop(u, None)
save(users) save(users)
(DATA / "history" / f"{u}.json").unlink(missing_ok=True) (DATA / "history" / f"{u}.json").unlink(missing_ok=True)
(DATA / "searches" / f"{u}.json").unlink(missing_ok=True)
if __name__ == "__main__": if __name__ == "__main__":
+25 -17
View File
@@ -9,7 +9,7 @@ const main = document.getElementById("main");
let hls = null, detail = null, lang = "jpn", seasonId = null; let hls = null, detail = null, lang = "jpn", seasonId = null;
async function api(path) { async function api(path) {
const r = await fetch(BASE + path.replace(/^\//, "")); const r = await fetch(BASE + path.replace(/^\//, ""), {headers: {"X-Aniweb": "1"}});
if (r.status === 401) { if (r.status === 401) {
location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search); location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("not authenticated"); throw new Error("not authenticated");
@@ -56,10 +56,11 @@ function showStreamError(msg) {
// ---------- history (server-persisted, incl. watch position) ---------- // ---------- history (server-persisted, incl. watch position) ----------
let history = []; let history = [];
async function refreshHistory() { async function refreshHistory() {
try { history = await api("/api/history"); } catch { /* home just shows empty */ } // ?all=1: resume positions live beyond the top-5 cap
try { history = await api("/api/history?all=1"); } catch { /* home just shows empty */ }
} }
function postHistory(entry) { function postHistory(entry) {
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json"}, fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(entry)}).catch(() => {}); body: JSON.stringify(entry)}).catch(() => {});
} }
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
@@ -139,7 +140,7 @@ function showHome() {
// ---------- search ---------- // ---------- search ----------
function saveSearch(q) { function saveSearch(q) {
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json"}, fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify({q})}).catch(() => {}); body: JSON.stringify({q})}).catch(() => {});
} }
@@ -313,7 +314,8 @@ function histCard(x) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) + await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE"}); "&ep_id=" + encodeURIComponent(x.ep_id),
{method: "DELETE", headers: {"X-Aniweb": "1"}});
toast("Removed from history"); toast("Removed from history");
await showHistory(); await showHistory();
}; };
@@ -477,18 +479,24 @@ async function play(ep_id, ep_no) {
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}), const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
poster: detail ? (detail.poster || null) : null}); poster: detail ? (detail.poster || null) : null});
function watchPos(v) { // one savePos covers all video elements: it reads currentEp, so stale
const save = () => { // closures from earlier plays can't write the wrong episode's position
if (!currentEp || v.currentTime <= 3) return; function savePos() {
lastSavedPos = v.currentTime; if (!currentEp || currentEp.v.currentTime <= 3) return;
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, lastSavedPos = currentEp.v.currentTime;
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime}); postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
}; ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime});
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); });
v.addEventListener("pause", save);
window.addEventListener("pagehide", save);
} }
function watchPos(v) {
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) savePos(); });
v.addEventListener("pause", savePos);
// pagehide is bound once at boot (per-play listeners accumulated and fired
// with stale video elements)
}
window.addEventListener("pagehide", savePos);
// ---------- boot ---------- // ---------- boot ----------
(async () => { (async () => {
document.querySelector(".logo").href = BASE; document.querySelector(".logo").href = BASE;
@@ -498,7 +506,7 @@ function watchPos(v) {
initRecent(); initRecent();
const lo = document.getElementById("logout"); const lo = document.getElementById("logout");
lo.onclick = async () => { lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1"; await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
}; };
const mb = document.getElementById("menubox"); const mb = document.getElementById("menubox");
mb.style.display = ""; mb.style.display = "";
@@ -510,7 +518,7 @@ function watchPos(v) {
const da = document.getElementById("delacct"); const da = document.getElementById("delacct");
da.onclick = async () => { da.onclick = async () => {
if (!confirm("Delete your account and watch history?")) return; if (!confirm("Delete your account and watch history?")) return;
await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login?deleted=1"; await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
}; };
} catch { /* not logged in: api() already redirected to /login */ } } catch { /* not logged in: api() already redirected to /login */ }
await refreshHistory(); await refreshHistory();
+36 -3
View File
@@ -25,7 +25,7 @@ def no_redirect(method, path, data=None, base=BASE):
r = urllib.request.Request(base + path, r = urllib.request.Request(base + path,
data=json.dumps(data).encode() if data is not None else None, data=json.dumps(data).encode() if data is not None else None,
method=method, method=method,
headers={"Content-Type": "application/json"}) headers={"Content-Type": "application/json", "X-Aniweb": "1"})
try: try:
resp = opener.open(r) resp = opener.open(r)
return resp.getcode(), resp.headers 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): def req(method, path, data=None, jar=None, base=BASE):
r = urllib.request.Request(base + path, r = urllib.request.Request(base + path,
data=json.dumps(data).encode() if data is not None else None, 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: if jar is not None:
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
try: try:
@@ -168,14 +169,42 @@ def test_recent_searches(base=BASE):
def test_self_delete(base=BASE): def test_self_delete(base=BASE):
jar = login("dave", "davepw12345", 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 req("POST", "/account/delete", jar=jar, base=base) == 200
assert "dave" not in json.load(open(_users_file())) 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) # old session cookie is dead immediately (middleware re-checks existence)
assert req("GET", "/api/me", jar=jar, base=base) == 401 assert req("GET", "/api/me", jar=jar, base=base) == 401
jar2 = CJ() jar2 = CJ()
assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401 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): def test_lockout_per_user(base=BASE):
# must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob, # must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob,
# admin unaffected # admin unaffected
@@ -187,8 +216,12 @@ def test_lockout_per_user(base=BASE):
# --- helpers --- # --- helpers ---
import os # noqa: E402
def _json(method, path, jar=None, base=BASE): 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 opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) if jar else urllib.request
try: try:
resp = opener.open(r) resp = opener.open(r)
+3 -3
View File
@@ -120,7 +120,7 @@ async def _history_page_and_delete():
"lang": "eng", "ep": n, "pos": 5, "dur": 600} "lang": "eng", "ep": n, "pos": 5, "dur": 600}
op.open(urllib.request.Request(BASE + "/api/history", op.open(urllib.request.Request(BASE + "/api/history",
data=json.dumps(e).encode(), 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: async with async_playwright() as p:
browser = await p.chromium.launch(args=LAUNCH_ARGS) browser = await p.chromium.launch(args=LAUNCH_ARGS)
@@ -190,7 +190,7 @@ async def _recent_searches():
headers={"Content-Type": "application/json"}, method="POST")) headers={"Content-Type": "application/json"}, 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"}, method="POST")) headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
async with async_playwright() as p: async with async_playwright() as p:
browser = await p.chromium.launch(args=LAUNCH_ARGS) 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") e1_id = await page.evaluate("document.querySelector('#eps .ep').dataset.ep")
await page.evaluate( await page.evaluate(
"(id) => fetch('api/history', {method: 'POST'," "(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'," " 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})})", " season_id: 922, ep_id: +id, lang: 'eng', ep: 1, pos: 2350, dur: 2400})})",
e1_id) e1_id)