VYPR
High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026

OpenClaw 2026.1.5 < 2026.2.14 - Arbitrary Code Execution via Unsafe Hook Module Path Handling

CVE-2026-28456

Description

OpenClaw versions 2026.1.5 prior to 2026.2.14 contain a vulnerability in the Gateway in which it does not sufficiently constrain configured hook module paths before passing them to dynamic import(), allowing code execution. An attacker with gateway configuration modification access can load and execute unintended local modules in the Node.js process.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.1.5, < 2026.2.142026.2.14

Affected products

1

Patches

2
35c0e66ed057

fix(security): harden hooks module loading

https://github.com/openclaw/openclawPeter SteinbergerFeb 14, 2026via ghsa
11 files changed · +145 20
  • CHANGELOG.md+0 1 modified
    @@ -11,7 +11,6 @@ Docs: https://docs.openclaw.ai
     - Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra.
     - Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
     - Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
    -
     ## 2026.2.13
     
     ### Changes
    
  • docs/automation/hooks.md+2 0 modified
    @@ -400,6 +400,8 @@ The old config format still works for backwards compatibility:
     }
     ```
     
    +Note: `module` must be a workspace-relative path. Absolute paths and traversal outside the workspace are rejected.
    +
     **Migration**: Use the new discovery-based system for new hooks. Legacy handlers are loaded after directory-based hooks.
     
     ## CLI Commands
    
  • docs/automation/webhook.md+3 1 modified
    @@ -139,7 +139,9 @@ 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 (restricted to `~/.openclaw/hooks/transforms`).
    +- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
    +  - `hooks.transformsDir` (if set) must stay within the transforms root under your OpenClaw config directory (typically `~/.openclaw/hooks/transforms`).
    +  - `transform.module` must resolve within the effective transforms directory (traversal/escape paths are rejected).
     - 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
    
  • src/config/config.hooks-module-paths.test.ts+58 0 added
    @@ -0,0 +1,58 @@
    +import { describe, expect, it } from "vitest";
    +import { validateConfigObjectWithPlugins } from "./config.js";
    +
    +describe("config hooks module paths", () => {
    +  it("rejects absolute hooks.mappings[].transform.module", () => {
    +    const res = validateConfigObjectWithPlugins({
    +      agents: { list: [{ id: "pi" }] },
    +      hooks: {
    +        mappings: [
    +          {
    +            match: { path: "custom" },
    +            action: "agent",
    +            transform: { module: "/tmp/transform.mjs" },
    +          },
    +        ],
    +      },
    +    });
    +    expect(res.ok).toBe(false);
    +    if (!res.ok) {
    +      expect(res.issues.some((iss) => iss.path === "hooks.mappings.0.transform.module")).toBe(true);
    +    }
    +  });
    +
    +  it("rejects escaping hooks.mappings[].transform.module", () => {
    +    const res = validateConfigObjectWithPlugins({
    +      agents: { list: [{ id: "pi" }] },
    +      hooks: {
    +        mappings: [
    +          {
    +            match: { path: "custom" },
    +            action: "agent",
    +            transform: { module: "../escape.mjs" },
    +          },
    +        ],
    +      },
    +    });
    +    expect(res.ok).toBe(false);
    +    if (!res.ok) {
    +      expect(res.issues.some((iss) => iss.path === "hooks.mappings.0.transform.module")).toBe(true);
    +    }
    +  });
    +
    +  it("rejects absolute hooks.internal.handlers[].module", () => {
    +    const res = validateConfigObjectWithPlugins({
    +      agents: { list: [{ id: "pi" }] },
    +      hooks: {
    +        internal: {
    +          enabled: true,
    +          handlers: [{ event: "command:new", module: "/tmp/handler.mjs" }],
    +        },
    +      },
    +    });
    +    expect(res.ok).toBe(false);
    +    if (!res.ok) {
    +      expect(res.issues.some((iss) => iss.path === "hooks.internal.handlers.0.module")).toBe(true);
    +    }
    +  });
    +});
    
  • src/config/types.hooks.ts+1 1 modified
    @@ -75,7 +75,7 @@ export type HooksGmailConfig = {
     export type InternalHookHandlerConfig = {
       /** Event key to listen for (e.g., 'command:new', 'session:start') */
       event: string;
    -  /** Path to handler module (absolute or relative to cwd) */
    +  /** Path to handler module (workspace-relative) */
       module: string;
       /** Export name from module (default: 'default') */
       export?: string;
    
  • src/config/zod-schema.hooks.ts+31 2 modified
    @@ -1,6 +1,35 @@
    +import path from "node:path";
     import { z } from "zod";
     import { sensitive } from "./zod-schema.sensitive.js";
     
    +function isSafeRelativeModulePath(raw: string): boolean {
    +  const value = raw.trim();
    +  if (!value) {
    +    return false;
    +  }
    +  // Hook modules are loaded via file-path resolution + dynamic import().
    +  // Keep this strictly relative to a configured base dir to avoid path traversal and surprises.
    +  if (path.isAbsolute(value)) {
    +    return false;
    +  }
    +  if (value.startsWith("~")) {
    +    return false;
    +  }
    +  // Disallow URL-ish and drive-relative forms (e.g. "file:...", "C:foo").
    +  if (value.includes(":")) {
    +    return false;
    +  }
    +  const parts = value.split(/[\\/]+/g);
    +  if (parts.some((part) => part === "..")) {
    +    return false;
    +  }
    +  return true;
    +}
    +
    +const SafeRelativeModulePathSchema = z
    +  .string()
    +  .refine(isSafeRelativeModulePath, "module must be a safe relative path (no absolute paths)");
    +
     export const HookMappingSchema = z
       .object({
         id: z.string().optional(),
    @@ -38,7 +67,7 @@ export const HookMappingSchema = z
         timeoutSeconds: z.number().int().positive().optional(),
         transform: z
           .object({
    -        module: z.string(),
    +        module: SafeRelativeModulePathSchema,
             export: z.string().optional(),
           })
           .strict()
    @@ -50,7 +79,7 @@ export const HookMappingSchema = z
     export const InternalHookHandlerSchema = z
       .object({
         event: z.string(),
    -    module: z.string(),
    +    module: SafeRelativeModulePathSchema,
         export: z.string().optional(),
       })
       .strict();
    
  • src/gateway/hooks-mapping.test.ts+0 1 modified
    @@ -217,7 +217,6 @@ describe("hooks mapping", () => {
           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");
    
  • src/gateway/server.auth.e2e.test.ts+15 0 modified
    @@ -144,6 +144,18 @@ describe("gateway server auth/connect", () => {
             signedAtMs,
             token: token ?? null,
           });
    +
    +      test("ignores requested scopes when device identity is omitted", async () => {
    +        const ws = await openWs(port);
    +        const res = await connectReq(ws, { device: null });
    +        expect(res.ok).toBe(true);
    +
    +        const health = await rpcReq(ws, "health");
    +        expect(health.ok).toBe(false);
    +        expect(health.error?.message).toContain("missing scope");
    +
    +        ws.close();
    +      });
           const device = {
             id: identity.deviceId,
             publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
    @@ -493,6 +505,9 @@ describe("gateway server auth/connect", () => {
           const ws = await openTailscaleWs(port);
           const res = await connectReq(ws, { token: "secret", device: null });
           expect(res.ok).toBe(true);
    +      const health = await rpcReq(ws, "health");
    +      expect(health.ok).toBe(false);
    +      expect(health.error?.message).toContain("missing scope");
           ws.close();
         });
       });
    
  • src/gateway/server/ws-connection/message-handler.ts+7 1 modified
    @@ -298,7 +298,9 @@ export function attachGatewayWsMessageHandler(params: {
               return;
             }
             // Default-deny: scopes must be explicit. Empty/missing scopes means no permissions.
    -        const scopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : [];
    +        // Note: If the client does not present a device identity, we can't bind scopes to a paired
    +        // device/token, so we will clear scopes after auth to avoid self-declared permissions.
    +        let scopes = Array.isArray(connectParams.scopes) ? connectParams.scopes : [];
             connectParams.role = role;
             connectParams.scopes = scopes;
     
    @@ -428,6 +430,10 @@ export function attachGatewayWsMessageHandler(params: {
               close(1008, truncateCloseReason(authMessage));
             };
             if (!device) {
    +          if (scopes.length > 0) {
    +            scopes = [];
    +            connectParams.scopes = scopes;
    +          }
               const canSkipDevice = sharedAuthOk;
     
               if (isControlUi && !allowControlUiBypass) {
    
  • src/hooks/loader.test.ts+9 9 modified
    @@ -79,7 +79,7 @@ describe("loader", () => {
                 handlers: [
                   {
                     event: "command:new",
    -                module: handlerPath,
    +                module: path.basename(handlerPath),
                   },
                 ],
               },
    @@ -106,8 +106,8 @@ describe("loader", () => {
               internal: {
                 enabled: true,
                 handlers: [
    -              { event: "command:new", module: handler1Path },
    -              { event: "command:stop", module: handler2Path },
    +              { event: "command:new", module: path.basename(handler1Path) },
    +              { event: "command:stop", module: path.basename(handler2Path) },
                 ],
               },
             },
    @@ -138,7 +138,7 @@ describe("loader", () => {
                 handlers: [
                   {
                     event: "command:new",
    -                module: handlerPath,
    +                module: path.basename(handlerPath),
                     export: "myHandler",
                   },
                 ],
    @@ -158,7 +158,7 @@ describe("loader", () => {
                 handlers: [
                   {
                     event: "command:new",
    -                module: "/nonexistent/path/handler.js",
    +                module: "missing-handler.js",
                   },
                 ],
               },
    @@ -182,7 +182,7 @@ describe("loader", () => {
                 handlers: [
                   {
                     event: "command:new",
    -                module: handlerPath,
    +                module: path.basename(handlerPath),
                   },
                 ],
               },
    @@ -199,8 +199,8 @@ describe("loader", () => {
           const handlerPath = path.join(tmpDir, "relative-handler.js");
           await fs.writeFile(handlerPath, "export default async function() {}", "utf-8");
     
    -      // Get relative path from cwd
    -      const relativePath = path.relative(process.cwd(), handlerPath);
    +      // Relative to workspaceDir (tmpDir)
    +      const relativePath = path.relative(tmpDir, handlerPath);
     
           const cfg: OpenClawConfig = {
             hooks: {
    @@ -241,7 +241,7 @@ describe("loader", () => {
                 handlers: [
                   {
                     event: "command:new",
    -                module: handlerPath,
    +                module: path.basename(handlerPath),
                   },
                 ],
               },
    
  • src/hooks/loader.ts+19 4 modified
    @@ -116,10 +116,25 @@ export async function loadInternalHooks(
       const handlers = cfg.hooks.internal.handlers ?? [];
       for (const handlerConfig of handlers) {
         try {
    -      // Resolve module path (absolute or relative to cwd)
    -      const modulePath = path.isAbsolute(handlerConfig.module)
    -        ? handlerConfig.module
    -        : path.join(process.cwd(), handlerConfig.module);
    +      // Legacy handler paths: keep them workspace-relative.
    +      const rawModule = handlerConfig.module.trim();
    +      if (!rawModule) {
    +        log.error("Handler module path is empty");
    +        continue;
    +      }
    +      if (path.isAbsolute(rawModule)) {
    +        log.error(
    +          `Handler module path must be workspace-relative (got absolute path): ${rawModule}`,
    +        );
    +        continue;
    +      }
    +      const baseDir = path.resolve(workspaceDir);
    +      const modulePath = path.resolve(baseDir, rawModule);
    +      const rel = path.relative(baseDir, modulePath);
    +      if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) {
    +        log.error(`Handler module path must stay within workspaceDir: ${rawModule}`);
    +        continue;
    +      }
     
           // Import the module with cache-busting to ensure fresh reload
           const url = pathToFileURL(modulePath).href;
    
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

7

News mentions

0

No linked articles in our index yet.