refactor: unify boundary hardening for file reads

This commit is contained in:
Peter Steinberger
2026-02-26 13:04:33 +01:00
parent cf4853e2b8
commit eac86c2081
11 changed files with 455 additions and 56 deletions

View File

@@ -281,5 +281,71 @@ describe("loader", () => {
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
});
it("rejects directory hook handlers that escape hook dir via hardlink", async () => {
if (process.platform === "win32") {
return;
}
const outsideHandlerPath = path.join(fixtureRoot, `outside-handler-hardlink-${caseId}.js`);
await fs.writeFile(outsideHandlerPath, "export default async function() {}", "utf-8");
const hookDir = path.join(tmpDir, "hooks", "hardlink-hook");
await fs.mkdir(hookDir, { recursive: true });
await fs.writeFile(
path.join(hookDir, "HOOK.md"),
[
"---",
"name: hardlink-hook",
"description: hardlink test",
'metadata: {"openclaw":{"events":["command:new"]}}',
"---",
"",
"# Hardlink Hook",
].join("\n"),
"utf-8",
);
try {
await fs.link(outsideHandlerPath, path.join(hookDir, "handler.js"));
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
const cfg = createEnabledHooksConfig();
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
});
it("rejects legacy handler modules that escape workspace via hardlink", async () => {
if (process.platform === "win32") {
return;
}
const outsideHandlerPath = path.join(fixtureRoot, `outside-legacy-hardlink-${caseId}.js`);
await fs.writeFile(outsideHandlerPath, "export default async function() {}", "utf-8");
const linkedHandlerPath = path.join(tmpDir, "legacy-handler.js");
try {
await fs.link(outsideHandlerPath, linkedHandlerPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
const cfg = createEnabledHooksConfig([
{
event: "command:new",
module: "legacy-handler.js",
},
]);
const count = await loadInternalHooks(cfg, tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).not.toContain("command:new");
});
});
});

View File

