fix(auth): paginate session maintenance safely
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user