High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 11, 2026
OpenClaw 2.0.0-beta3 < 2026.2.14 - Arbitrary JavaScript Module Loading via Hook Transform Path Traversal
CVE-2026-28393
Description
OpenClaw versions 2.0.0-beta3 prior to 2026.2.14 contain a path traversal vulnerability in hook transform module loading that allows arbitrary JavaScript execution. The hooks.mappings[].transform.module parameter accepts absolute paths and traversal sequences, enabling attackers with configuration write access to load and execute malicious modules with gateway process privileges.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2.0.0-beta3, < 2026.2.14 | 2026.2.14 |
Affected products
1Patches
218e8bd68c501fix(security): block hook manifest path escapes
3 files changed · +88 −2
CHANGELOG.md+2 −1 modified@@ -6,7 +6,8 @@ Docs: https://docs.openclaw.ai ### Fixes -- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). +- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Thanks @akhmittra. +- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery). ## 2026.2.13
src/hooks/workspace.test.ts+69 −0 added@@ -0,0 +1,69 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { loadHookEntriesFromDir } from "./workspace.js"; + +describe("hooks workspace", () => { + it("ignores package.json hook paths that traverse outside package directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-")); + const hooksRoot = path.join(root, "hooks"); + fs.mkdirSync(hooksRoot, { recursive: true }); + + const pkgDir = path.join(hooksRoot, "pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + + const outsideHookDir = path.join(root, "outside"); + fs.mkdirSync(outsideHookDir, { recursive: true }); + fs.writeFileSync(path.join(outsideHookDir, "HOOK.md"), "---\nname: outside\n---\n"); + fs.writeFileSync(path.join(outsideHookDir, "handler.js"), "export default async () => {};\n"); + + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify( + { + name: "pkg", + [MANIFEST_KEY]: { + hooks: ["../outside"], + }, + }, + null, + 2, + ), + ); + + const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); + expect(entries.some((e) => e.hook.name === "outside")).toBe(false); + }); + + it("accepts package.json hook paths within package directory", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-ok-")); + const hooksRoot = path.join(root, "hooks"); + fs.mkdirSync(hooksRoot, { recursive: true }); + + const pkgDir = path.join(hooksRoot, "pkg"); + const nested = path.join(pkgDir, "nested"); + fs.mkdirSync(nested, { recursive: true }); + + fs.writeFileSync(path.join(nested, "HOOK.md"), "---\nname: nested\n---\n"); + fs.writeFileSync(path.join(nested, "handler.js"), "export default async () => {};\n"); + + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify( + { + name: "pkg", + [MANIFEST_KEY]: { + hooks: ["./nested"], + }, + }, + null, + 2, + ), + ); + + const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" }); + expect(entries.some((e) => e.hook.name === "nested")).toBe(true); + }); +});
src/hooks/workspace.ts+17 −1 modified@@ -52,6 +52,16 @@ function resolvePackageHooks(manifest: HookPackageManifest): string[] { return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); } +function resolveContainedDir(baseDir: string, targetDir: string): string | null { + const base = path.resolve(baseDir); + const resolved = path.resolve(baseDir, targetDir); + const relative = path.relative(base, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return null; + } + return resolved; +} + function loadHookFromDir(params: { hookDir: string; source: HookSource; @@ -129,7 +139,13 @@ function loadHooksFromDir(params: { dir: string; source: HookSource; pluginId?: if (packageHooks.length > 0) { for (const hookPath of packageHooks) { - const resolvedHookDir = path.resolve(hookDir, hookPath); + const resolvedHookDir = resolveContainedDir(hookDir, hookPath); + if (!resolvedHookDir) { + console.warn( + `[hooks] Ignoring out-of-package hook path "${hookPath}" in ${hookDir} (must be within package directory)`, + ); + continue; + } const hook = loadHookFromDir({ hookDir: resolvedHookDir, source,
a0361b8ba959fix(security): restrict hook transform module loading
7 files changed · +198 −38
CHANGELOG.md+6 −0 modified@@ -2,6 +2,12 @@ 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 ### Changes
docs/automation/gmail-pubsub.md+1 −1 modified@@ -88,7 +88,7 @@ Notes: To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`. 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)
docs/automation/webhook.md+1 −1 modified@@ -139,7 +139,7 @@ Mapping options (summary): - `hooks.presets: ["gmail"]` enables the built-in Gmail mapping. - `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). - 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
docs/gateway/configuration-examples.md+2 −2 modified@@ -363,7 +363,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. path: "/hooks", token: "shared-secret", presets: ["gmail"], - transformsDir: "~/.openclaw/hooks", + transformsDir: "~/.openclaw/hooks/transforms", mappings: [ { id: "gmail-hook", @@ -380,7 +380,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. thinking: "low", timeoutSeconds: 300, transform: { - module: "./transforms/gmail.js", + module: "gmail.js", export: "transformGmail", }, },
docs/gateway/configuration-reference.md+1 −1 modified@@ -1987,7 +1987,7 @@ See [Multiple Gateways](/gateway/multiple-gateways). allowedSessionKeyPrefixes: ["hook:"], allowedAgentIds: ["hooks", "main"], presets: ["gmail"], - transformsDir: "~/.openclaw/hooks", + transformsDir: "~/.openclaw/hooks/transforms", mappings: [ { match: { path: "gmail" },
src/gateway/hooks-mapping.test.ts+148 −23 modified@@ -62,24 +62,28 @@ describe("hooks mapping", () => { }); it("runs transform module", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-")); - const modPath = path.join(dir, "transform.mjs"); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + fs.mkdirSync(transformsRoot, { recursive: true }); + const modPath = path.join(transformsRoot, "transform.mjs"); const placeholder = "${payload.name}"; fs.writeFileSync( modPath, `export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`, ); - const mappings = resolveHookMappings({ - transformsDir: dir, - mappings: [ - { - match: { path: "custom" }, - action: "agent", - transform: { module: "transform.mjs" }, - }, - ], - }); + const mappings = resolveHookMappings( + { + mappings: [ + { + match: { path: "custom" }, + action: "agent", + transform: { module: "transform.mjs" }, + }, + ], + }, + { configDir }, + ); const result = await applyHookMappings(mappings, { payload: { name: "Ada" }, @@ -97,22 +101,143 @@ describe("hooks mapping", () => { } }); - it("treats null transform as a handled skip", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-skip-")); - const modPath = path.join(dir, "transform.mjs"); - fs.writeFileSync(modPath, "export default () => null;"); + it("rejects transform module traversal outside transformsDir", () => { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-traversal-")); + const transformsRoot = path.join(configDir, "hooks", "transforms"); + 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({ - transformsDir: dir, - mappings: [ + it("rejects absolute transform module path outside transformsDir", () => { + 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( { - match: { path: "skip" }, - action: "agent", - transform: { module: "transform.mjs" }, + 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: [ + { + match: { path: "skip" }, + action: "agent", + 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, { payload: {}, headers: {},
src/gateway/hooks-mapping.ts+39 −10 modified@@ -102,7 +102,10 @@ type HookTransformFn = ( ctx: HookMappingContext, ) => HookTransformResult | Promise<HookTransformResult>; -export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] { +export function resolveHookMappings( + hooks?: HooksConfig, + opts?: { configDir?: string }, +): HookMappingResolved[] { const presets = hooks?.presets ?? []; const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent; const mappings: HookMappingConfig[] = []; @@ -129,10 +132,13 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] return []; } - const configDir = path.dirname(CONFIG_PATH); - const transformsDir = hooks?.transformsDir - ? resolvePath(configDir, hooks.transformsDir) - : configDir; + const configDir = path.resolve(opts?.configDir ?? path.dirname(CONFIG_PATH)); + const transformsRootDir = path.join(configDir, "hooks", "transforms"); + const transformsDir = resolveOptionalContainedPath( + transformsRootDir, + hooks?.transformsDir, + "Hook transformsDir", + ); return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir)); } @@ -187,7 +193,7 @@ function normalizeHookMapping( const wakeMode = mapping.wakeMode ?? "now"; const transform = mapping.transform ? { - modulePath: resolvePath(transformsDir, mapping.transform.module), + modulePath: resolveContainedPath(transformsDir, mapping.transform.module, "Hook transform"), exportName: mapping.transform.export?.trim() || undefined, } : undefined; @@ -340,12 +346,35 @@ function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): function resolvePath(baseDir: string, target: string): string { if (!target) { - return baseDir; + return path.resolve(baseDir); } - if (path.isAbsolute(target)) { - return target; + return path.isAbsolute(target) ? path.resolve(target) : path.resolve(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 path.join(baseDir, target); + return resolveContainedPath(baseDir, trimmed, label); } function normalizeMatchPath(raw?: string): string | undefined {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/18e8bd68c5015a894f999c6d5e6e32468965bfb5ghsapatchWEB
- github.com/openclaw/openclaw/commit/a0361b8ba959e8506dc79d638b6e6a00d12887e4ghsapatchWEB
- github.com/advisories/GHSA-7xhj-55q9-pc3mghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-7xhj-55q9-pc3mghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-28393ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-beta-arbitrary-javascript-module-loading-via-hook-transform-path-traversalghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.