fix(auth): make diagnostics bounded and portable
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user