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
+85 -13
View File
@@ -12,6 +12,7 @@ import {
} from "../src/auth/windows-auth-storage.js";
const root = "C:\\ProgramData\\ThothII\\auth";
const posixRoot = "/var/lib/thothii/auth";
const filename = "a".repeat(64) + ".json";
const realChildFixture = fileURLToPath(new URL("./fixtures/windows-auth-storage-real-child.mjs", import.meta.url));
const fixtureRoots: string[] = [];
@@ -29,18 +30,25 @@ async function waitForMarker(marker: string, expected: string): Promise<void> {
throw new Error(`real helper marker did not contain ${expected}`);
}
function realChildBridge(mode: "timeout" | "stdout" | "stderr" | "stdin") {
function realChildBridge(
mode: "timeout" | "stdout" | "stderr" | "stdin",
pathStyle: "windows" | "posix",
) {
const directory = mkdtempSync(join(tmpdir(), "thothii-auth-bridge-child-"));
fixtureRoots.push(directory);
const marker = join(directory, "marker.txt");
const launcher = join(directory, "tht.exe");
writeFileSync(launcher, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(realChildFixture)} ${shellQuote(mode)} ${shellQuote(marker)} "$@"\n`, { mode: 0o700 });
chmodSync(launcher, 0o700);
const factory = pathStyle === "windows" ? createWindowsAuthStorageBridge : createPosixAuthStorageBridge;
return {
marker,
bridge: createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
bridge: factory({
thtExecutable: pathStyle === "windows" ? "C:\\tht.exe" : launcher,
spawnChild: (_executable, args, options) => spawn(launcher, [...args], options),
// Leave enough startup headroom for a real child under a busy CI host while retaining a
// sub-1.5-second bound from request start through final settlement.
deadlinesForTest: { timeoutMs: 750, terminationGraceMs: 50, finalSettlementMs: 500 },
...(mode === "stdin" ? {
beforeInputForTest: async () => {
await waitForMarker(marker, "stdin-closed");
@@ -67,16 +75,18 @@ class FakeBridgeChild extends EventEmitter {
readonly stdout = new PassThrough();
readonly stderr = new PassThrough();
readonly kill = vi.fn(() => true);
readonly unref = vi.fn();
close(code = 0, signal: NodeJS.Signals | null = null): void {
this.emit("close", code, signal);
}
}
function bridgeForChild(child: FakeBridgeChild) {
function bridgeForChild(child: FakeBridgeChild, pathStyle: "windows" | "posix" = "windows") {
const spawnChild = vi.fn(() => child);
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\tht.exe",
const factory = pathStyle === "windows" ? createWindowsAuthStorageBridge : createPosixAuthStorageBridge;
const bridge = factory({
thtExecutable: pathStyle === "windows" ? "C:\\tht.exe" : "/opt/thothii/bin/tht",
spawnChild,
} as never);
return { bridge, spawnChild };
@@ -214,6 +224,34 @@ describe("Windows auth-storage bridge", () => {
expect(JSON.stringify(syncCalls[0]!.args)).not.toContain(config.toString("utf8"));
});
test("reads native Windows users.yaml only through a bounded hidden bridge request", async () => {
const users = Buffer.from("version: 1\nusers:\n - passwordHash: not-in-argv\n", "utf8");
const calls: Array<{ args: readonly string[]; input: Buffer; maximumOutputBytes: number }> = [];
const bridge = createWindowsAuthStorageBridge({
thtExecutable: "C:\\Program Files\\ThothII\\tht.exe",
invoke: async (call) => {
calls.push(call);
return {
code: 0,
stdout: Buffer.from(`${JSON.stringify({
version: 1, ok: true, found: true, contentBase64: users.toString("base64"),
})}\n`),
stderr: Buffer.alloc(0),
};
},
});
await expect(bridge.readLocalUsers(`${root}\\users.yaml`)).resolves.toEqual(users);
expect(calls).toHaveLength(1);
expect(calls[0]!.args).toEqual(["_auth-storage"]);
expect(calls[0]!.maximumOutputBytes).toBeGreaterThan(1024 * 1024);
expect(JSON.parse(calls[0]!.input.toString("utf8"))).toEqual({
version: 1, operation: "read-local-users", root, filename: "users.yaml",
});
expect(JSON.stringify(calls[0]!.args)).not.toContain("not-in-argv");
expect(calls[0]!.input.toString("utf8")).not.toContain("not-in-argv");
});
test.each([
{ label: "nonzero", result: { code: 1, stdout: Buffer.from('{"version":1,"ok":true}\n'), stderr: Buffer.from("secret") } },
{ label: "malformed stdout", result: { code: 0, stdout: Buffer.from("not-json"), stderr: Buffer.alloc(0) } },
@@ -400,7 +438,7 @@ describe("Windows auth-storage bridge", () => {
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
});
test("aborts a stdin-closed looping helper on timeout and waits for close", async () => {
test("settles a stdin-closed looping helper by a final deadline when close never arrives", async () => {
vi.useFakeTimers();
const child = new FakeBridgeChild();
const { bridge, spawnChild } = bridgeForChild(child);
@@ -410,17 +448,44 @@ describe("Windows auth-storage bridge", () => {
await vi.advanceTimersByTimeAsync(5_000);
expect(spawnChild).toHaveBeenCalledOnce();
expect(child.kill).toHaveBeenCalledOnce();
expect(child.unref).toHaveBeenCalledOnce();
expect(child.stdin.destroyed).toBe(true);
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
child.close();
await vi.advanceTimersByTimeAsync(1_000);
expect(child.kill).toHaveBeenCalledTimes(2);
await expect(outcome).resolves.toBe("rejected");
expect(() => child.emit("error", new Error("late helper failure"))).not.toThrow();
expect(() => child.stdin.emit("error", new Error("late stdin failure"))).not.toThrow();
} finally {
child.close();
vi.useRealTimers();
}
});
test("releases bounded late-error guards when a finally-dead helper closes", async () => {
vi.useFakeTimers();
const child = new FakeBridgeChild();
const { bridge } = bridgeForChild(child);
const outcome = bridge.list(root, "sessions").then(() => "resolved", () => "rejected");
try {
await vi.advanceTimersByTimeAsync(6_000);
await expect(outcome).resolves.toBe("rejected");
expect(child.listenerCount("error")).toBe(1);
expect(child.stdin.listenerCount("error")).toBe(1);
expect(child.stdout.listenerCount("error")).toBe(1);
expect(child.stderr.listenerCount("error")).toBe(1);
child.close();
expect(child.listenerCount("error")).toBe(0);
expect(child.stdin.listenerCount("error")).toBe(0);
expect(child.stdout.listenerCount("error")).toBe(0);
expect(child.stderr.listenerCount("error")).toBe(0);
} finally {
vi.useRealTimers();
}
});
test.each(["stdout", "stderr"] as const)("aborts a %s-flooding helper and waits for close", async (stream) => {
const child = new FakeBridgeChild();
const { bridge, spawnChild } = bridgeForChild(child);
@@ -469,6 +534,10 @@ describe("Windows auth-storage bridge", () => {
await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending");
child.close();
await expect(outcome).resolves.toBe("rejected");
expect(() => child.emit("error", new Error("late helper failure"))).not.toThrow();
expect(() => child.stdin.emit("error", new Error("late stdin failure"))).not.toThrow();
expect(() => child.stdout.emit("error", new Error("late stdout failure"))).not.toThrow();
expect(() => child.stderr.emit("error", new Error("late stderr failure"))).not.toThrow();
});
test("fails closed when launching the helper throws before a child exists", async () => {
@@ -480,16 +549,19 @@ describe("Windows auth-storage bridge", () => {
await expect(bridge.list(root, "sessions")).rejects.toThrow("auth_session_store_invalid");
});
test.each(["timeout", "stdout", "stderr", "stdin"] as const)("kills a real %s helper process through the production spawn path", async (mode) => {
const { bridge, marker } = realChildBridge(mode);
test.each([
...(["windows", "posix"] as const).flatMap((pathStyle) =>
(["timeout", "stdout", "stderr", "stdin"] as const).map((mode) => ({ pathStyle, mode }))),
])("settles a real $pathStyle $mode helper within the production deadline", async ({ pathStyle, mode }) => {
const { bridge, marker } = realChildBridge(mode, pathStyle);
const startedAt = Date.now();
const pending = bridge.list(root, "sessions");
const pending = bridge.list(pathStyle === "windows" ? root : posixRoot, "sessions");
const outcome = pending.then(() => undefined, (error: unknown) => error);
await waitForMarker(marker, "started");
if (mode === "stdin") await waitForMarker(marker, "before-input");
await expect(outcome).resolves.toMatchObject({ message: "auth_session_store_invalid" });
await waitForMarker(marker, "terminated");
if (mode === "stdin") expect(Date.now() - startedAt).toBeLessThan(2_000);
}, 10_000);
expect(Date.now() - startedAt).toBeLessThan(1_500);
}, 5_000);
});