fix(auth): a tab acts only as the account it was built for, and the review's other findings

A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
2026-10-21 01:05:12 +02:00
parent 28b77fed1e
commit 94418a087e
39 changed files with 576 additions and 78 deletions
+25
View File
@@ -18,6 +18,7 @@ vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log";
import { claimLocalData } from "../storage";
import { downloadsApi } from "./downloads";
import { filesApi } from "./files";
import {
@@ -465,6 +466,30 @@ describe("the debug session a tab records into", () => {
});
});
// A tab built for one account must not act as another: a sign-in as someone else in another tab
// replaces the cookie under this one. Every request path names the account the tab was claimed for,
// so the server can refuse it — a beacon or an upload that went out without it would land on the
// account signed in now.
describe("the account a tab was built for", () => {
const sentHeaders = () => {
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
return new Headers(init.headers as HeadersInit);
};
it("rides every request path once the tab is claimed", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve(new Response("{}", { status: 200 })),
);
claimLocalData(7);
await req("/api/x");
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
beacon("/api/y", {});
expect(sentHeaders().get("X-Siftlode-Expect-Account")).toBe("7");
void uploadReq("/u", body());
expect(FakeXHR.last.headers["X-Siftlode-Expect-Account"]).toBe("7");
});
});
// The upload's wire shape is a security boundary, not a detail. A multipart part is a DECLARED
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
+17 -5
View File
@@ -8,6 +8,7 @@ import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
import { claimedAccount } from "../storage";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -270,13 +271,24 @@ export const setupHeaders = (token: string) => ({
// The debug session this tab is recording: the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
// The account this tab was built for. The cookie alone says who is asking, and a sign-in as someone
// else in another tab replaces it under this one too: the server refuses (401) a request whose tab
// shows another account than the one signed in, and the 401 reloads the tab as the one who is. It
// never picks an account — a player checkpoint or a pref save from a tab still showing the previous
// account would otherwise land on the new one.
const EXPECTED_ACCOUNT_HEADER = "X-Siftlode-Expect-Account";
/** What this request carries besides the session cookie (which alone says who is asking) — the
* debug session it records into — as the headers every request path sends (`req`, `beacon`,
* `uploadReq`): one definition, so a path cannot quietly go out without them. */
* account the tab was built for, the debug session it records into — as the headers every request
* path sends (`req`, `beacon`, `uploadReq`): one definition, so a path cannot quietly go out
* without them. */
export function identityHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const account = claimedAccount();
if (account) headers[EXPECTED_ACCOUNT_HEADER] = account;
const session = currentDebugSession();
return session ? { [DEBUG_SESSION_HEADER]: session } : {};
if (session) headers[DEBUG_SESSION_HEADER] = session;
return headers;
}
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
@@ -320,8 +332,8 @@ export async function req<T = unknown>(
credentials: "include",
headers: {
"Content-Type": "application/json",
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
// Who the tab is (only setup calls and raw uploads pass their own headers: setup is
// pre-auth, and an upload spreads `identityHeaders()` into its own).
...identityHeaders(),
},
...opts,
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// A tab names the account it was built for, and the server refuses a request from a tab showing
// another one than the cookie's. Spelled on both sides, so nothing but this test notices a rename:
// the server would see no header, and a tab left showing the previous account would act as the new
// one without a word.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf-8");
const AUTH = read("../../../../backend/app/auth.py");
const CORE = read("./core.ts");
describe("the account a tab was built for", () => {
it("is the header the server checks", () => {
const backend = /^EXPECTED_ACCOUNT_HEADER = "([^"]+)"$/m.exec(AUTH)?.[1];
const frontend = /^const EXPECTED_ACCOUNT_HEADER = "([^"]+)";$/m.exec(CORE)?.[1];
expect(backend).toBeTruthy();
expect(backend).toBe(frontend?.toLowerCase());
});
});
+18
View File
@@ -14,6 +14,8 @@
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted) ride the query string.
import type { NotifLevel } from "./notifications";
export const entrySearch = new URLSearchParams(window.location.search);
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
@@ -58,3 +60,19 @@ export function clearEntryUrl(): void {
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`,
);
}
/** What a sign-in as someone else that did not happen says to the account still signed in here. The
* callback answers a Google account that is not approved, or is suspended, without replacing the
* session, and with the flags the sign-in page reads; the signed-in app read none of them, so the
* person came back as before without a word. A request recorded on the way says so. */
export function refusedSwitchNotice(
search: URLSearchParams = entrySearch,
hash: URLSearchParams = entryHash,
): { level: NotifLevel; key: string } | null {
if (search.get("login") === "suspended")
return { level: "warning", key: "welcome.auth.suspended" };
const access = hash.get("access");
if (access === "requested") return { level: "info", key: "welcome.auth.accessRequested" };
if (access === "denied") return { level: "warning", key: "header.account.switchNotApproved" };
return null;
}
+16
View File
@@ -412,6 +412,22 @@ describe("the gate register (docs/gates.md)", () => {
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
}, 120_000);
// A spec left out of the webkit and ipad projects runs on one engine only.
it("lists every e2e spec that runs on one engine only", () => {
const cfg = read("frontend/playwright.config.ts");
const alternatives = /const CHROMIUM_ONLY = \/\(([^)]+)\)\\.spec\\.ts\//.exec(cfg)?.[1];
expect(alternatives, "CHROMIUM_ONLY").toBeTruthy();
const found = alternatives!
.split("|")
.map((name): [string, string] => ["e2e", `${name}.spec.ts`]);
expect(missingFrom(found)).toEqual([]);
// Any other way of leaving a spec out of a project would be a narrowing this does not read.
expect([...cfg.matchAll(/testIgnore:\s*([^,\n]+)/g)].map((m) => m[1])).toEqual([
"CHROMIUM_ONLY",
"CHROMIUM_ONLY",
]);
});
it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => {
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0");
+18 -2
View File
@@ -33,7 +33,7 @@ describe("claiming the browser's local data", () => {
expect(keys(localStorage)).toEqual(
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
);
expect(keys(sessionStorage)).toEqual(["siftlode.settingsTab"]);
expect(keys(sessionStorage)).toEqual(["siftlode.owner", "siftlode.settingsTab"]);
});
it("an old per-account copy wins over a plain key already there", async () => {
@@ -82,7 +82,23 @@ describe("claiming the browser's local data", () => {
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
);
expect(localStorage.getItem("siftlode.owner")).toBe("1");
expect(keys(sessionStorage)).toEqual([]);
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
});
it("a tab reloading for the account another tab claimed the browser for drops its own view state", async () => {
// Tab 2 signed in as 7 and claimed the browser; this tab still holds 1's sub-tab and the
// onboarding flow 1 was in. Its sessionStorage is its own, so the browser's owner says nothing
// about it.
localStorage.setItem("siftlode.owner", "7");
sessionStorage.setItem("siftlode.owner", "1");
sessionStorage.setItem("siftlode.settingsTab", "plex");
sessionStorage.setItem("siftlode.onboarding.active", "1");
const { claimLocalData } = await storage();
claimLocalData(7);
expect(keys(sessionStorage)).toEqual(["siftlode.owner"]);
expect(sessionStorage.getItem("siftlode.owner")).toBe("7");
});
it("the same account starting again keeps everything", async () => {
+4 -2
View File
@@ -15,8 +15,10 @@
// addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the
// call site asking `enabled(level)` before it builds the attribute.
//
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
// the error boundary all log through here, so it must not import any of them.
// Nearly dependency-free on purpose (the build stamp and the storage key registry, which import
// nothing of the app at runtime): the request layer, the notification store and the error boundary
// all log through here, so it must not import any of them, not even through another module
// (logImports.contract.test.ts follows the runtime imports).
import { LS } from "./storage";
import { FRONTEND_VERSION } from "./version";
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The request layer, the notification store and the error boundary all log through lib/log.ts, so
// log.ts must not reach any of them at runtime: the import would be a cycle, and the module that
// logs a failure would load the one that failed. A type-only import is erased, so it does not count.
// The comment in log.ts said this for a long time while nothing checked it.
const LIB = new URL("./", import.meta.url);
const ALLOWED = ["log.ts", "storage.ts", "version.ts"];
/** The app modules `file` imports at runtime (relative, not `import type`), as lib/ file names. */
function runtimeImports(file: string): string[] {
const text = readFileSync(new URL(file, LIB), "utf-8");
return [...text.matchAll(/^import\s+(?!type\b)[^;]*?from\s+"(\.[^"]+)";/gms)].map(
(m) => `${m[1]!.replace(/^\.\//, "")}.ts`,
);
}
describe("lib/log.ts", () => {
it("reaches only the build stamp and the key registry at runtime", () => {
const seen = new Set<string>();
const queue = ["log.ts"];
while (queue.length) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
if (ALLOWED.includes(file)) queue.push(...runtimeImports(file));
}
expect([...seen].sort()).toEqual([...ALLOWED].sort());
});
});
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// "Sign in as someone else" with a Google account the server will not sign in (not approved, or
// suspended) comes back to the account still signed in here, with the flags the sign-in page reads.
// The signed-in app read none of them, so the person was back as before without a word.
import { describe, expect, it } from "vitest";
import { refusedSwitchNotice } from "./entryUrl";
const notice = (search: string, hash = "") =>
refusedSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
describe("a sign-in as someone else the server refused", () => {
it("says a request was recorded for an address not approved yet", () => {
expect(notice("", "access=requested&email=b%40example.com")).toEqual({
level: "info",
key: "welcome.auth.accessRequested",
});
});
it("says the account is not approved, and nothing changed here", () => {
expect(notice("", "access=denied")).toEqual({
level: "warning",
key: "header.account.switchNotApproved",
});
});
it("says the account is suspended", () => {
expect(notice("?login=suspended")).toEqual({
level: "warning",
key: "welcome.auth.suspended",
});
});
it("says nothing on an ordinary load", () => {
expect(notice("?page=stats", "")).toBeNull();
});
});
@@ -53,7 +53,9 @@ describe("removed features stay removed", () => {
// One account per browser session (2026-10-20): the per-tab account pin, the header and `?account=`
// that carried it, and the account list and switch went. A request naming an account would now
// be ignored by the server, so a pin left behind reads as working while it does nothing. Only the
// claim that clears the old pin keys (storage.ts) and its test may name them.
// claim that clears the old pin keys (storage.ts) and its test may name them. (The tab's
// X-Siftlode-Expect-Account is not a pin: it never picks an account, the server refuses a request
// from a tab showing another one than the cookie's.)
it("leaves no per-tab account pin, header or switch", { timeout: 30_000 }, () => {
const PIN =
/X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i;
+2 -1
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LS } from "./storage";
const fire = (): Event => {
const e = new Event("vite:preloadError", { cancelable: true });
@@ -32,7 +33,7 @@ describe("a lazy chunk the server no longer has", () => {
});
it("lets the error through when the reload a moment ago did not help", async () => {
sessionStorage.setItem("sf.staleBuildReload", String(Date.now() - 5_000));
sessionStorage.setItem(LS.staleBuildReload, String(Date.now() - 5_000));
const { installStaleBuildReload, reloadingForNewBuild } = await import("./staleBuild");
const reload = vi.fn();
uninstall = installStaleBuildReload(reload);
+2 -1
View File
@@ -8,8 +8,9 @@
// minute at most: a chunk that is missing from the NEW build too would otherwise reload forever, so
// the second failure inside the minute is let through to the error boundary, which says so.
import { log } from "./log";
import { LS } from "./storage";
const KEY = "sf.staleBuildReload";
const KEY = LS.staleBuildReload;
const WINDOW_MS = 60_000;
let reloading = false;
+27 -10
View File
@@ -3,8 +3,11 @@ import { useCallback, useState } from "react";
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind
// (storageKinds.contract.test.ts).
// declares its kind: what it is decides where it belongs. storageKinds.contract.test.ts holds what a
// reading of the source can: no key is spelled out (or handed to the Storage API as a literal)
// anywhere else, a `pref` is read and written only through usePref or the one store listed as
// keeping it, and usePref is handed a `pref` and nothing else. That a kind fits what the code does
// with its key is the reviewer's to check.
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
// follows the account to every device — usePref, whose local copy only carries a value
// this browser held before the server did.
@@ -17,11 +20,12 @@ export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
const KEYS = {
theme: { key: "siftlode.theme", kind: "pref" },
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
// A side panel's group cards: their order, collapsed and hidden. Synced by lib/panelLayout.ts.
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "pref" },
plexLayout: { key: "siftlode.plexLayout", kind: "pref" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "pref" },
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
filesLayout: { key: "siftlode.filesLayout", kind: "pref" },
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
// The Files view last open (every file, shared with me, or a folder id), per-account.
filesView: { key: "siftlode.filesView", kind: "view" },
@@ -107,10 +111,12 @@ const KEYS = {
// reason as above: the viewer has no account to keep it in.
watchResume: { key: "siftlode.watchResume", kind: "public" },
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id.
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id, and
// when the tab last reloaded onto a newer build (`lib/staleBuild.ts`).
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
logTab: { key: "siftlode.logTab", kind: "device" },
staleBuildReload: { key: "siftlode.staleBuildReload", kind: "device" },
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
/** Every key the app stores, by name. */
@@ -178,12 +184,16 @@ function settleStore(store: Storage, id: string, wipe: boolean): void {
export function claimLocalData(id: number): void {
const me = String(id);
try {
// Each store records whose it is: a tab's sessionStorage is its own, so a tab reloading for the
// account another tab already claimed the browser for still holds the previous account's. A
// tab that records no owner (one a build before this opened) goes by the browser's.
const owner = localStorage.getItem(OWNER_KEY);
const wipe = owner != null && owner !== me;
settleStore(localStorage, me, wipe);
settleStore(sessionStorage, me, wipe);
const tabOwner = sessionStorage.getItem(OWNER_KEY) ?? owner;
settleStore(localStorage, me, owner != null && owner !== me);
settleStore(sessionStorage, me, tabOwner != null && tabOwner !== me);
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
localStorage.setItem(OWNER_KEY, me);
sessionStorage.setItem(OWNER_KEY, me);
} catch {
/* storage unavailable: nothing to settle */
}
@@ -191,6 +201,13 @@ export function claimLocalData(id: number): void {
claimListeners.forEach((reread) => reread());
}
/** The account this tab was built for (claimed at boot), as its id; null before the claim. Every
* request says so (`identityHeaders`): the server refuses one from a tab whose browser has since
* signed in as someone else, and the tab reloads as whoever is signed in now. */
export function claimedAccount(): string | null {
return claimed;
}
/** `base` while this tab's account owns the browser's local state, else null: before the claim, and
* after another tab has claimed it for a different account (a sign-in there replaces this one). */
export function ownKey(base: string): string | null {
+38 -4
View File
@@ -25,22 +25,28 @@ function files(dir: string): string[] {
}
const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") }));
// Preferences the Settings page owns (PrefsProvider saves them to the server) whose local copy is
// kept by the store that applies them: it paints the first frame, and for the language the
// signed-out pages too, before the account is known.
// Preferences whose local copy is kept by the store that applies them. The Settings page's
// (PrefsProvider saves them to the server): the store paints the first frame, and for the language
// the signed-out pages too, before the account is known. The side panels' group cards: App seeds
// them from the account and saves every change to it, through lib/panelLayout.ts.
const SYNCED_BY_STORE: Record<string, string> = {
theme: "lib/theme.ts",
hints: "lib/hints.ts",
notifSettings: "lib/notifications.ts",
lang: "i18n/index.ts",
sidebarLayout: "lib/panelLayout.ts",
plexLayout: "lib/panelLayout.ts",
playlistsLayout: "lib/panelLayout.ts",
filesLayout: "lib/panelLayout.ts",
};
describe("storage kinds", () => {
it("spells out no key outside the registry", () => {
// The debug recorder's BroadcastChannel is a channel name, not a stored key.
const allowed = new Set(['"siftlode.debugSession"']);
// Any literal that starts the namespace, so a key built by concatenation or a template counts.
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
[...text.matchAll(/["'`]siftlode\.[\w.]+["'`]/g)]
[...text.matchAll(/["'`]siftlode\.[^"'`]*["'`]?/g)]
.map((m) => m[0])
.filter((lit) => !allowed.has(lit))
.map((lit) => `${f}: ${lit}`),
@@ -48,6 +54,23 @@ describe("storage kinds", () => {
expect(hits).toEqual([]);
});
it("hands the Storage API no key of its own", () => {
// A key outside the namespace (`"sf.x"`) would slip past the scan above and the claim's wipe:
// a literal handed to the Storage API, or a constant holding one.
const call = /(?:localStorage|sessionStorage)\.(?:getItem|setItem|removeItem)\(\s*([^,)]+)/g;
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
[...text.matchAll(call)]
.map((m) => m[1]!.trim())
.filter(
(arg) =>
/^["'`]/.test(arg) ||
new RegExp(`\\b(?:const|let)\\s+${arg}\\s*=\\s*["'\`]`).test(text),
)
.map((arg) => `${f}: ${arg}`),
);
expect(hits).toEqual([]);
});
it("keeps an account preference on the server: its local copy only through usePref", () => {
const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref");
expect(prefs.length).toBeGreaterThan(10);
@@ -63,6 +86,17 @@ describe("storage kinds", () => {
expect(hits).toEqual([]);
});
// usePref saves what it holds to the account: a device, view or cache key handed to it would follow
// the account to every device.
it("hands usePref a preference and nothing else", () => {
const hits = SOURCES.flatMap(({ f, text }) =>
[...text.matchAll(/\blocalKey:\s*LS\.(\w+)/g)]
.filter((m) => KIND_OF.get(LS[m[1] as keyof typeof LS]) !== "pref")
.map((m) => `${f}: LS.${m[1]}`),
);
expect(hits).toEqual([]);
});
it("lists only preferences as kept by their store", () => {
for (const name of Object.keys(SYNCED_BY_STORE))
expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref");