fix(auth): paginate session maintenance safely

This commit is contained in:
2026-08-17 09:05:20 +02:00
parent 3e7bb11313
commit c86f01e886
12 changed files with 1021 additions and 79 deletions
+161 -27
View File
@@ -21,7 +21,13 @@ import type { Stats } from "node:fs";
import { dirname, isAbsolute, join, normalize } from "node:path";
import { z } from "zod";
import type { PrincipalContext } from "./principal.js";
import type { AuthSessionRecord, OidcStateRecord, Permission, Role } from "./types.js";
import type {
AuthSessionRecord,
OidcStateRecord,
OidcTransactionTransport,
Permission,
Role,
} from "./types.js";
import {
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
@@ -42,6 +48,9 @@ const OIDC_STATE_TTL_MS = 10 * 60 * 1000;
const OIDC_STATE_CAPACITY = 64;
const MAX_OIDC_STORAGE_ENTRIES = OIDC_STATE_CAPACITY * 3;
const MAX_SESSION_PRUNE_ENTRIES = 512;
// A separate scan ceiling bounds directory-walk CPU and the duplicate-detection set without
// reviving the former 512-record availability ceiling.
const MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES = 16_384;
const TOUCH_INTERVAL_MS = 5 * 60 * 1000;
const CSRF_CONTEXT = Buffer.from("thothii-csrf-v1", "utf8");
const EMPTY_HKDF_SALT = Buffer.alloc(0);
@@ -76,6 +85,7 @@ export interface OidcStateCreateInput {
authConfigRevision: string;
issuer: string;
browserTransactionDigest: string;
browserTransactionTransport: OidcTransactionTransport;
}
export interface CreatedOidcState {
@@ -149,6 +159,11 @@ interface DirectoryIdentity {
mode?: number;
}
interface DirectoryScanIdentity extends DirectoryIdentity {
mtimeMs: number;
ctimeMs: number;
}
interface TrustedFile<T> {
value: T;
identity: FileIdentity;
@@ -160,6 +175,11 @@ interface StorageDirectories {
oidc: string;
}
interface SessionDirectoryPage {
entries: string[];
more: boolean;
}
const text = z.string().min(1).max(512).refine((value) => !/[\u0000-\u001f\u007f]/.test(value));
const timestamp = z.string().length(24).refine((value) => {
const parsed = Date.parse(value);
@@ -207,6 +227,9 @@ const oidcStateRecordSchema = z.strictObject({
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
// Existing ten-minute records from before this field was introduced can be consumed and
// rejected by the route. New records always receive the required input field below.
browserTransactionTransport: z.enum(["https", "loopback_http"]).optional(),
capacitySlot: z.number().int().min(0).max(OIDC_STATE_CAPACITY - 1).optional(),
createdAt: timestamp,
expiresAt: timestamp,
@@ -254,6 +277,7 @@ const oidcStateInputSchema = z.strictObject({
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
browserTransactionDigest: z.string().regex(/^[a-f0-9]{64}$/),
browserTransactionTransport: z.enum(["https", "loopback_http"]),
});
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
@@ -265,6 +289,10 @@ function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity
return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode;
}
function sameDirectoryScanIdentity(left: DirectoryScanIdentity, right: DirectoryScanIdentity): boolean {
return sameDirectoryIdentity(left, right) && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
}
function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
}
@@ -340,6 +368,15 @@ function directoryIdentity(path: string): DirectoryIdentity {
};
}
function directoryScanIdentity(path: string): DirectoryScanIdentity {
const identity = directoryIdentity(path);
const info = lstatSync(path) as Stats;
if (!info.isDirectory() || info.isSymbolicLink() || info.dev !== identity.dev || info.ino !== identity.ino
|| info.uid !== identity.uid || (info.mode & 0o7777) !== identity.mode
|| !Number.isFinite(info.mtimeMs) || !Number.isFinite(info.ctimeMs)) throw invalid();
return { ...identity, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs };
}
function privateDirectory(path: string): void {
let created = false;
try {
@@ -403,6 +440,71 @@ function boundedDirectoryNames(directory: string, maximumEntries: number): strin
}
}
/**
* Return the next lexical page without retaining a complete directory listing. Every direct
* child is structurally validated during the bounded scan, so an unsafe entry cannot hide after
* a full page of ordinary sessions.
*/
function boundedSessionDirectoryPage(
directory: string,
after: string | undefined,
maximumEntries: number,
): SessionDirectoryPage {
if (!Number.isInteger(maximumEntries) || maximumEntries < 1
|| (after !== undefined && !DIGEST_FILENAME_PATTERN.test(after))) throw invalid();
let handle: ReturnType<typeof opendirSync> | undefined;
try {
const before = directoryScanIdentity(directory);
handle = opendirSync(directory);
const seen = new Set<string>();
const selected: string[] = [];
let scanned = 0;
while (true) {
const entry = handle.readSync();
if (entry === null) {
handle.closeSync();
handle = undefined;
if (!sameDirectoryScanIdentity(before, directoryScanIdentity(directory))) throw invalid();
selected.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
const more = selected.length > maximumEntries;
return { entries: more ? selected.slice(0, maximumEntries) : selected, more };
}
scanned += 1;
if (scanned > MAX_SESSION_DIRECTORY_PAGE_SCAN_ENTRIES) throw invalid();
const filename = entry.name;
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)) throw invalid();
seen.add(filename);
let info: Stats;
try {
info = lstatSync(filePath(directory, filename)) as Stats;
} catch (error) {
throw invalid();
}
fileIdentity(info);
if (after !== undefined && filename <= after) continue;
appendBoundedSessionFilename(selected, filename, maximumEntries + 1);
}
} catch {
throw invalid();
} finally {
if (handle !== undefined) {
try { handle.closeSync(); } catch { /* the operation is already fail-closed */ }
}
}
}
function appendBoundedSessionFilename(names: string[], filename: string, maximumEntries: number): void {
if (names.length < maximumEntries) {
names.push(filename);
return;
}
let greatest = 0;
for (let index = 1; index < names.length; index += 1) {
if (names[index] > names[greatest]) greatest = index;
}
if (filename < names[greatest]) names[greatest] = filename;
}
function openDirectory(directory: string): number | undefined {
if (process.platform === "win32") return undefined;
return openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
@@ -835,12 +937,65 @@ export function createFileAuthSessionStore(
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
let sessionPruneCursor: string | undefined;
function requiredWindowsStorage(): WindowsAuthStorageBridge {
if (windowsStorage === undefined) throw invalid();
return windowsStorage;
}
async function ordinarySessionPage(after: string | undefined): Promise<SessionDirectoryPage> {
if (process.platform !== "win32") {
return boundedSessionDirectoryPage(storageDirectories(root).sessions, after, MAX_SESSION_PRUNE_ENTRIES);
}
const page = await requiredWindowsStorage().listPage(root, "sessions", after, MAX_SESSION_PRUNE_ENTRIES);
if (!page || !Array.isArray(page.entries) || typeof page.more !== "boolean") throw invalid();
return { entries: page.entries.map((entry) => entry.name), more: page.more };
}
function nextSessionPruneCursor(page: SessionDirectoryPage, after: string | undefined): string | undefined {
if (!Array.isArray(page.entries) || typeof page.more !== "boolean"
|| page.entries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
const seen = new Set<string>();
let previous = after;
for (const filename of page.entries) {
if (!DIGEST_FILENAME_PATTERN.test(filename) || seen.has(filename)
|| (previous !== undefined && filename <= previous)) throw invalid();
seen.add(filename);
previous = filename;
}
if (!page.more) return undefined;
if (page.entries.length !== MAX_SESSION_PRUNE_ENTRIES || previous === undefined || previous === after) throw invalid();
return previous;
}
async function pruneOrdinarySessions(nowMs: number): Promise<number> {
const after = sessionPruneCursor;
const page = await ordinarySessionPage(after);
const next = nextSessionPruneCursor(page, after);
let removed = 0;
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
for (const filename of page.entries) {
const contents = await bridge.read(root, "sessions", filename);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", filename)) removed += 1;
}
} else {
const directory = storageDirectories(root).sessions;
for (const filename of page.entries) {
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directory, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (trusted && sessionExpired(trusted.value, nowMs)
&& removeTrusted(directory, filename, trusted.identity)) removed += 1;
});
}
}
sessionPruneCursor = next;
return removed;
}
async function oidcStorageEntries(): Promise<string[]> {
const entries = process.platform === "win32"
? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name)
@@ -1239,6 +1394,7 @@ export function createFileAuthSessionStore(
authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer,
browserTransactionDigest: validated.browserTransactionDigest,
browserTransactionTransport: validated.browserTransactionTransport,
capacitySlot,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
@@ -1284,32 +1440,10 @@ export function createFileAuthSessionStore(
async function prune(now = new Date()): Promise<number> {
const nowMs = dateMilliseconds(now);
if (process.platform === "win32") {
const bridge = requiredWindowsStorage();
const sessionEntries = await bridge.list(root, "sessions", MAX_SESSION_PRUNE_ENTRIES);
if (sessionEntries.length > MAX_SESSION_PRUNE_ENTRIES) throw invalid();
let removed = 0;
for (const entry of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(entry.name)) throw invalid();
const contents = await bridge.read(root, "sessions", entry.name);
if (!contents) continue;
const record = parseWindowsRecord(contents, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", entry.name)) removed += 1;
}
return removed + await pruneOidcStates(nowMs);
}
const directories = storageDirectories(root);
let removed = 0;
const sessionEntries = boundedDirectoryNames(directories.sessions, MAX_SESSION_PRUNE_ENTRIES);
for (const filename of sessionEntries) {
if (!DIGEST_FILENAME_PATTERN.test(filename)) throw invalid();
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (trusted && sessionExpired(trusted.value, nowMs)
&& removeTrusted(directories.sessions, filename, trusted.identity)) removed += 1;
});
}
return removed + await pruneOidcStates(nowMs);
// Cursor advancement is process-local, so concurrent timer/manual invocations must not
// observe the same page and strand a later page forever.
return await withLock(lockKey(root, "sessions", "maintenance"), async () =>
(await pruneOrdinarySessions(nowMs)) + (await pruneOidcStates(nowMs)));
}
return {