fix(auth): make diagnostics bounded and portable

This commit is contained in:
2026-08-17 12:19:19 +02:00
parent 7cfbee36fa
commit be1724890a
23 changed files with 1088 additions and 116 deletions
+86 -10
View File
@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { win32 } from "node:path";
import type { Readable, Writable } from "node:stream";
import { z } from "zod";
@@ -6,6 +6,9 @@ import { z } from "zod";
const PROTOCOL_VERSION = 1;
const MAX_PROTOCOL_BYTES = 64 * 1024;
const MAX_RESPONSE_BYTES = 64 * 1024;
const MAX_AUTH_CONFIG_BYTES = 1024 * 1024;
const MAX_AUTH_CONFIG_BASE64_BYTES = 4 * Math.ceil(MAX_AUTH_CONFIG_BYTES / 3);
const MAX_AUTH_CONFIG_RESPONSE_BYTES = MAX_AUTH_CONFIG_BASE64_BYTES + 1024;
const MAX_SESSION_BYTES = 16 * 1024;
const MAX_OIDC_BYTES = 8 * 1024;
const DEFAULT_MAX_ENTRIES = 256;
@@ -14,6 +17,7 @@ const TIMEOUT_MS = 5_000;
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$/;
const AUTH_CONFIG_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,249}\.yaml$/;
const invalid = (): Error => new Error("auth_session_store_invalid");
@@ -31,6 +35,8 @@ export interface WindowsAuthStoragePage {
/** Internal adapter boundary for the file-session store's native Windows path. */
export interface WindowsAuthStorageBridge {
validateRoot(root: string): Promise<void>;
readAuthConfig(path: string): 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>;
@@ -56,6 +62,7 @@ export interface WindowsAuthStorageInvocation {
args: readonly string[];
input: Buffer;
timeoutMs: number;
maximumOutputBytes: number;
}
export interface WindowsAuthStorageInvocationResult {
@@ -83,6 +90,8 @@ type WindowsAuthStorageSpawn = (
export interface WindowsAuthStorageBridgeOptions {
/** Test-only transport seam. Production always uses the no-shell child-process invocation. */
invoke?: (invocation: WindowsAuthStorageInvocation) => Promise<WindowsAuthStorageInvocationResult>;
/** Test-only synchronous seam used by the synchronous authentication-config provider. */
invokeSync?: (invocation: WindowsAuthStorageInvocation) => 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. */
@@ -98,21 +107,22 @@ const responseSchema = z.strictObject({
replaced: z.boolean().optional(),
removed: z.boolean().optional(),
found: z.boolean().optional(),
contentBase64: z.string().max(MAX_PROTOCOL_BYTES).optional(),
contentBase64: z.string().max(MAX_AUTH_CONFIG_BASE64_BYTES).optional(),
entries: z.array(z.strictObject({
name: z.string().max(128),
modifiedUnixMs: z.number().int().safe().nonnegative(),
})).max(MAX_ENTRIES).optional(),
more: z.boolean().optional(),
validated: z.boolean().optional(),
});
type BridgeResponse = z.infer<typeof responseSchema>;
interface BridgeRequest {
version: typeof PROTOCOL_VERSION;
operation: "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
operation: "validate-root" | "read-auth-config" | "create" | "read" | "replace" | "remove" | "list" | "claim-consume" | "read-claim" | "remove-claim";
root: string;
directory: WindowsAuthStorageDirectory;
directory?: WindowsAuthStorageDirectory;
filename?: string;
contentBase64?: string;
maximumEntries?: number;
@@ -154,9 +164,10 @@ function safeThtExecutable(value: string | undefined): string {
throw invalid();
}
function parseResponse(result: WindowsAuthStorageInvocationResult): BridgeResponse {
function parseResponse(result: WindowsAuthStorageInvocationResult, maximumOutputBytes: number): BridgeResponse {
if (!Number.isInteger(result.code) || result.code !== 0 || !Buffer.isBuffer(result.stdout)
|| !Buffer.isBuffer(result.stderr) || result.stdout.length === 0 || result.stdout.length > MAX_RESPONSE_BYTES) {
|| !Buffer.isBuffer(result.stderr) || result.stderr.length > MAX_RESPONSE_BYTES
|| result.stdout.length === 0 || result.stdout.length > maximumOutputBytes) {
throw invalid();
}
try {
@@ -169,7 +180,15 @@ function parseResponse(result: WindowsAuthStorageInvocationResult): BridgeRespon
function encodedRequest(request: BridgeRequest): Buffer {
validateRoot(request.root);
if (request.operation === "list") {
if (request.operation === "validate-root") {
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") {
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();
} else if (request.operation === "list") {
if (request.directory === undefined) throw invalid();
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
if (request.maximumEntries !== undefined && (!Number.isInteger(request.maximumEntries)
|| request.maximumEntries < 1 || request.maximumEntries > MAX_ENTRIES)) throw invalid();
@@ -181,6 +200,7 @@ function encodedRequest(request: BridgeRequest): Buffer {
throw invalid();
}
} else {
if (request.directory === undefined) throw invalid();
if (request.maximumEntries !== undefined || request.afterName !== undefined || request.continuation !== undefined) throw invalid();
if (request.filename === undefined) throw invalid();
const allowClaim = request.operation === "remove" && request.directory === "oidc";
@@ -191,7 +211,10 @@ function encodedRequest(request: BridgeRequest): Buffer {
}
if ((request.operation === "claim-consume" || request.operation === "read-claim" || request.operation === "remove-claim")
&& request.directory !== "oidc") throw invalid();
if (request.contentBase64 !== undefined) canonicalBase64(request.contentBase64, directoryMaximum(request.directory));
if (request.contentBase64 !== undefined) {
if (request.directory === undefined) throw invalid();
canonicalBase64(request.contentBase64, directoryMaximum(request.directory));
}
const encoded = Buffer.from(JSON.stringify(request), "utf8");
if (encoded.length === 0 || encoded.length > MAX_PROTOCOL_BYTES) throw invalid();
return encoded;
@@ -208,6 +231,25 @@ function environmentForBridge(): NodeJS.ProcessEnv {
const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(executable, [...args], options);
function invokeThtSync(invocation: WindowsAuthStorageInvocation): WindowsAuthStorageInvocationResult {
try {
const result = spawnSync(invocation.executable, [...invocation.args], {
shell: false,
windowsHide: true,
env: environmentForBridge(),
input: invocation.input,
timeout: invocation.timeoutMs,
maxBuffer: invocation.maximumOutputBytes,
encoding: "buffer",
});
if (result.error || result.signal !== null || typeof result.status !== "number"
|| !Buffer.isBuffer(result.stdout) || !Buffer.isBuffer(result.stderr)) throw invalid();
return { code: result.status, stdout: result.stdout, stderr: result.stderr };
} catch {
throw invalid();
}
}
async function invokeTht(
invocation: WindowsAuthStorageInvocation,
spawnChild: WindowsAuthStorageSpawn = spawnTht,
@@ -355,20 +397,37 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge
options.spawnChild,
options.beforeInputForTest,
));
const invokeSync = options.invokeSync ?? invokeThtSync;
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const maximumOutputBytes = MAX_RESPONSE_BYTES;
const response = await invoke({
executable,
args: ["_auth-storage"],
input: encodedRequest(value),
timeoutMs: TIMEOUT_MS,
maximumOutputBytes,
});
return parseResponse(response);
return parseResponse(response, maximumOutputBytes);
} catch {
throw invalid();
}
};
const recordRequest = (operation: BridgeRequest["operation"], root: string, directory: WindowsAuthStorageDirectory, filename: string, contents?: Buffer): BridgeRequest => ({
const syncRequest = (value: BridgeRequest): BridgeResponse => {
try {
const response = invokeSync({
executable,
args: ["_auth-storage"],
input: encodedRequest(value),
timeoutMs: TIMEOUT_MS,
maximumOutputBytes: MAX_AUTH_CONFIG_RESPONSE_BYTES,
});
return parseResponse(response, MAX_AUTH_CONFIG_RESPONSE_BYTES);
} catch {
throw invalid();
}
};
const recordRequest = (operation: "create" | "read" | "replace" | "remove" | "claim-consume" | "read-claim" | "remove-claim", root: string, directory: WindowsAuthStorageDirectory, filename: string, contents?: Buffer): BridgeRequest => ({
version: PROTOCOL_VERSION,
operation,
root,
@@ -378,6 +437,23 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge
});
return {
async validateRoot(root) {
const response = await request({ version: PROTOCOL_VERSION, operation: "validate-root", root });
if (response.validated !== true
|| Object.keys(response).some((key) => !["version", "ok", "validated"].includes(key))) throw invalid();
},
readAuthConfig(path) {
if (typeof path !== "string" || path.length === 0 || /[\u0000-\u001f\u007f]/.test(path)
|| !win32.isAbsolute(path) || win32.normalize(path) !== path) throw invalid();
const root = win32.dirname(path);
const filename = win32.basename(path);
if (!AUTH_CONFIG_FILENAME.test(filename) || win32.join(root, filename) !== path) throw invalid();
const response = syncRequest({ version: PROTOCOL_VERSION, operation: "read-auth-config", 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));