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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user