refactor(security): unify hook rate-limit and hook module loading

This commit is contained in:
Peter Steinberger
2026-02-22 08:56:24 +01:00
parent 7cf280805c
commit 9f97555b5e
6 changed files with 152 additions and 82 deletions

View File

@@ -6,7 +6,6 @@
*/
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
@@ -14,6 +13,7 @@ import { resolveHookConfig } from "./config.js";
import { shouldIncludeHook } from "./config.js";
import type { InternalHookHandler } from "./internal-hooks.js";
import { registerInternalHook } from "./internal-hooks.js";
import { importFileModule, resolveFunctionModuleExport } from "./module-loader.js";
import { loadWorkspaceHookEntries } from "./workspace.js";
const log = createSubsystemLogger("hooks:loader");
@@ -82,16 +82,18 @@ export async function loadInternalHooks(
);
continue;
}
// Import handler module with cache-busting
const url = pathToFileURL(entry.hook.handlerPath).href;
const cacheBustedUrl = `${url}?t=${Date.now()}`;
const mod = (await import(cacheBustedUrl)) as Record<string, unknown>;
// Get handler function (default or named export)
const exportName = entry.metadata?.export ?? "default";
const handler = mod[exportName];
const mod = await importFileModule({
modulePath: entry.hook.handlerPath,
cacheBust: true,
});
const handler = resolveFunctionModuleExport<InternalHookHandler>({
mod,
exportName,
});
if (typeof handler !== "function") {
if (!handler) {
log.error(`Handler '${exportName}' from ${entry.hook.name} is not a function`);
continue;
}
@@ -104,7 +106,7 @@ export async function loadInternalHooks(
}
for (const event of events) {
registerInternalHook(event, handler as InternalHookHandler);
registerInternalHook(event, handler);
}
log.info(
@@ -157,21 +159,23 @@ export async function loadInternalHooks(
continue;
}
// Import the module with cache-busting to ensure fresh reload
const url = pathToFileURL(modulePath).href;
const cacheBustedUrl = `${url}?t=${Date.now()}`;
const mod = (await import(cacheBustedUrl)) as Record<string, unknown>;
// Get the handler function
const exportName = handlerConfig.export ?? "default";
const handler = mod[exportName];
const mod = await importFileModule({
modulePath,
cacheBust: true,
});
const handler = resolveFunctionModuleExport<InternalHookHandler>({
mod,
exportName,
});
if (typeof handler !== "function") {
if (!handler) {
log.error(`Handler '${exportName}' from ${modulePath} is not a function`);
continue;
}
registerInternalHook(handlerConfig.event, handler as InternalHookHandler);
registerInternalHook(handlerConfig.event, handler);
log.info(
`Registered hook (legacy): ${handlerConfig.event} -> ${modulePath}${exportName !== "default" ? `#${exportName}` : ""}`,
);

View File

@@ -0,0 +1,48 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import { resolveFileModuleUrl, resolveFunctionModuleExport } from "./module-loader.js";
describe("hooks module loader helpers", () => {
it("builds a file URL without cache-busting by default", () => {
const modulePath = path.resolve("/tmp/hook-handler.js");
expect(resolveFileModuleUrl({ modulePath })).toBe(pathToFileURL(modulePath).href);
});
it("adds a cache-busting query when requested", () => {
const modulePath = path.resolve("/tmp/hook-handler.js");
expect(
resolveFileModuleUrl({
modulePath,
cacheBust: true,
nowMs: 123,
}),
).toBe(`${pathToFileURL(modulePath).href}?t=123`);
});
it("resolves explicit function exports", () => {
const fn = () => "ok";
const resolved = resolveFunctionModuleExport({
mod: { run: fn },
exportName: "run",
});
expect(resolved).toBe(fn);
});
it("falls back through named exports when no explicit export is provided", () => {
const fallback = () => "ok";
const resolved = resolveFunctionModuleExport({
mod: { transform: fallback },
fallbackExportNames: ["default", "transform"],
});
expect(resolved).toBe(fallback);
});
it("returns undefined when export exists but is not callable", () => {
const resolved = resolveFunctionModuleExport({
mod: { run: "nope" },
exportName: "run",
});
expect(resolved).toBeUndefined();
});
});

View File

@@ -0,0 +1,46 @@
import { pathToFileURL } from "node:url";
type ModuleNamespace = Record<string, unknown>;
type GenericFunction = (...args: never[]) => unknown;
export function resolveFileModuleUrl(params: {
modulePath: string;
cacheBust?: boolean;
nowMs?: number;
}): string {
const url = pathToFileURL(params.modulePath).href;
if (!params.cacheBust) {
return url;
}
const ts = params.nowMs ?? Date.now();
return `${url}?t=${ts}`;
}
export async function importFileModule(params: {
modulePath: string;
cacheBust?: boolean;
nowMs?: number;
}): Promise<ModuleNamespace> {
const specifier = resolveFileModuleUrl(params);
return (await import(specifier)) as ModuleNamespace;
}
export function resolveFunctionModuleExport<T extends GenericFunction>(params: {
mod: ModuleNamespace;
exportName?: string;
fallbackExportNames?: string[];
}): T | undefined {
const explicitExport = params.exportName?.trim();
if (explicitExport) {
const candidate = params.mod[explicitExport];
return typeof candidate === "function" ? (candidate as T) : undefined;
}
const fallbacks = params.fallbackExportNames ?? ["default"];
for (const exportName of fallbacks) {
const candidate = params.mod[exportName];
if (typeof candidate === "function") {
return candidate as T;
}
}
return undefined;
}