fix(auth): tighten bridge claim protocol
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user