fix: harden secret-file readers

This commit is contained in:
Peter Steinberger
2026-03-10 23:40:10 +00:00
parent 208fb1aa35
commit 201420a7ee
26 changed files with 433 additions and 188 deletions

View File

@@ -1,54 +1,12 @@
import { mkdir, symlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import { describe, expect, it } from "vitest";
import { MAX_SECRET_FILE_BYTES, readSecretFromFile } from "./secret-file.js";
const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-secret-file-test-");
afterEach(async () => {
await tempDirs.cleanup();
});
describe("readSecretFromFile", () => {
it("reads and trims a regular secret file", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, " top-secret \n", "utf8");
expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret");
it("keeps the shared secret-file limit", () => {
expect(MAX_SECRET_FILE_BYTES).toBe(16 * 1024);
});
it("rejects files larger than the secret-file limit", async () => {
const dir = await createTempDir();
const file = path.join(dir, "secret.txt");
await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8");
expect(() => readSecretFromFile(file, "Gateway password")).toThrow(
`Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`,
);
});
it("rejects non-regular files", async () => {
const dir = await createTempDir();
const nestedDir = path.join(dir, "secret-dir");
await mkdir(nestedDir);
expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow(
`Gateway password file at ${nestedDir} must be a regular file.`,
);
});
it("rejects symlinks", async () => {
const dir = await createTempDir();
const target = path.join(dir, "target.txt");
const link = path.join(dir, "secret-link.txt");
await writeFile(target, "top-secret\n", "utf8");
await symlink(target, link);
expect(() => readSecretFromFile(link, "Gateway password")).toThrow(
`Gateway password file at ${link} must not be a symlink.`,
);
it("exposes the hardened secret reader", () => {
expect(typeof readSecretFromFile).toBe("function");
});
});

View File

@@ -1,43 +1,10 @@
import fs from "node:fs";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_SECRET_FILE_MAX_BYTES, readSecretFileSync } from "../infra/secret-file.js";
export const MAX_SECRET_FILE_BYTES = 16 * 1024;
export const MAX_SECRET_FILE_BYTES = DEFAULT_SECRET_FILE_MAX_BYTES;
export function readSecretFromFile(filePath: string, label: string): string {
const resolvedPath = resolveUserPath(filePath.trim());
if (!resolvedPath) {
throw new Error(`${label} file path is empty.`);
}
let stat: fs.Stats;
try {
stat = fs.lstatSync(resolvedPath);
} catch (err) {
throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, {
cause: err,
});
}
if (stat.isSymbolicLink()) {
throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`);
}
if (!stat.isFile()) {
throw new Error(`${label} file at ${resolvedPath} must be a regular file.`);
}
if (stat.size > MAX_SECRET_FILE_BYTES) {
throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`);
}
let raw = "";
try {
raw = fs.readFileSync(resolvedPath, "utf8");
} catch (err) {
throw new Error(`Failed to read ${label} file at ${resolvedPath}: ${String(err)}`, {
cause: err,
});
}
const secret = raw.trim();
if (!secret) {
throw new Error(`${label} file at ${resolvedPath} is empty.`);
}
return secret;
return readSecretFileSync(filePath, label, {
maxBytes: MAX_SECRET_FILE_BYTES,
rejectSymlink: true,
});
}