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());
});
});