fix(auth): pin native auth storage operations
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
+119
-913
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user