@@ -5,10 +5,11 @@
* and from directory-based discovery (bundled, managed, workspace)
*/
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFile } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
import { resolveHookConfig } from "./config.js";
import { shouldIncludeHook } from "./config.js";
import { buildImportUrl } from "./import-url.js";
@@ -73,18 +74,23 @@ export async function loadInternalHooks(
}
try {
if (
!isPathInsideWithRealpath(entry.hook.baseDir, entry.hook.handlerPath, {
requireRealpath: true,
})
) {
const hookBaseDir = safeRealpathOrResolve(entry.hook.baseDir);
const opened = await openBoundaryFile({
absolutePath: entry.hook.handlerPath,
rootPath: hookBaseDir,
boundaryLabel: "hook directory",
});
if (!opened.ok) {
log.error(
`Hook '${entry.hook.name}' handler path resolves outside hook directory: ${entry.hook.handlerPath}`,
`Hook '${entry.hook.name}' handler path fails boundary checks: ${entry.hook.handlerPath}`,
);
continue;
}
const safeHandlerPath = opened.path;
fs.closeSync(opened.fd);
// Import handler module — only cache-bust mutable (workspace/managed) hooks
const importUrl = buildImportUrl(entry.hook.handlerPath, entry.hook.source);
const importUrl = buildImportUrl(safeHandlerPath, entry.hook.source);
const mod = (await import(importUrl)) as Record<string, unknown>;
// Get handler function (default or named export)
@@ -144,24 +150,27 @@ export async function loadInternalHooks(
}
const baseDir = path.resolve(workspaceDir);
const modulePath = path.resolve(baseDir, rawModule);
const baseDirReal = safeRealpathOrResolve(baseDir);
const modulePathSafe = safeRealpathOrResolve(modulePath);
const rel = path.relative(baseDir, modulePath);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
log.error(`Handler module path must stay within workspaceDir: ${rawModule}`);
continue;
}
if (
!isPathInsideWithRealpath(baseDir, modulePath, {
requireRealpath: true,
})
) {
log.error(
`Handler module path resolves outside workspaceDir after symlink resolution: ${rawModule}`,
);
const opened = await openBoundaryFile({
absolutePath: modulePathSafe,
rootPath: baseDirReal,
boundaryLabel: "workspace directory",
});
if (!opened.ok) {
log.error(`Handler module path fails boundary checks under workspaceDir: ${rawModule}`);
continue;
}
const safeModulePath = opened.path;
fs.closeSync(opened.fd);
// Legacy handlers are always workspace-relative, so use mtime-based cache busting
const importUrl = buildImportUrl(modulePath, "openclaw-workspace");
const importUrl = buildImportUrl(safeModulePath, "openclaw-workspace");
const mod = (await import(importUrl)) as Record<string, unknown>;
// Get the handler function
@@ -190,3 +199,11 @@ export async function loadInternalHooks(
return loadedCount;
}
function safeRealpathOrResolve(value: string): string {
try {
return fs.realpathSync(value);
} catch {
return path.resolve(value);
}
}

View File

@@ -102,4 +102,67 @@ describe("hooks workspace", () => {
const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" });
expect(entries.some((e) => e.hook.name === "outside")).toBe(false);
});
it("ignores hooks with hardlinked HOOK.md aliases", () => {
if (process.platform === "win32") {
return;
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-hardlink-"));
const hooksRoot = path.join(root, "hooks");
fs.mkdirSync(hooksRoot, { recursive: true });
const hookDir = path.join(hooksRoot, "hardlink-hook");
const outsideDir = path.join(root, "outside");
fs.mkdirSync(hookDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(hookDir, "handler.js"), "export default async () => {};\n");
const outsideHookMd = path.join(outsideDir, "HOOK.md");
const linkedHookMd = path.join(hookDir, "HOOK.md");
fs.writeFileSync(linkedHookMd, "---\nname: hardlink-hook\n---\n");
fs.rmSync(linkedHookMd);
fs.writeFileSync(outsideHookMd, "---\nname: outside\n---\n");
try {
fs.linkSync(outsideHookMd, linkedHookMd);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" });
expect(entries.some((e) => e.hook.name === "hardlink-hook")).toBe(false);
expect(entries.some((e) => e.hook.name === "outside")).toBe(false);
});
it("ignores hooks with hardlinked handler aliases", () => {
if (process.platform === "win32") {
return;
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-hardlink-"));
const hooksRoot = path.join(root, "hooks");
fs.mkdirSync(hooksRoot, { recursive: true });
const hookDir = path.join(hooksRoot, "hardlink-handler-hook");
const outsideDir = path.join(root, "outside");
fs.mkdirSync(hookDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: hardlink-handler-hook\n---\n");
const outsideHandler = path.join(outsideDir, "handler.js");
const linkedHandler = path.join(hookDir, "handler.js");
fs.writeFileSync(outsideHandler, "export default async () => {};\n");
try {
fs.linkSync(outsideHandler, linkedHandler);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" });
expect(entries.some((e) => e.hook.name === "hardlink-handler-hook")).toBe(false);
});
});

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
@@ -36,11 +37,15 @@ function filterHookEntries(
function readHookPackageManifest(dir: string): HookPackageManifest | null {
const manifestPath = path.join(dir, "package.json");
if (!fs.existsSync(manifestPath)) {
const raw = readBoundaryFileUtf8({
absolutePath: manifestPath,
rootPath: dir,
boundaryLabel: "hook package directory",
});
if (raw === null) {
return null;
}
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
return JSON.parse(raw) as HookPackageManifest;
} catch {
return null;
@@ -75,12 +80,15 @@ function loadHookFromDir(params: {
nameHint?: string;
}): Hook | null {
const hookMdPath = path.join(params.hookDir, "HOOK.md");
if (!fs.existsSync(hookMdPath)) {
const content = readBoundaryFileUtf8({
absolutePath: hookMdPath,
rootPath: params.hookDir,
boundaryLabel: "hook directory",
});
if (content === null) {
return null;
}
try {
const content = fs.readFileSync(hookMdPath, "utf-8");
const frontmatter = parseFrontmatter(content);
const name = frontmatter.name || params.nameHint || path.basename(params.hookDir);
@@ -90,8 +98,13 @@ function loadHookFromDir(params: {
let handlerPath: string | undefined;
for (const candidate of handlerCandidates) {
const candidatePath = path.join(params.hookDir, candidate);
if (fs.existsSync(candidatePath)) {
handlerPath = candidatePath;
const safeCandidatePath = resolveBoundaryFilePath({
absolutePath: candidatePath,
rootPath: params.hookDir,
boundaryLabel: "hook directory",
});
if (safeCandidatePath) {
handlerPath = safeCandidatePath;
break;
}
}
@@ -192,11 +205,13 @@ export function loadHookEntriesFromDir(params: {
});
return hooks.map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
const raw = readBoundaryFileUtf8({
absolutePath: hook.filePath,
rootPath: hook.baseDir,
boundaryLabel: "hook directory",
});
if (raw !== null) {
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed hooks
}
const entry: HookEntry = {
hook: {
@@ -267,11 +282,13 @@ function loadHookEntries(
return Array.from(merged.values()).map((hook) => {
let frontmatter: ParsedHookFrontmatter = {};
try {
const raw = fs.readFileSync(hook.filePath, "utf-8");
const raw = readBoundaryFileUtf8({
absolutePath: hook.filePath,
rootPath: hook.baseDir,
boundaryLabel: "hook directory",
});
if (raw !== null) {
frontmatter = parseFrontmatter(raw);
} catch {
// ignore malformed hooks
}
return {
hook,
@@ -316,3 +333,43 @@ export function loadWorkspaceHookEntries(
): HookEntry[] {
return loadHookEntries(workspaceDir, opts);
}
function readBoundaryFileUtf8(params: {
absolutePath: string;
rootPath: string;
boundaryLabel: string;
}): string | null {
const opened = openBoundaryFileSync({
absolutePath: params.absolutePath,
rootPath: params.rootPath,
boundaryLabel: params.boundaryLabel,
});
if (!opened.ok) {
return null;
}
try {
return fs.readFileSync(opened.fd, "utf-8");
} catch {
return null;
} finally {
fs.closeSync(opened.fd);
}
}
function resolveBoundaryFilePath(params: {
absolutePath: string;
rootPath: string;
boundaryLabel: string;
}): string | null {
const opened = openBoundaryFileSync({
absolutePath: params.absolutePath,
rootPath: params.rootPath,
boundaryLabel: params.boundaryLabel,
});
if (!opened.ok) {
return null;
}
const safePath = opened.path;
fs.closeSync(opened.fd);
return safePath;
}