VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
>= 2.0.0-beta3, < 2026.2.142026.2.14

Affected products

1

Patches

2
18e8bd68c501

fix(security): block hook manifest path escapes

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
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,
    
a0361b8ba959

fix(security): restrict hook transform module loading

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.