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 -17
View File
@@ -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();