fix(auth): close diagnostic filesystem races

This commit is contained in:
2026-08-17 13:04:15 +02:00
parent be1724890a
commit 6d438f4c7e
16 changed files with 545 additions and 90 deletions
+57 -50
View File
@@ -8,7 +8,6 @@ import {
fsyncSync,
lstatSync,
linkSync,
mkdirSync,
openSync,
opendirSync,
readSync,
@@ -29,6 +28,7 @@ import type {
Role,
} from "./types.js";
import {
createPosixAuthStorageBridge,
createWindowsAuthStorageBridge,
type WindowsAuthStorageBridge,
} from "./windows-auth-storage.js";
@@ -137,9 +137,11 @@ export interface AuthSessionStore {
consumeOidcState(state: string, now?: Date): Promise<OidcStateRecord | undefined>;
}
/** Narrow test seam for the native Windows tht-backed storage adaptor. */
/** Narrow test seams for the native tht-backed storage adaptors. */
export interface FileAuthSessionStoreOptions {
windowsStorageBridge?: WindowsAuthStorageBridge;
/** Test seam for POSIX layout creation; production uses the bounded hidden tht bridge. */
posixStorageBridge?: Pick<WindowsAuthStorageBridge, "ensureLayout">;
/** Test-only capacity seam; production always uses the fixed 64-state bound. */
oidcStateCapacity?: number;
}
@@ -297,10 +299,6 @@ function isNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
}
function isAlreadyExists(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
}
function canonicalRawValue(value: string): boolean {
if (typeof value !== "string" || !TOKEN_PATTERN.test(value)) return false;
try {
@@ -387,6 +385,7 @@ interface SessionRootAncestor {
dev: number;
ino: number;
uid: number;
mode: number;
}
function validateSessionRootSyntax(root: string): void {
@@ -396,7 +395,7 @@ function validateSessionRootSyntax(root: string): void {
function sameAncestor(ancestor: SessionRootAncestor, info: Stats): boolean {
return info.isDirectory() && !info.isSymbolicLink() && ancestor.dev === info.dev
&& ancestor.ino === info.ino && ancestor.uid === info.uid;
&& ancestor.ino === info.ino && ancestor.uid === info.uid && ancestor.mode === info.mode;
}
function withSessionRootPreflight<T>(root: string, use: (exists: boolean) => T): T {
@@ -428,11 +427,19 @@ function withSessionRootPreflight<T>(root: string, use: (exists: boolean) => T):
const descriptor = openSync(current, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
const opened = fstatSync(descriptor) as Stats;
if (!opened.isDirectory() || opened.dev !== info.dev || opened.ino !== info.ino || opened.uid !== info.uid) {
if (!opened.isDirectory() || opened.dev !== info.dev || opened.ino !== info.ino
|| opened.uid !== info.uid || opened.mode !== info.mode) {
closeSync(descriptor);
throw invalid();
}
ancestors.push({ path: current, descriptor, dev: info.dev, ino: info.ino, uid: info.uid });
ancestors.push({
path: current,
descriptor,
dev: info.dev,
ino: info.ino,
uid: info.uid,
mode: info.mode,
});
}
directoryIdentity(root);
const result = use(true);
@@ -450,34 +457,16 @@ function withSessionRootPreflight<T>(root: string, use: (exists: boolean) => T):
}
}
function privateDirectory(path: string): void {
withSessionRootPreflight(path, (exists) => {
if (exists) return;
try {
mkdirSync(path, { recursive: false, mode: PRIVATE_DIRECTORY_MODE });
} catch (error) {
if (!isAlreadyExists(error)) throw invalid();
directoryIdentity(path);
return;
}
const descriptor = openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0)
| (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
try {
fchmodSync(descriptor, PRIVATE_DIRECTORY_MODE);
const opened = fstatSync(descriptor) as Stats;
const current = directoryIdentity(path);
if (opened.dev !== current.dev || opened.ino !== current.ino || opened.uid !== current.uid
|| (opened.mode & 0o7777) !== current.mode) throw invalid();
} finally {
try { closeSync(descriptor); } catch { /* creation already fails closed */ }
}
});
}
/** Side-effect-free POSIX validator shared by runtime storage and static diagnostics. */
export function validateAuthSessionRoot(root: string): void {
try {
withSessionRootPreflight(root, () => undefined);
const exists = withSessionRootPreflight(root, (currentExists) => currentExists);
if (!exists) return;
for (const child of ["sessions", "oidc"]) {
const path = join(root, child);
if (dirname(path) !== root) throw invalid();
withSessionRootPreflight(path, () => undefined);
}
} catch {
throw invalid();
}
@@ -487,12 +476,11 @@ function storageDirectories(root: string): StorageDirectories {
// Native Windows calls must dispatch to the tht DACL-capable bridge before reaching this
// POSIX-only helper. Keep this guard so an un-routed caller cannot fall back to chmod.
validateSessionRootSyntax(root);
privateDirectory(root);
validateAuthSessionRoot(root);
const sessions = join(root, "sessions");
const oidc = join(root, "oidc");
privateDirectory(sessions);
privateDirectory(oidc);
directoryIdentity(sessions);
directoryIdentity(oidc);
return { root, sessions, oidc };
}
@@ -1020,6 +1008,9 @@ export function createFileAuthSessionStore(
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
const posixStorage = process.platform === "win32"
? undefined
: options.posixStorageBridge ?? createPosixAuthStorageBridge();
let sessionPruneCursor: string | undefined;
function requiredWindowsStorage(): WindowsAuthStorageBridge {
@@ -1027,9 +1018,25 @@ export function createFileAuthSessionStore(
return windowsStorage;
}
async function posixStorageDirectories(): Promise<StorageDirectories> {
if (process.platform === "win32" || posixStorage === undefined) throw invalid();
try {
return storageDirectories(root);
} catch {
// A missing safe layout is the only case the helper can repair. Unsafe layouts are
// rejected by the same native primitive without path-based fallback in this process.
}
try {
await posixStorage.ensureLayout(root);
return storageDirectories(root);
} catch {
throw invalid();
}
}
async function ordinarySessionPage(after: string | undefined): Promise<SessionDirectoryPage> {
if (process.platform !== "win32") {
return boundedSessionDirectoryPage(storageDirectories(root).sessions, after, MAX_SESSION_PRUNE_ENTRIES);
return boundedSessionDirectoryPage((await posixStorageDirectories()).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();
@@ -1066,7 +1073,7 @@ export function createFileAuthSessionStore(
if (sessionExpired(record, nowMs) && await bridge.remove(root, "sessions", filename)) removed += 1;
}
} else {
const directory = storageDirectories(root).sessions;
const directory = (await posixStorageDirectories()).sessions;
for (const filename of page.entries) {
await withLock(lockKey(root, "sessions", filename), async () => {
const trusted = readTrusted(directory, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
@@ -1082,7 +1089,7 @@ export function createFileAuthSessionStore(
async function oidcStorageEntries(): Promise<string[]> {
const entries = process.platform === "win32"
? (await requiredWindowsStorage().list(root, "oidc", MAX_OIDC_STORAGE_ENTRIES)).map((entry) => entry.name)
: boundedDirectoryNames(storageDirectories(root).oidc, MAX_OIDC_STORAGE_ENTRIES);
: boundedDirectoryNames((await posixStorageDirectories()).oidc, MAX_OIDC_STORAGE_ENTRIES);
if (entries.length > MAX_OIDC_STORAGE_ENTRIES) throw invalid();
if (entries.some((entry) => !DIGEST_FILENAME_PATTERN.test(entry)
&& !CLAIM_FILENAME_PATTERN.test(entry) && oidcSlotIndex(entry) === undefined)) throw invalid();
@@ -1111,7 +1118,7 @@ export function createFileAuthSessionStore(
if (retryDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
try {
trusted = readTrusted(
storageDirectories(root).oidc,
(await posixStorageDirectories()).oidc,
filename,
MAX_OIDC_SLOT_RECORD_BYTES,
parseOidcSlotRecord,
@@ -1140,7 +1147,7 @@ export function createFileAuthSessionStore(
|| !await requiredWindowsStorage().remove(root, "oidc", slot.filename)) throw invalid();
return;
}
if (!slot.identity || !removeTrusted(storageDirectories(root).oidc, slot.filename, slot.identity)) throw invalid();
if (!slot.identity || !removeTrusted((await posixStorageDirectories()).oidc, slot.filename, slot.identity)) throw invalid();
}
async function releaseOidcSlot(index: number | undefined, stateFilename: string): Promise<void> {
@@ -1172,7 +1179,7 @@ export function createFileAuthSessionStore(
const filename = oidcSlotFilename(index);
const created = process.platform === "win32"
? await requiredWindowsStorage().create(root, "oidc", filename, contents)
: writeExclusive(storageDirectories(root).oidc, filename, contents);
: writeExclusive((await posixStorageDirectories()).oidc, filename, contents);
if (created) return index;
}
throw new OidcStateCapacityError();
@@ -1216,7 +1223,7 @@ export function createFileAuthSessionStore(
}
throw invalid();
}
const directories = storageDirectories(root);
const directories = await posixStorageDirectories();
for (let attempt = 0; attempt < 8; attempt += 1) {
const token = randomBytes(TOKEN_BYTES).toString("base64url");
const filename = digestFilename(token);
@@ -1255,7 +1262,7 @@ export function createFileAuthSessionStore(
await bridge.remove(root, "sessions", filename);
return undefined;
}
const directories = storageDirectories(root);
const directories = await posixStorageDirectories();
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (!trusted) return undefined;
if (sessionExpired(trusted.value, nowMs)) {
@@ -1300,7 +1307,7 @@ export function createFileAuthSessionStore(
await bridge.replace(root, "sessions", filename, serialize(touched, MAX_SESSION_RECORD_BYTES));
return;
}
const directories = storageDirectories(root);
const directories = await posixStorageDirectories();
const trusted = readTrusted(directories.sessions, filename, MAX_SESSION_RECORD_BYTES, parseSessionRecord);
if (!trusted) return;
if (sessionExpired(trusted.value, nowMs)) {
@@ -1333,7 +1340,7 @@ export function createFileAuthSessionStore(
await requiredWindowsStorage().remove(root, "sessions", filename);
return;
}
const directories = storageDirectories(root);
const directories = await posixStorageDirectories();
removeTrusted(directories.sessions, filename);
});
}
@@ -1394,7 +1401,7 @@ export function createFileAuthSessionStore(
return removed;
}
const directory = storageDirectories(root).oidc;
const directory = (await posixStorageDirectories()).oidc;
const oidcEntries = boundedDirectoryNames(directory, MAX_OIDC_STORAGE_ENTRIES);
const stateFilenames = new Set(oidcEntries.filter((entry) => DIGEST_FILENAME_PATTERN.test(entry)));
let removed = 0;
@@ -1485,7 +1492,7 @@ export function createFileAuthSessionStore(
const contents = serialize(record, MAX_OIDC_STATE_RECORD_BYTES);
const created = process.platform === "win32"
? await requiredWindowsStorage().create(root, "oidc", filename, contents)
: writeExclusive(storageDirectories(root).oidc, filename, contents);
: writeExclusive((await posixStorageDirectories()).oidc, filename, contents);
if (created) return { state, record };
await releaseOidcSlot(capacitySlot, filename);
}
@@ -1505,7 +1512,7 @@ export function createFileAuthSessionStore(
await releaseOidcSlot(record.capacitySlot, filename);
return oidcStateExpired(record, nowMs) ? undefined : record;
}
const directories = storageDirectories(root);
const directories = await posixStorageDirectories();
const claim = claimOidcState(directories.oidc, filename);
// An installed claim belongs to another process/store instance. Only the process which
// created the hard link is allowed to receive the record.