fix(security): restrict hook transform module loading

This commit is contained in:
Peter Steinberger
2026-02-14 13:45:58 +01:00
parent 6543ce717c
commit a0361b8ba9
7 changed files with 199 additions and 39 deletions
+6
View File
@@ -2,6 +2,12 @@
Docs: https://docs.openclaw.ai Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config).
## 2026.2.13 ## 2026.2.13
### Changes ### Changes
+1 -1
View File
@@ -88,7 +88,7 @@ Notes:
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`. To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)). under `~/.openclaw/hooks/transforms` (see [Webhooks](/automation/webhook)).
## Wizard (recommended) ## Wizard (recommended)
+1 -1
View File
@@ -139,7 +139,7 @@ Mapping options (summary):
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping. - `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
- `hooks.mappings` lets you define `match`, `action`, and templates in config. - `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic. - `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic (restricted to `~/.openclaw/hooks/transforms`).
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing). - Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime. - TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface - Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
+2 -2
View File
@@ -363,7 +363,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
path: "/hooks", path: "/hooks",
token: "shared-secret", token: "shared-secret",
presets: ["gmail"], presets: ["gmail"],
transformsDir: "~/.openclaw/hooks", transformsDir: "~/.openclaw/hooks/transforms",
mappings: [ mappings: [
{ {
id: "gmail-hook", id: "gmail-hook",
@@ -380,7 +380,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
thinking: "low", thinking: "low",
timeoutSeconds: 300, timeoutSeconds: 300,
transform: { transform: {
module: "./transforms/gmail.js", module: "gmail.js",
export: "transformGmail", export: "transformGmail",
}, },
}, },
+1 -1
View File
@@ -1987,7 +1987,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
allowedSessionKeyPrefixes: ["hook:"], allowedSessionKeyPrefixes: ["hook:"],
allowedAgentIds: ["hooks", "main"], allowedAgentIds: ["hooks", "main"],
presets: ["gmail"], presets: ["gmail"],
transformsDir: "~/.openclaw/hooks", transformsDir: "~/.openclaw/hooks/transforms",
mappings: [ mappings: [
{ {
match: { path: "gmail" }, match: { path: "gmail" },
+136 -11
View File
@@ -62,16 +62,18 @@ describe("hooks mapping", () => {
}); });
it("runs transform module", async () => { it("runs transform module", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-")); const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-"));
const modPath = path.join(dir, "transform.mjs"); const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
const modPath = path.join(transformsRoot, "transform.mjs");
const placeholder = "${payload.name}"; const placeholder = "${payload.name}";
fs.writeFileSync( fs.writeFileSync(
modPath, modPath,
`export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`, `export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`,
); );
const mappings = resolveHookMappings({ const mappings = resolveHookMappings(
transformsDir: dir, {
mappings: [ mappings: [
{ {
match: { path: "custom" }, match: { path: "custom" },
@@ -79,7 +81,9 @@ describe("hooks mapping", () => {
transform: { module: "transform.mjs" }, transform: { module: "transform.mjs" },
}, },
], ],
}); },
{ configDir },
);
const result = await applyHookMappings(mappings, { const result = await applyHookMappings(mappings, {
payload: { name: "Ada" }, payload: { name: "Ada" },
@@ -97,13 +101,98 @@ describe("hooks mapping", () => {
} }
}); });
it("treats null transform as a handled skip", async () => { it("rejects transform module traversal outside transformsDir", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-skip-")); const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-traversal-"));
const modPath = path.join(dir, "transform.mjs"); const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.writeFileSync(modPath, "export default () => null;"); fs.mkdirSync(transformsRoot, { recursive: true });
expect(() =>
resolveHookMappings(
{
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "../evil.mjs" },
},
],
},
{ configDir },
),
).toThrow(/must be within/);
});
const mappings = resolveHookMappings({ it("rejects absolute transform module path outside transformsDir", () => {
transformsDir: dir, const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-abs-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
const outside = path.join(os.tmpdir(), "evil.mjs");
expect(() =>
resolveHookMappings(
{
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: outside },
},
],
},
{ configDir },
),
).toThrow(/must be within/);
});
it("rejects transformsDir traversal outside the transforms root", () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-trav-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
expect(() =>
resolveHookMappings(
{
transformsDir: "..",
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
),
).toThrow(/Hook transformsDir/);
});
it("rejects transformsDir absolute path outside the transforms root", () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-abs-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
expect(() =>
resolveHookMappings(
{
transformsDir: os.tmpdir(),
mappings: [
{
match: { path: "custom" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
),
).toThrow(/Hook transformsDir/);
});
it("accepts transformsDir subdirectory within the transforms root", async () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-xformdir-ok-"));
const transformsSubdir = path.join(configDir, "hooks", "transforms", "subdir");
fs.mkdirSync(transformsSubdir, { recursive: true });
fs.writeFileSync(path.join(transformsSubdir, "transform.mjs"), "export default () => null;");
const mappings = resolveHookMappings(
{
transformsDir: "subdir",
mappings: [ mappings: [
{ {
match: { path: "skip" }, match: { path: "skip" },
@@ -111,8 +200,44 @@ describe("hooks mapping", () => {
transform: { module: "transform.mjs" }, transform: { module: "transform.mjs" },
}, },
], ],
},
{ configDir },
);
const result = await applyHookMappings(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/skip"),
path: "skip",
}); });
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action).toBeNull();
expect("skipped" in result).toBe(true);
}
});
it("treats null transform as a handled skip", async () => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-skip-"));
const transformsRoot = path.join(configDir, "hooks", "transforms");
fs.mkdirSync(transformsRoot, { recursive: true });
const modPath = path.join(transformsRoot, "transform.mjs");
fs.writeFileSync(modPath, "export default () => null;");
const mappings = resolveHookMappings(
{
mappings: [
{
match: { path: "skip" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
},
{ configDir },
);
const result = await applyHookMappings(mappings, { const result = await applyHookMappings(mappings, {
payload: {}, payload: {},
headers: {}, headers: {},
+39 -10
View File
@@ -102,7 +102,10 @@ type HookTransformFn = (
ctx: HookMappingContext, ctx: HookMappingContext,
) => HookTransformResult | Promise<HookTransformResult>; ) => HookTransformResult | Promise<HookTransformResult>;
export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] { export function resolveHookMappings(
hooks?: HooksConfig,
opts?: { configDir?: string },
): HookMappingResolved[] {
const presets = hooks?.presets ?? []; const presets = hooks?.presets ?? [];
const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent; const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent;
const mappings: HookMappingConfig[] = []; const mappings: HookMappingConfig[] = [];
@@ -129,10 +132,13 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[]
return []; return [];
} }
const configDir = path.dirname(CONFIG_PATH); const configDir = path.resolve(opts?.configDir ?? path.dirname(CONFIG_PATH));
const transformsDir = hooks?.transformsDir const transformsRootDir = path.join(configDir, "hooks", "transforms");
? resolvePath(configDir, hooks.transformsDir) const transformsDir = resolveOptionalContainedPath(
: configDir; transformsRootDir,
hooks?.transformsDir,
"Hook transformsDir",
);
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir)); return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
} }
@@ -187,7 +193,7 @@ function normalizeHookMapping(
const wakeMode = mapping.wakeMode ?? "now"; const wakeMode = mapping.wakeMode ?? "now";
const transform = mapping.transform const transform = mapping.transform
? { ? {
modulePath: resolvePath(transformsDir, mapping.transform.module), modulePath: resolveContainedPath(transformsDir, mapping.transform.module, "Hook transform"),
exportName: mapping.transform.export?.trim() || undefined, exportName: mapping.transform.export?.trim() || undefined,
} }
: undefined; : undefined;
@@ -340,12 +346,35 @@ function resolveTransformFn(mod: Record<string, unknown>, exportName?: string):
function resolvePath(baseDir: string, target: string): string { function resolvePath(baseDir: string, target: string): string {
if (!target) { if (!target) {
return baseDir; return path.resolve(baseDir);
} }
if (path.isAbsolute(target)) { return path.isAbsolute(target) ? path.resolve(target) : path.resolve(baseDir, target);
return target;
} }
return path.join(baseDir, target);
function resolveContainedPath(baseDir: string, target: string, label: string): string {
const base = path.resolve(baseDir);
const trimmed = target?.trim();
if (!trimmed) {
throw new Error(`${label} module path is required`);
}
const resolved = resolvePath(base, trimmed);
const relative = path.relative(base, resolved);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error(`${label} module path must be within ${base}: ${target}`);
}
return resolved;
}
function resolveOptionalContainedPath(
baseDir: string,
target: string | undefined,
label: string,
): string {
const trimmed = target?.trim();
if (!trimmed) {
return path.resolve(baseDir);
}
return resolveContainedPath(baseDir, trimmed, label);
} }
function normalizeMatchPath(raw?: string): string | undefined { function normalizeMatchPath(raw?: string): string | undefined {