fix(auth): pin native auth storage operations

This commit is contained in:
2026-08-17 14:52:21 +02:00
parent 6d438f4c7e
commit cb0e873ed7
23 changed files with 3016 additions and 1385 deletions
+30 -9
View File
@@ -13,6 +13,7 @@ import { parseDocument } from "yaml";
import { z } from "zod";
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
import type { LoadedAuthConfig, Role } from "./types.js";
import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js";
const MAX_USERS_YAML_BYTES = 1 << 20;
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/;
@@ -44,6 +45,11 @@ export interface CurrentLocalUserRegistryResolver {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined;
}
/** Native Windows obtains protected registry bytes only from the hidden tht bridge. */
export interface LocalUserRegistryOptions {
windowsStorageBridge?: Pick<WindowsAuthStorageBridge, "readLocalUsers">;
}
interface FileIdentity {
dev: number;
ino: number;
@@ -220,10 +226,13 @@ function load(path: string): { records: LocalUserRecord[]; identity: RegistryIde
return { records: parseRegistry(read.source), identity: read.identity };
}
export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
export function createLocalUserRegistry(usersPath: string, options: LocalUserRegistryOptions = {}): LocalUserRegistry {
let cached: { records: LocalUserRecord[]; identity: RegistryIdentity } | undefined;
const windowsStorage = process.platform === "win32"
? options.windowsStorageBridge ?? createWindowsAuthStorageBridge()
: undefined;
function current(): LocalUserRecord[] {
function currentPosix(): LocalUserRecord[] {
try {
const before = registryIdentity(usersPath);
if (cached && sameIdentity(cached.identity, before)) return cached.records;
@@ -240,22 +249,34 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
throw invalid();
}
function operationalRecords(): LocalUserRecord[] {
const records = current();
async function current(): Promise<LocalUserRecord[]> {
if (process.platform !== "win32") return currentPosix();
try {
if (!windowsStorage) throw invalid();
const contents = await windowsStorage.readLocalUsers(usersPath);
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_USERS_YAML_BYTES) throw invalid();
return parseRegistry(new TextDecoder("utf-8", { fatal: true }).decode(contents));
} catch {
throw invalid();
}
}
async function operationalRecords(): Promise<LocalUserRecord[]> {
const records = await current();
if (!records.some((user) => user.enabled && user.roles.includes("admin"))) throw invalid();
return records;
}
return {
async hasEnabledAdmin(): Promise<boolean> {
return current().some((user) => user.enabled && user.roles.includes("admin"));
return (await current()).some((user) => user.enabled && user.roles.includes("admin"));
},
async findByUsername(username: string): Promise<LocalUserRecord | undefined> {
const normalized = normalizeUsername(username);
return operationalRecords().find((user) => user.normalizedUsername === normalized);
return (await operationalRecords()).find((user) => user.normalizedUsername === normalized);
},
async findBySubject(id: string): Promise<LocalUserRecord | undefined> {
return operationalRecords().find((user) => user.id === id);
return (await operationalRecords()).find((user) => user.id === id);
},
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
if (!user || !user.enabled) {
@@ -267,14 +288,14 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
};
}
export function createCurrentLocalUserRegistryResolver(): CurrentLocalUserRegistryResolver {
export function createCurrentLocalUserRegistryResolver(options: LocalUserRegistryOptions = {}): CurrentLocalUserRegistryResolver {
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
return {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
if (loaded.value.mode !== "local") return undefined;
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
if (current?.usersPath === usersPath) return current.registry;
const registry = createLocalUserRegistry(usersPath);
const registry = createLocalUserRegistry(usersPath, options);
current = { usersPath, registry };
return registry;
},
File diff suppressed because it is too large Load Diff
+145 -53
View File
@@ -14,6 +14,8 @@ const MAX_OIDC_BYTES = 8 * 1024;
const DEFAULT_MAX_ENTRIES = 256;
const MAX_ENTRIES = 512;
const TIMEOUT_MS = 5_000;
const TERMINATION_GRACE_MS = 100;
const FINAL_SETTLEMENT_MS = 750;
const DIGEST_FILENAME = /^[a-f0-9]{64}\.json$/;
const CLAIM_FILENAME = /^[a-f0-9]{64}\.claim$/;
const OIDC_SLOT_FILENAME = /^slot-(?:[0-5][0-9]|6[0-3])\.json$/;
@@ -38,6 +40,7 @@ export interface WindowsAuthStorageBridge {
validateRoot(root: string): Promise<void>;
ensureLayout(root: string): Promise<void>;
readAuthConfig(path: string): Buffer;
readLocalUsers(path: string): Promise<Buffer>;
create(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<boolean>;
read(root: string, directory: WindowsAuthStorageDirectory, filename: string): Promise<Buffer | undefined>;
replace(root: string, directory: WindowsAuthStorageDirectory, filename: string, contents: Buffer): Promise<void>;
@@ -77,9 +80,11 @@ interface WindowsAuthStorageChild {
readonly stdout: Readable | null;
readonly stderr: Readable | null;
kill(signal?: NodeJS.Signals | number): boolean;
unref?(): void;
on(event: "error", listener: (error: Error) => void): this;
once(event: "error", listener: (error: Error) => void): this;
once(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
removeListener?(event: "error" | "close", listener: (...args: any[]) => void): this;
}
type WindowsAuthStorageSpawn = (
@@ -99,6 +104,12 @@ export interface WindowsAuthStorageBridgeOptions {
spawnChild?: WindowsAuthStorageSpawn;
/** Test-only input scheduling seam for real child-process lifecycle tests. */
beforeInputForTest?: () => Promise<void>;
/** Test-only bounded lifecycle timings. Production always uses the fixed deadlines below. */
deadlinesForTest?: {
timeoutMs?: number;
terminationGraceMs?: number;
finalSettlementMs?: number;
};
}
type AuthStoragePathStyle = "posix" | "windows";
@@ -124,7 +135,7 @@ type BridgeResponse = z.infer<typeof responseSchema>;
interface BridgeRequest {
version: typeof PROTOCOL_VERSION;
operation: "validate-root" | "ensure-layout" | "read-auth-config" | "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
operation: "validate-root" | "ensure-layout" | "read-auth-config" | "read-local-users" | "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
root: string;
directory?: WindowsAuthStorageDirectory;
filename?: string;
@@ -190,7 +201,7 @@ function encodedRequest(request: BridgeRequest, pathStyle: AuthStoragePathStyle)
if (request.operation === "validate-root" || request.operation === "ensure-layout") {
if (request.directory !== undefined || request.filename !== undefined || request.contentBase64 !== undefined
|| request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
} else if (request.operation === "read-auth-config") {
} else if (request.operation === "read-auth-config" || request.operation === "read-local-users") {
if (request.directory !== undefined || request.contentBase64 !== undefined || request.maximumEntries !== undefined
|| request.afterName !== undefined || request.continuation !== undefined
|| request.filename === undefined || !AUTH_CONFIG_FILENAME.test(request.filename)) throw invalid();
@@ -261,52 +272,127 @@ async function invokeTht(
invocation: WindowsAuthStorageInvocation,
spawnChild: WindowsAuthStorageSpawn = spawnTht,
beforeInputForTest?: () => Promise<void>,
terminationGraceMs = TERMINATION_GRACE_MS,
finalSettlementMs = FINAL_SETTLEMENT_MS,
): Promise<WindowsAuthStorageInvocationResult> {
return new Promise((resolve, reject) => {
let settled = false;
let closeSeen = false;
let aborted = false;
let closeCode: number | null = null;
let closeSignal: NodeJS.Signals | null = null;
let timeout: NodeJS.Timeout | undefined;
let terminationTimer: NodeJS.Timeout | undefined;
let finalSettlementTimer: NodeJS.Timeout | undefined;
let lateErrorReleaseTimer: NodeJS.Timeout | undefined;
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let child: WindowsAuthStorageChild | undefined;
const settle = (callback: () => void): void => {
if (settled) return;
settled = true;
if (timeout !== undefined) clearTimeout(timeout);
callback();
let stdin: Writable | undefined;
let stdoutStream: Readable | undefined;
let stderrStream: Readable | undefined;
const swallowChildError = (): void => undefined;
const swallowStreamError = (): void => undefined;
const releaseQuarantine = (): void => {
if (lateErrorReleaseTimer !== undefined) clearTimeout(lateErrorReleaseTimer);
lateErrorReleaseTimer = undefined;
removeChildListener("error", swallowChildError);
removeChildListener("close", releaseQuarantine);
removeStreamListener(stdin, "error", swallowStreamError);
removeStreamListener(stdoutStream, "error", swallowStreamError);
removeStreamListener(stderrStream, "error", swallowStreamError);
};
const quarantineLateErrors = (): void => {
// A final-deadline settlement can precede a broken ChildProcess object's terminal events.
// Keep only no-capture listeners for a bounded grace period so a late EventEmitter error
// cannot become uncaught, including after an already-observed close event.
child?.on("error", swallowChildError);
child?.once("close", releaseQuarantine);
stdin?.on("error", swallowStreamError);
stdoutStream?.on("error", swallowStreamError);
stderrStream?.on("error", swallowStreamError);
lateErrorReleaseTimer = setTimeout(releaseQuarantine, finalSettlementMs);
lateErrorReleaseTimer.unref?.();
};
const removeChildListener = (event: "error" | "close", listener: (...args: any[]) => void): void => {
try { child?.removeListener?.(event, listener); } catch { /* the helper is already terminal */ }
};
const removeStreamListener = (stream: Writable | Readable | undefined, event: "data" | "error", listener: (...args: any[]) => void): void => {
try { stream?.removeListener(event, listener); } catch { /* the helper is already terminal */ }
};
const stopStream = (stream: Writable | Readable | null | undefined): void => {
try { stream?.destroy(); } catch { /* abort is already fail-closed */ }
};
const finishAfterClose = (): void => {
if (!closeSeen || settled) return;
if (aborted || typeof closeCode !== "number" || !Number.isInteger(closeCode) || closeSignal !== null) {
settle(() => reject(invalid()));
const onChildError = (): void => abort();
const onStdinError = (): void => abort();
const onStdoutError = (): void => abort();
const onStderrError = (): void => abort();
const onStdoutData = (chunk: Buffer): void => {
if (aborted || settled) return;
stdoutBytes += chunk.length;
if (stdoutBytes > invocation.maximumOutputBytes) {
abort();
return;
}
stdout.push(Buffer.from(chunk));
};
const onStderrData = (chunk: Buffer): void => {
if (aborted || settled) return;
stderrBytes += chunk.length;
if (stderrBytes > MAX_RESPONSE_BYTES) {
abort();
return;
}
stderr.push(Buffer.from(chunk));
};
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) return;
if (aborted || code === null || !Number.isInteger(code) || signal !== null) {
settle(() => reject(invalid()), true);
return;
}
const code = closeCode;
settle(() => resolve({
code,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
}));
};
const cleanup = (quarantine = false): void => {
if (timeout !== undefined) clearTimeout(timeout);
if (terminationTimer !== undefined) clearTimeout(terminationTimer);
if (finalSettlementTimer !== undefined) clearTimeout(finalSettlementTimer);
removeChildListener("error", onChildError);
removeChildListener("close", onClose as (...args: any[]) => void);
removeStreamListener(stdin, "error", onStdinError);
removeStreamListener(stdoutStream, "data", onStdoutData);
removeStreamListener(stdoutStream, "error", onStdoutError);
removeStreamListener(stderrStream, "data", onStderrData);
removeStreamListener(stderrStream, "error", onStderrError);
if (quarantine) quarantineLateErrors();
};
const settle = (callback: () => void, quarantine = false): void => {
if (settled) return;
settled = true;
cleanup(quarantine);
callback();
};
const abort = (): void => {
if (aborted || settled) return;
aborted = true;
if (timeout !== undefined) clearTimeout(timeout);
if (child !== undefined) {
stopStream(child.stdin);
stopStream(child.stdout);
stopStream(child.stderr);
try { child.kill(); } catch { /* the close handler still owns settlement */ }
stopStream(stdin);
stopStream(stdoutStream);
stopStream(stderrStream);
try { child.kill("SIGTERM"); } catch { /* final settlement still owns completion */ }
try { child.unref?.(); } catch { /* the bounded timers still own completion */ }
}
finishAfterClose();
terminationTimer = setTimeout(() => {
if (settled || child === undefined) return;
try { child.kill("SIGKILL"); } catch { /* final settlement still owns completion */ }
}, terminationGraceMs);
finalSettlementTimer = setTimeout(() => {
settle(() => reject(invalid()), true);
}, finalSettlementMs);
};
try {
child = spawnChild(invocation.executable, invocation.args, {
@@ -319,43 +405,23 @@ async function invokeTht(
settle(() => reject(invalid()));
return;
}
child.once("close", (code, signal) => {
closeSeen = true;
closeCode = code;
closeSignal = signal;
if (code === null || signal !== null) aborted = true;
finishAfterClose();
});
child.on("error", abort);
child.once("close", onClose);
child.on("error", onChildError);
if (!child.stdin || !child.stdout || !child.stderr) {
abort();
return;
}
const stdin = child.stdin;
const stdoutStream = child.stdout;
const stderrStream = child.stderr;
stdin = child.stdin;
stdoutStream = child.stdout;
stderrStream = child.stderr;
timeout = setTimeout(() => {
abort();
}, invocation.timeoutMs);
stdoutStream.on("data", (chunk: Buffer) => {
if (aborted) return;
stdoutBytes += chunk.length;
if (stdoutBytes > MAX_RESPONSE_BYTES) {
abort();
return;
}
stdout.push(Buffer.from(chunk));
});
stderrStream.on("data", (chunk: Buffer) => {
if (aborted) return;
stderrBytes += chunk.length;
if (stderrBytes > MAX_RESPONSE_BYTES) {
abort();
return;
}
stderr.push(Buffer.from(chunk));
});
stdin.once("error", abort);
stdoutStream.on("data", onStdoutData);
stdoutStream.once("error", onStdoutError);
stderrStream.on("data", onStderrData);
stderrStream.once("error", onStderrError);
stdin.once("error", onStdinError);
const writeInput = (): void => {
if (aborted || settled) return;
try {
@@ -402,20 +468,33 @@ function createAuthStorageBridge(
options: WindowsAuthStorageBridgeOptions = {},
): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable, pathStyle);
const testDeadlines = options.deadlinesForTest;
const timeoutMs = testDeadlines?.timeoutMs ?? TIMEOUT_MS;
const terminationGraceMs = testDeadlines?.terminationGraceMs ?? TERMINATION_GRACE_MS;
const finalSettlementMs = testDeadlines?.finalSettlementMs ?? FINAL_SETTLEMENT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > TIMEOUT_MS
|| !Number.isSafeInteger(terminationGraceMs) || terminationGraceMs < 1 || terminationGraceMs > TIMEOUT_MS
|| !Number.isSafeInteger(finalSettlementMs) || finalSettlementMs <= terminationGraceMs || finalSettlementMs > TIMEOUT_MS) {
throw invalid();
}
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
invocation,
options.spawnChild,
options.beforeInputForTest,
terminationGraceMs,
finalSettlementMs,
));
const invokeSync = options.invokeSync ?? invokeThtSync;
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const maximumOutputBytes = MAX_RESPONSE_BYTES;
const maximumOutputBytes = value.operation === "read-local-users"
? MAX_AUTH_CONFIG_RESPONSE_BYTES
: MAX_RESPONSE_BYTES;
const response = await invoke({
executable,
args: ["_auth-storage"],
input: encodedRequest(value, pathStyle),
timeoutMs: TIMEOUT_MS,
timeoutMs,
maximumOutputBytes,
});
return parseResponse(response, maximumOutputBytes);
@@ -429,7 +508,7 @@ function createAuthStorageBridge(
executable,
args: ["_auth-storage"],
input: encodedRequest(value, pathStyle),
timeoutMs: TIMEOUT_MS,
timeoutMs,
maximumOutputBytes: MAX_AUTH_CONFIG_RESPONSE_BYTES,
});
return parseResponse(response, MAX_AUTH_CONFIG_RESPONSE_BYTES);
@@ -470,6 +549,19 @@ function createAuthStorageBridge(
if (contents === undefined) throw invalid();
return contents;
},
async readLocalUsers(path) {
const paths = pathStyle === "windows" ? win32 : posix;
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|| !paths.isAbsolute(path) || paths.normalize(path) !== path) throw invalid();
const root = paths.dirname(path);
const filename = paths.basename(path);
if (!AUTH_CONFIG_FILENAME.test(filename) || paths.join(root, filename) !== path) throw invalid();
const response = await request({ version: PROTOCOL_VERSION, operation: "read-local-users", root, filename });
if (Object.keys(response).some((key) => !["version", "ok", "found", "contentBase64"].includes(key))) throw invalid();
const contents = contentFrom(response, MAX_AUTH_CONFIG_BYTES);
if (contents === undefined) throw invalid();
return contents;
},
async create(root, directory, filename, contents) {
if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > directoryMaximum(directory)) throw invalid();
const response = await request(recordRequest("create", root, directory, filename, contents));