fix(security): enforce audited account, network and resource boundaries

Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
cesnimda
2026-09-09 18:21:47 +02:00
parent 7d641edc7b
commit 0954876295
116 changed files with 10101 additions and 122 deletions
+22 -4
View File
@@ -1,7 +1,8 @@
import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState } from "react";
import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Box, Button, IconButton, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { isCancel } from "axios";
import DashboardIcon from "@mui/icons-material/Dashboard";
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
@@ -42,6 +43,7 @@ import { AccountPlanProvider } from "./accountPlan";
import AppShell, { NavItem } from "./layout/AppShell";
import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs";
import JobTrackerThemeProvider from "./ThemeProvider";
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
@@ -209,8 +211,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
})
.catch(() => {
if (!active) return;
.catch((error) => {
if (!active || isCancel(error)) return;
setMe(null);
setIsAdmin(false);
clearAuthClientState(false);
@@ -252,7 +254,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
})
.catch(() => {
.catch((error) => {
if (isCancel(error)) return;
setMe(null);
setIsAdmin(false);
clearAuthClientState(false);
@@ -422,7 +425,22 @@ function ThemeAwareShell(props: Omit<React.ComponentProps<typeof Shell>, "themeM
return <Shell {...props} {...controls} />;
}
function subscribeSessionBoundary(onChange: () => void) {
window.addEventListener(SESSION_INVALIDATED_EVENT, onChange);
return () => window.removeEventListener(SESSION_INVALIDATED_EVENT, onChange);
}
export default function App() {
const invalidated = useSyncExternalStore(subscribeSessionBoundary, isSessionInvalidated, () => false);
useEffect(() => {
if (invalidated) window.location.replace("/login?sessionChanged=1");
}, [invalidated]);
// Unmount providers as well as routes: confirmations/toasts can retain private text and
// callbacks. Passive effect navigation runs after the old forms' unload guards are removed.
return invalidated ? <main role="alert">Session changed. Clearing this page…</main> : <Application />;
}
function Application() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
+38 -3
View File
@@ -1,5 +1,6 @@
import axios from "axios";
import axios, { CanceledError, type InternalAxiosRequestConfig } from "axios";
import { clearAuthClientState, getCsrfToken } from "./auth";
import { bindSession, checkSessionCookie, getSessionContext, invalidateSession, isSessionInvalidated, sessionRequestSignal } from "./sessionBoundary";
function looksLikeHtml(value: string) {
return /<\s*html\b|<\s*body\b|<\s*head\b|<\s*title\b|<\s*!doctype\b/i.test(value);
@@ -48,7 +49,19 @@ export const api = axios.create({
xsrfHeaderName: "X-CSRF-TOKEN",
});
const requestCookies = new WeakMap<InternalAxiosRequestConfig, string | null>();
api.interceptors.request.use((config) => {
checkSessionCookie();
if (isSessionInvalidated()) throw new CanceledError("This page belongs to an earlier session. Reload it before continuing.");
const context = getSessionContext();
const retainedContext = config.headers.get("X-Session-Context");
if (retainedContext && retainedContext !== context) throw new CanceledError("The request belongs to an earlier session.");
if (context) config.headers.set("X-Session-Context", context);
requestCookies.set(config, getCsrfToken());
config.signal = config.signal instanceof AbortSignal
? AbortSignal.any([config.signal, sessionRequestSignal()])
: sessionRequestSignal();
const method = (config.method ?? "get").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrfToken = getCsrfToken();
@@ -58,13 +71,35 @@ api.interceptors.request.use((config) => {
}
}
return config;
});
}, (error) => { throw error; }, { synchronous: true });
api.interceptors.response.use(
(r) => r,
(r) => {
checkSessionCookie();
if (isSessionInvalidated()) throw new CanceledError("Discarded a response from an earlier session.");
if ((r.config.method ?? "get").toLowerCase() === "get" && requestCookies.get(r.config) !== getCsrfToken()) {
throw new CanceledError("Discarded a read started before the session changed.");
}
if (r.config.url === "/auth/me") {
if (!bindSession(r.data?.id, r.data?.sessionContext)) {
invalidateSession();
throw new CanceledError("The session changed while loading this page.");
}
}
return r;
},
(err) => {
checkSessionCookie();
if (isSessionInvalidated()) return Promise.reject(new CanceledError("Discarded an earlier session's response."));
if (err?.config && requestCookies.get(err.config) !== getCsrfToken()) {
return Promise.reject(new CanceledError("Discarded an earlier session's error."));
}
const status = err?.response?.status;
if ([409, 428].includes(status) && ["session_context_required", "session_context_mismatch"].includes(err?.response?.data?.code)) {
invalidateSession();
}
if (status === 401) {
if (getSessionContext()) invalidateSession();
clearAuthClientState();
}
return Promise.reject(err);
@@ -13,8 +13,8 @@ type ImportJob = Omit<JobApplication, "id" | "company" | "savedAt"> & {
company: Pick<Company, "name" | "location" | "source">;
};
function downloadText(filename: string, text: string, mime: string) {
const blob = new Blob([text], { type: mime });
function downloadFile(filename: string, content: BlobPart, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -186,8 +186,8 @@ export default function ImportExportJobs() {
const url = `/export/jobs?format=${format}&includeDeleted=false`;
const res = await api.get(url, { responseType: "blob" });
const text = await res.data.text();
downloadText(`job-tracker-export-${stamp}.${format}`, text, format === "json" ? "application/json" : "text/csv");
// Keep the CSV's UTF-8 BOM: decoding through Blob.text() would discard it.
downloadFile(`job-tracker-export-${stamp}.${format}`, res.data, format === "json" ? "application/json" : "text/csv;charset=utf-8");
toast(t("exportedJobs", { format: format.toUpperCase() }), "success");
} catch {
toast(t("exportFailed"), "error");
@@ -238,7 +238,7 @@ export default function ImportExportJobs() {
const text = await file.text();
const jobs = csvToImportJobs(text);
const stamp = new Date().toISOString().slice(0, 10);
downloadText(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json");
downloadFile(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json");
toast(t("convertedRows", { count: jobs.length }), "success");
} catch {
toast(t("csvConversionFailed"), "error");
+33
View File
@@ -0,0 +1,33 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ImportExportJobs from "./components/ImportExportJobs";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
import { api } from "./api";
const mockedApi = api as jest.Mocked<typeof api>;
const readBytes = (blob: Blob) => new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
reader.onerror = reject;
reader.readAsArrayBuffer(blob);
});
afterEach(() => jest.restoreAllMocks());
test.each(["csv", "json"])("%s download preserves server bytes and a generated safe filename", async (format) => {
const bytes = new TextEncoder().encode(format === "csv" ? "\ufeffCompany,JobTitle\r\nÆrlig,'=1+1\r\n" : '[{"jobTitle":"=1+1","company":"Ærlig"}]');
const incoming = new Blob([bytes]);
mockedApi.get.mockResolvedValue({ data: incoming });
const createUrl = jest.fn<string, [Blob]>(() => "blob:csv-export");
Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createUrl });
Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: jest.fn() });
let filename = "";
jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { filename = this.download; });
render(<ToastProvider><I18nProvider><ImportExportJobs /></I18nProvider></ToastProvider>);
fireEvent.click(screen.getByRole("button", { name: `Export ${format.toUpperCase()}` }));
await waitFor(() => expect(createUrl).toHaveBeenCalledTimes(1));
expect(mockedApi.get).toHaveBeenCalledWith(`/export/jobs?format=${format}&includeDeleted=false`, { responseType: "blob" });
expect(await readBytes(createUrl.mock.calls[0][0])).toEqual(bytes);
expect(filename).toMatch(new RegExp(`^job-tracker-export-\\d{4}-\\d{2}-\\d{2}\\.${format}$`));
});
@@ -53,6 +53,20 @@ beforeEach(() => {
afterEach(() => window.localStorage.removeItem('uiLanguage'));
test.each([
[503, 'PDF generation is busy. Please try again shortly.'],
[504, 'PDF generation took too long. Please try again.'],
])('PDF status %s shows an actionable message without retrying', async (status, message) => {
mockedApi.get.mockResolvedValueOnce({ data: [{ id: 1, name: 'Synthetic CV', themeId: 'modern', version: 1, updatedAtUtc: new Date().toISOString() }] } as any);
mockedApi.post.mockRejectedValueOnce({ response: { status, data: new Blob(['{}'], { type: 'application/problem+json' }) } });
renderPage();
await screen.findByText('Synthetic CV');
fireEvent.click(screen.getByRole('button', { name: 'Actions for Synthetic CV' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PDF' }));
expect(await screen.findByText(message)).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test('lists existing CVs from the variants API', async () => {
mockedApi.get.mockResolvedValueOnce({
data: [
+6
View File
@@ -695,6 +695,8 @@ export const translations = {
cvEditorLinkCopied: "Public link copied.",
cvEditorSaveBeforeExport: "Save the current CV before exporting it.",
cvEditorPdfFailed: "PDF export failed.",
cvPdfBusy: "PDF generation is busy. Please try again shortly.",
cvPdfTimedOut: "PDF generation took too long. Please try again.",
cvEditorSaveBeforeDuplicate: "Save the current CV before duplicating it.",
cvEditorCopyName: "{name} copy",
cvEditorUntitledCv: "Untitled CV",
@@ -2009,6 +2011,7 @@ export const translations = {
microsoft: "Microsoft",
createAccount: "Create account",
signedIn: "Signed in.",
sessionChangedCleared: "Your session changed. Private data and unsaved edits were cleared from this page. Sign in again to continue.",
rememberMe: "Remember me",
rememberMeHelpPersistent: "Keeps you signed in on this device until you sign out.",
rememberMeHelpSession: "Keeps you signed in only for this browser session.",
@@ -3130,6 +3133,8 @@ export const translations = {
cvEditorLinkCopied: "Offentlig lenke kopiert.",
cvEditorSaveBeforeExport: "Lagre CV-en før du eksporterer den.",
cvEditorPdfFailed: "PDF-eksport mislyktes.",
cvPdfBusy: "PDF-generering er opptatt. Prøv igjen om litt.",
cvPdfTimedOut: "PDF-genereringen tok for lang tid. Prøv igjen.",
cvEditorSaveBeforeDuplicate: "Lagre CV-en før du dupliserer den.",
cvEditorCopyName: "Kopi av {name}",
cvEditorUntitledCv: "CV uten tittel",
@@ -4444,6 +4449,7 @@ export const translations = {
microsoft: "Microsoft",
createAccount: "Opprett konto",
signedIn: "Logget inn.",
sessionChangedCleared: "Økten din ble endret. Private data og ulagrede endringer ble fjernet fra denne siden. Logg inn igjen for å fortsette.",
rememberMe: "Husk meg",
rememberMeHelpPersistent: "Holder deg innlogget på denne enheten til du logger ut.",
rememberMeHelpSession: "Holder deg innlogget bare i denne nettleserøkten.",
+154
View File
@@ -0,0 +1,154 @@
import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
type Harness = { api: typeof import("./api").api; boundary: typeof import("./sessionBoundary") };
let harness: Harness;
let listeners: Array<[string, EventListenerOrEventListenerObject]>;
let addListener: jest.SpyInstance;
function cookie(value: string) { document.cookie = `XSRF-TOKEN=${value}; path=/`; }
function response(config: InternalAxiosRequestConfig, data: unknown): AxiosResponse {
return { config, data, status: 200, statusText: "OK", headers: {} };
}
async function bindA() {
cookie("csrf-a");
harness.api.defaults.adapter = async config => response(config, { id: "user-a", sessionContext: "session-a" });
await harness.api.get("/auth/me");
}
beforeEach(() => {
jest.useFakeTimers();
window.localStorage.clear();
document.cookie = "XSRF-TOKEN=; max-age=0; path=/";
listeners = [];
const originalAdd = window.addEventListener.bind(window);
addListener = jest.spyOn(window, "addEventListener").mockImplementation((type, callback, options) => {
listeners.push([type, callback]);
originalAdd(type, callback, options);
});
jest.isolateModules(() => {
// setupTests keeps its API mock for UI tests; these tests explicitly exercise real Axios.
harness = { api: jest.requireActual("./api").api, boundary: jest.requireActual("./sessionBoundary") };
});
});
afterEach(() => {
for (const [type, callback] of listeners) window.removeEventListener(type, callback);
addListener.mockRestore();
jest.clearAllTimers();
jest.useRealTimers();
});
test("writes carry the page's server-issued context and preserve the same account", async () => {
await bindA();
const calls: InternalAxiosRequestConfig[] = [];
harness.api.defaults.adapter = async config => { calls.push(config); return response(config, {}); };
await harness.api.put("/career/profile", { profile: { contact: { fullName: "A edit" } } });
expect(calls).toHaveLength(1);
expect(calls[0].headers.get("X-Session-Context")).toBe("session-a");
expect(calls[0].headers.get("X-CSRF-TOKEN")).toBe("csrf-a");
expect(harness.boundary.getSessionContext()).toBe("session-a");
});
test("cookie switch rejects a stale callback before transport and never adopts B's context", async () => {
await bindA();
const adapter = jest.fn(async config => response(config, {}));
harness.api.defaults.adapter = adapter;
const queuedSave = () => harness.api.put("/career/profile", { profile: { contact: { fullName: "A retained draft" } } });
cookie("csrf-b");
await expect(queuedSave()).rejects.toMatchObject({ code: "ERR_CANCELED" });
expect(adapter).not.toHaveBeenCalled();
expect(harness.boundary.isSessionInvalidated()).toBe(true);
expect(harness.boundary.bindSession("user-b", "session-b")).toBe(false);
expect(harness.boundary.getSessionContext()).toBeNull();
});
test.each([
["authUserKey", "user-b"],
["authUserKey", null],
["authSessionContext", "new-session-for-same-user"],
[null, null],
])("cross-tab storage event %s invalidates and aborts outstanding requests", async (key, newValue) => {
await bindA();
window.dispatchEvent(new StorageEvent("storage", { key, newValue }));
expect(harness.boundary.isSessionInvalidated()).toBe(true);
expect(harness.boundary.sessionRequestSignal().aborted).toBe(true);
});
test("another session cannot be adopted even if the readable cookie signal is unchanged", async () => {
await bindA();
harness.api.defaults.adapter = async config => response(config, { id: "user-b", sessionContext: "session-b" });
await expect(harness.api.get("/auth/me")).rejects.toMatchObject({ code: "ERR_CANCELED" });
expect(harness.boundary.getSessionContext()).toBeNull();
});
test("same-account reauthentication also discards the old page", async () => {
await bindA();
expect(harness.boundary.bindSession("user-a", "session-a-new")).toBe(false);
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
test("a delayed private response cannot repopulate caches after invalidation", async () => {
await bindA();
let finish!: (value: AxiosResponse) => void;
let request!: InternalAxiosRequestConfig;
harness.api.defaults.adapter = config => { request = config; return new Promise(resolve => { finish = resolve; }); };
const read = harness.api.get("/companies");
cookie("csrf-b");
window.dispatchEvent(new Event("focus"));
finish(response(request, [{ id: 1, name: "Private A company" }]));
await expect(read).rejects.toMatchObject({ code: "ERR_CANCELED" });
});
test.each([false, true])("a late bootstrap read/error cannot overwrite or sign out a newly bound session (error=%s)", async failure => {
cookie("csrf-a");
let finish!: (value: AxiosResponse) => void;
let fail!: (reason: unknown) => void;
let oldRequest!: InternalAxiosRequestConfig;
harness.api.defaults.adapter = config => {
if (config.url === "/companies") {
oldRequest = config;
return new Promise((resolve, reject) => { finish = resolve; fail = reject; });
}
return Promise.resolve(response(config, { id: "user-b", sessionContext: "session-b" }));
};
const oldRead = harness.api.get("/companies");
cookie("csrf-b");
await harness.api.get("/auth/me");
if (failure) fail(new AxiosError("Expired old session", undefined, oldRequest, undefined,
{ ...response(oldRequest, {}), status: 401 }));
else finish(response(oldRequest, [{ name: "A private company" }]));
await expect(oldRead).rejects.toMatchObject({ code: "ERR_CANCELED" });
expect(harness.boundary.getSessionContext()).toBe("session-b");
expect(window.localStorage.getItem("authUserKey")).toBe("user-b");
expect(harness.boundary.isSessionInvalidated()).toBe(false);
});
test("server precondition failure clears the page without silently retrying a mutation", async () => {
await bindA();
const adapter = jest.fn(async (config: InternalAxiosRequestConfig) => {
throw new AxiosError("Session mismatch", undefined, config, undefined,
{ ...response(config, { code: "session_context_mismatch" }), status: 409 });
});
harness.api.defaults.adapter = adapter;
await expect(harness.api.post("/cv/variants", { name: "A draft" })).rejects.toBeDefined();
expect(adapter).toHaveBeenCalledTimes(1);
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
test("BFCache restoration invalidates old private state", async () => {
await bindA();
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
test("anonymous public-page history does not require a new login", () => {
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
expect(harness.boundary.isSessionInvalidated()).toBe(false);
});
test("cookie fallback still works without a cross-tab storage event", async () => {
await bindA();
cookie("csrf-b");
jest.advanceTimersByTime(1000);
expect(harness.boundary.isSessionInvalidated()).toBe(true);
});
+53
View File
@@ -0,0 +1,53 @@
import { getCsrfToken, setAuthUserKey } from "./auth";
export const SESSION_INVALIDATED_EVENT = "session-invalidated";
const SESSION_SIGNAL_KEY = "authSessionContext";
let bound: { id: string; context: string; csrf: string | null } | null = null;
let invalidated = false;
const requests = new AbortController();
export function isSessionInvalidated() { return invalidated; }
export function sessionRequestSignal() { return requests.signal; }
export function getSessionContext() { return invalidated ? null : bound?.context ?? null; }
export function invalidateSession() {
if (invalidated) return;
invalidated = true;
requests.abort();
window.dispatchEvent(new Event(SESSION_INVALIDATED_EVENT));
}
// A page never adopts a second session. Reloading disposes old forms, promises, caches,
// dialogs and retry callbacks together; none can obtain the next account's write context.
export function bindSession(id: string, context: string) {
if (invalidated) return false;
if (!id || !context || (bound && (bound.id !== id || bound.context !== context))) {
invalidateSession();
return false;
}
bound ??= { id, context, csrf: getCsrfToken() };
setAuthUserKey(id, false);
try { window.localStorage.setItem(SESSION_SIGNAL_KEY, context); } catch { /* Cookie/focus checks still apply. */ }
return true;
}
export function checkSessionCookie() {
if (bound && bound.csrf !== getCsrfToken()) invalidateSession();
}
window.addEventListener("storage", event => {
if (!bound) return;
if (event.key === null ||
(event.key === SESSION_SIGNAL_KEY && event.newValue !== bound.context) ||
(event.key === "authUserKey" && event.newValue !== bound.id)) invalidateSession();
});
window.addEventListener("auth-user-changed", checkSessionCookie);
window.addEventListener("focus", checkSessionCookie);
window.addEventListener("pageshow", event => {
// A BFCache snapshot can contain private state and outstanding callbacks from an old session.
if (event.persisted && bound) invalidateSession();
else checkSessionCookie();
});
window.addEventListener("visibilitychange", checkSessionCookie);
// Fallback when cookies change through another client without a storage event.
window.setInterval(checkSessionCookie, 1000);
+2 -1
View File
@@ -317,7 +317,8 @@ export default function CvBuilderEditor() {
a.click();
URL.revokeObjectURL(url);
} catch (err) {
toast(getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
const status = (err as { response?: { status?: number } })?.response?.status;
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
} finally {
setExporting(false);
}
+4 -1
View File
@@ -116,7 +116,10 @@ export default function CvBuilderPage() {
link.download = `${cvName || "cv"}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) { toast(getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error"); }
} catch (err) {
const status = (err as { response?: { status?: number } })?.response?.status;
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvDashboardDownloadFailed")), "error");
}
};
return (
+2
View File
@@ -150,6 +150,8 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
</Typography>
{new URLSearchParams(location.search).has("sessionChanged") ? <Alert severity="info" sx={{ mb: 2 }}>{t("sessionChangedCleared")}</Alert> : null}
{pendingToken ? (
<TwoFactorChallenge
pendingToken={pendingToken}