mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 17:51:24 +00:00
refactor(security): unify hook rate-limit and hook module loading
This commit is contained in:
@@ -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}` : ""}`,
|
||||
);
|
||||
|
||||
48
src/hooks/module-loader.test.ts
Normal file
48
src/hooks/module-loader.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
46
src/hooks/module-loader.ts
Normal file
46
src/hooks/module-loader.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user