fix(secrets): enforce file provider read timeouts

This commit is contained in:
joshavant
2026-02-25 19:08:56 -06:00
committed by Peter Steinberger
parent 67e9554645
commit 86622ebea9
2 changed files with 73 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js";
@@ -15,6 +15,7 @@ describe("secret ref resolver", () => {
const cleanupRoots: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
while (cleanupRoots.length > 0) {
const root = cleanupRoots.pop();
if (!root) {
@@ -280,6 +281,56 @@ describe("secret ref resolver", () => {
expect(value).toBe("raw-token-value");
});
it("times out file provider reads when timeoutMs elapses", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-timeout-"));
cleanupRoots.push(root);
const filePath = path.join(root, "secrets.json");
await writeSecureFile(
filePath,
JSON.stringify({
providers: {
openai: {
apiKey: "sk-file-value",
},
},
}),
);
const originalReadFile = fs.readFile.bind(fs);
vi.spyOn(fs, "readFile").mockImplementation(((
targetPath: Parameters<typeof fs.readFile>[0],
options?: Parameters<typeof fs.readFile>[1],
) => {
if (typeof targetPath === "string" && targetPath === filePath) {
return new Promise<Buffer>(() => {});
}
return originalReadFile(targetPath, options);
}) as typeof fs.readFile);
await expect(
resolveSecretRefString(
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
{
config: {
secrets: {
providers: {
filemain: {
source: "file",
path: filePath,
mode: "jsonPointer",
timeoutMs: 5,
},
},
},
},
},
),
).rejects.toThrow('File provider "filemain" timed out');
});
it("rejects misconfigured provider source mismatches", async () => {
await expect(
resolveSecretRefValue(

View File

@@ -188,11 +188,20 @@ async function readFileProviderPayload(params: {
DEFAULT_FILE_TIMEOUT_MS,
);
const maxBytes = normalizePositiveInt(params.providerConfig.maxBytes, DEFAULT_FILE_MAX_BYTES);
const timeoutHandle = setTimeout(() => {
// noop marker to keep timeout behavior explicit and deterministic
}, timeoutMs);
const abortController = new AbortController();
const timeoutErrorMessage = `File provider "${params.providerName}" timed out after ${timeoutMs}ms.`;
let timeoutHandle: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutHandle = setTimeout(() => {
abortController.abort();
reject(new Error(timeoutErrorMessage));
}, timeoutMs);
});
try {
const payload = await fs.readFile(filePath);
const payload = await Promise.race([
fs.readFile(filePath, { signal: abortController.signal }),
timeoutPromise,
]);
if (payload.byteLength > maxBytes) {
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
}
@@ -205,8 +214,15 @@ async function readFileProviderPayload(params: {
throw new Error(`File provider "${params.providerName}" payload is not a JSON object.`);
}
return parsed;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(timeoutErrorMessage, { cause: error });
}
throw error;
} finally {
clearTimeout(timeoutHandle);
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
})();