fix(auth): tighten bridge claim protocol

This commit is contained in:
2026-08-16 22:44:44 +02:00
parent 7d9ca13f1d
commit 8477a69a29
7 changed files with 333 additions and 40 deletions
+3
View File
@@ -609,6 +609,9 @@ function claimOidcState(directory: string, filename: string): OidcStateClaim | u
before = fileIdentity(lstatSync(statePath) as Stats);
} catch (error) {
if (isNotFound(error)) return undefined;
// A winner can install its hard-link after the first claim check and before this state
// identity check. That process owns the state; this contender must fail closed as used.
if (oidcClaimExists(directory, filename)) return undefined;
throw invalid();
}
try {
+33 -9
View File
@@ -70,6 +70,8 @@ export interface WindowsAuthStorageBridgeOptions {
thtExecutable?: string;
/** Test-only child-launch seam; production uses the fixed no-shell Node child-process launcher. */
spawnChild?: WindowsAuthStorageSpawn;
/** Test-only input scheduling seam for real child-process lifecycle tests. */
beforeInputForTest?: () => Promise<void>;
}
const responseSchema = z.strictObject({
@@ -117,8 +119,8 @@ function validateRoot(root: string): void {
|| !win32.isAbsolute(root) || win32.normalize(root) !== root) throw invalid();
}
function validateFilename(filename: string, claim = false): void {
if (typeof filename !== "string" || !(claim ? CLAIM_FILENAME : DIGEST_FILENAME).test(filename)) throw invalid();
function validateFilename(filename: string, allowClaim = false): void {
if (typeof filename !== "string" || (!DIGEST_FILENAME.test(filename) && !(allowClaim && CLAIM_FILENAME.test(filename)))) throw invalid();
}
function safeThtExecutable(value: string | undefined): string {
@@ -144,7 +146,16 @@ function parseResponse(result: WindowsAuthStorageInvocationResult): BridgeRespon
function encodedRequest(request: BridgeRequest): Buffer {
validateRoot(request.root);
if (request.filename !== undefined) validateFilename(request.filename);
if (request.operation === "list") {
if (request.filename !== undefined || request.contentBase64 !== undefined) throw invalid();
} else {
if (request.filename === undefined) throw invalid();
const allowClaim = request.operation === "remove" && request.directory === "oidc";
validateFilename(request.filename, allowClaim);
if (request.contentBase64 !== undefined && request.operation !== "create" && request.operation !== "replace") throw invalid();
}
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));
const encoded = Buffer.from(JSON.stringify(request), "utf8");
if (encoded.length === 0 || encoded.length > MAX_PROTOCOL_BYTES) throw invalid();
@@ -165,6 +176,7 @@ const spawnTht: WindowsAuthStorageSpawn = (executable, args, options) => spawn(e
async function invokeTht(
invocation: WindowsAuthStorageInvocation,
spawnChild: WindowsAuthStorageSpawn = spawnTht,
beforeInputForTest?: () => Promise<void>,
): Promise<WindowsAuthStorageInvocationResult> {
return new Promise((resolve, reject) => {
let settled = false;
@@ -260,10 +272,18 @@ async function invokeTht(
stderr.push(Buffer.from(chunk));
});
stdin.once("error", abort);
try {
stdin.end(invocation.input);
} catch {
abort();
const writeInput = (): void => {
if (aborted || settled) return;
try {
stdin.end(invocation.input);
} catch {
abort();
}
};
if (beforeInputForTest === undefined) {
writeInput();
} else {
void Promise.resolve().then(beforeInputForTest).then(writeInput, abort);
}
});
}
@@ -279,7 +299,11 @@ function contentFrom(response: BridgeResponse, maximum: number): Buffer | undefi
export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridgeOptions = {}): WindowsAuthStorageBridge {
const executable = safeThtExecutable(options.thtExecutable);
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(invocation, options.spawnChild));
const invoke = options.invoke ?? ((invocation: WindowsAuthStorageInvocation) => invokeTht(
invocation,
options.spawnChild,
options.beforeInputForTest,
));
const request = async (value: BridgeRequest): Promise<BridgeResponse> => {
try {
const response = await invoke({
@@ -325,7 +349,7 @@ export function createWindowsAuthStorageBridge(options: WindowsAuthStorageBridge
const response = await request({ version: PROTOCOL_VERSION, operation: "list", root, directory });
if (response.entries === undefined) throw invalid();
for (const entry of response.entries) {
if (!DIGEST_FILENAME.test(entry.name) && !CLAIM_FILENAME.test(entry.name)) throw invalid();
if (!DIGEST_FILENAME.test(entry.name) && !(directory === "oidc" && CLAIM_FILENAME.test(entry.name))) throw invalid();
}
return response.entries.map((entry) => ({ name: entry.name, modifiedUnixMs: entry.modifiedUnixMs }));
},