fix(auth): harden Windows storage bridge

This commit is contained in:
2026-08-16 22:16:13 +02:00
parent c9b02fc57e
commit 7d9ca13f1d
12 changed files with 473 additions and 29 deletions
+85 -20
View File
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { win32 } from "node:path";
import type { Readable, Writable } from "node:stream";
import { z } from "zod";
const PROTOCOL_VERSION = 1;
@@ -46,11 +47,29 @@ export interface WindowsAuthStorageInvocationResult {
stderr: Buffer;
}
interface WindowsAuthStorageChild {
readonly stdin: Writable | null;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
kill(signal?: NodeJS.Signals | number): boolean;
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;
}
type WindowsAuthStorageSpawn = (
executable: string,
args: readonly string[],
options: { shell: false; windowsHide: true; stdio: ["pipe", "pipe", "pipe"]; env: NodeJS.ProcessEnv },
) => WindowsAuthStorageChild;
export interface WindowsAuthStorageBridgeOptions {
/** Test-only transport seam. Production always uses the no-shell child-process invocation. */
invoke?: (invocation: WindowsAuthStorageInvocation) => Promise<WindowsAuthStorageInvocationResult>;
/** Optional configured tht path. Defaults to THT_BIN, then the safe bare command `tht`. */
thtExecutable?: string;
/** Test-only child-launch seam; production uses the fixed no-shell Node child-process launcher. */
spawnChild?: WindowsAuthStorageSpawn;
}
const responseSchema = z.strictObject({
@@ -141,65 +160,111 @@ function environmentForBridge(): NodeJS.ProcessEnv {
};
}
async function invokeTht(invocation: WindowsAuthStorageInvocation): Promise<WindowsAuthStorageInvocationResult> {
const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(executable, [...args], options);
async function invokeTht(
invocation: WindowsAuthStorageInvocation,
spawnChild: WindowsAuthStorageSpawn = spawnTht,
): 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;
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 child: ReturnType<typeof spawn>;
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()));
return;
}
const code = closeCode;
settle(() => resolve({
code,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
}));
};
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 */ }
}
finishAfterClose();
};
try {
child = spawn(invocation.executable, [...invocation.args], {
child = spawnChild(invocation.executable, invocation.args, {
shell: false,
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
env: environmentForBridge(),
});
} catch {
reject(invalid());
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);
if (!child.stdin || !child.stdout || !child.stderr) {
try { child.kill(); } catch { /* unavailable child streams fail closed */ }
reject(invalid());
abort();
return;
}
const stdin = child.stdin;
const stdoutStream = child.stdout;
const stderrStream = child.stderr;
timeout = setTimeout(() => {
try { child.kill(); } catch { /* child failure is converted below */ }
settle(() => reject(invalid()));
abort();
}, invocation.timeoutMs);
child.once("error", () => settle(() => reject(invalid())));
stdoutStream.on("data", (chunk: Buffer) => {
if (aborted) return;
stdoutBytes += chunk.length;
if (stdoutBytes > MAX_RESPONSE_BYTES) {
try { child.kill(); } catch { /* child failure is converted below */ }
settle(() => reject(invalid()));
abort();
return;
}
stdout.push(Buffer.from(chunk));
});
stderrStream.on("data", (chunk: Buffer) => {
if (aborted) return;
stderrBytes += chunk.length;
if (stderrBytes <= MAX_RESPONSE_BYTES) stderr.push(Buffer.from(chunk));
if (stderrBytes > MAX_RESPONSE_BYTES) {
abort();
return;
}
stderr.push(Buffer.from(chunk));
});
child.once("close", (code) => settle(() => resolve({
code: code ?? -1,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
})));
stdin.once("error", () => settle(() => reject(invalid())));
stdin.end(invocation.input);
stdin.once("error", abort);
try {
stdin.end(invocation.input);
} catch {
abort();
}
});
}
@@ -214,7 +279,7 @@ function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefi
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable);
const invoke = options.invoke ?? invokeTht;
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(invocation, options.spawnChild));
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const response = await invoke({