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
@@ -4,3 +4,5 @@ __pycache__/
|
||||
*.tar.gz
|
||||
server/data/
|
||||
server.log
|
||||
pi-session-*.html
|
||||
.pytest_cache/
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
+25
-17
@@ -9,7 +9,7 @@ const main = document.getElementById("main");
|
||||
let hls = null, detail = null, lang = "jpn", seasonId = null;
|
||||
|
||||
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) {
|
||||
location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search);
|
||||
throw new Error("not authenticated");
|
||||
@@ -56,10 +56,11 @@ function showStreamError(msg) {
|
||||
// ---------- history (server-persisted, incl. watch position) ----------
|
||||
let history = [];
|
||||
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) {
|
||||
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(() => {});
|
||||
}
|
||||
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 ----------
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
@@ -313,7 +314,8 @@ function histCard(x) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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");
|
||||
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} : {}),
|
||||
poster: detail ? (detail.poster || null) : null});
|
||||
|
||||
function watchPos(v) {
|
||||
const save = () => {
|
||||
if (!currentEp || v.currentTime <= 3) return;
|
||||
lastSavedPos = v.currentTime;
|
||||
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||||
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime});
|
||||
};
|
||||
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); });
|
||||
v.addEventListener("pause", save);
|
||||
window.addEventListener("pagehide", save);
|
||||
// one savePos covers all video elements: it reads currentEp, so stale
|
||||
// closures from earlier plays can't write the wrong episode's position
|
||||
function savePos() {
|
||||
if (!currentEp || currentEp.v.currentTime <= 3) return;
|
||||
lastSavedPos = currentEp.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});
|
||||
}
|
||||
|
||||
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 ----------
|
||||
(async () => {
|
||||
document.querySelector(".logo").href = BASE;
|
||||
@@ -498,7 +506,7 @@ function watchPos(v) {
|
||||
initRecent();
|
||||
const lo = document.getElementById("logout");
|
||||
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");
|
||||
mb.style.display = "";
|
||||
@@ -510,7 +518,7 @@ function watchPos(v) {
|
||||
const da = document.getElementById("delacct");
|
||||
da.onclick = async () => {
|
||||
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 */ }
|
||||
await refreshHistory();
|
||||
|
||||
+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