fix(auth): harden Windows storage bridge
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user