VYPR
High severityNVD Advisory· Published Mar 19, 2026· Updated Mar 25, 2026

OpenClaw < 2026.2.19 - Sensitive File Disclosure via stageSandboxMedia Path Traversal

CVE-2026-32030

Description

OpenClaw versions prior to 2026.2.19 contain a path traversal vulnerability in the stageSandboxMedia function that accepts arbitrary absolute paths when iMessage remote attachment fetching is enabled. An attacker who can tamper with attachment path metadata can disclose files readable by the OpenClaw process on the configured remote host via SCP.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.192026.2.19

Affected products

1

Patches

1
1316e5740382

fix: enforce inbound attachment root policy across pipelines

https://github.com/openclaw/openclawPeter SteinbergerFeb 19, 2026via ghsa
16 files changed · +555 37
  • docs/channels/imessage.md+11 1 modified
    @@ -97,6 +97,10 @@ exec ssh -T gateway-host imsg "$@"
           cliPath: "~/.openclaw/scripts/imsg-ssh",
           remoteHost: "user@gateway-host", // used for SCP attachment fetches
           includeAttachments: true,
    +      // Optional: override allowed attachment roots.
    +      // Defaults include /Users/*/Library/Messages/Attachments
    +      attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
    +      remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
         },
       },
     }
    @@ -105,6 +109,7 @@ exec ssh -T gateway-host imsg "$@"
         If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script.
         `remoteHost` must be `host` or `user@host` (no spaces or SSH options).
         OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`.
    +    Attachment paths are validated against allowed roots (`attachmentRoots` / `remoteAttachmentRoots`).
     
       </Tab>
     </Tabs>
    @@ -233,7 +238,7 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
       <Accordion title="Multi-account pattern">
         iMessage supports per-account config under `channels.imessage.accounts`.
     
    -    Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, and history settings.
    +    Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists.
     
       </Accordion>
     </AccordionGroup>
    @@ -244,6 +249,10 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
       <Accordion title="Attachments and media">
         - inbound attachment ingestion is optional: `channels.imessage.includeAttachments`
         - remote attachment paths can be fetched via SCP when `remoteHost` is set
    +    - attachment paths must match allowed roots:
    +      - `channels.imessage.attachmentRoots` (local)
    +      - `channels.imessage.remoteAttachmentRoots` (remote SCP mode)
    +      - default root pattern: `/Users/*/Library/Messages/Attachments`
         - SCP uses strict host-key checking (`StrictHostKeyChecking=yes`)
         - outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB)
       </Accordion>
    @@ -329,6 +338,7 @@ openclaw channels status --probe
         Check:
     
         - `channels.imessage.remoteHost`
    +    - `channels.imessage.remoteAttachmentRoots`
         - SSH/SCP key auth from the gateway host
         - host key exists in `~/.ssh/known_hosts` on the gateway host
         - remote path readability on the Mac running Messages
    
  • docs/gateway/configuration-reference.md+3 0 modified
    @@ -394,6 +394,8 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
           allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
           historyLimit: 50,
           includeAttachments: false,
    +      attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
    +      remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
           mediaMaxMb: 16,
           service: "auto",
           region: "US",
    @@ -405,6 +407,7 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
     - Requires Full Disk Access to the Messages DB.
     - Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
     - `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
    +- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
     - SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
     
     <Accordion title="iMessage SSH wrapper example">
    
  • src/auto-reply/reply/stage-sandbox-media.ts+30 2 modified
    @@ -2,14 +2,18 @@ import { spawn } from "node:child_process";
     import fs from "node:fs/promises";
     import path from "node:path";
     import { fileURLToPath } from "node:url";
    +import type { OpenClawConfig } from "../../config/config.js";
    +import type { MsgContext, TemplateContext } from "../templating.js";
     import { assertSandboxPath } from "../../agents/sandbox-paths.js";
     import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
    -import type { OpenClawConfig } from "../../config/config.js";
     import { logVerbose } from "../../globals.js";
     import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
    +import {
    +  isInboundPathAllowed,
    +  resolveIMessageRemoteAttachmentRoots,
    +} from "../../media/inbound-path-policy.js";
     import { getMediaDir } from "../../media/store.js";
     import { CONFIG_DIR } from "../../utils.js";
    -import type { MsgContext, TemplateContext } from "../templating.js";
     
     export async function stageSandboxMedia(params: {
       ctx: MsgContext;
    @@ -70,6 +74,10 @@ export async function stageSandboxMedia(params: {
           ? path.join(effectiveWorkspaceDir, "media", "inbound")
           : effectiveWorkspaceDir;
         await fs.mkdir(destDir, { recursive: true });
    +    const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
    +      cfg,
    +      accountId: ctx.AccountId,
    +    });
     
         const usedNames = new Set<string>();
         const staged = new Map<string, string>(); // absolute source -> relative sandbox path
    @@ -83,9 +91,29 @@ export async function stageSandboxMedia(params: {
             continue;
           }
     
    +      if (
    +        ctx.MediaRemoteHost &&
    +        !isInboundPathAllowed({
    +          filePath: source,
    +          roots: remoteAttachmentRoots,
    +        })
    +      ) {
    +        logVerbose(`Blocking remote media staging from disallowed attachment path: ${source}`);
    +        continue;
    +      }
    +
           // Local paths must be restricted to the media directory.
           if (!ctx.MediaRemoteHost) {
             const mediaDir = getMediaDir();
    +        if (
    +          !isInboundPathAllowed({
    +            filePath: source,
    +            roots: [mediaDir],
    +          })
    +        ) {
    +          logVerbose(`Blocking attempt to stage media from outside media directory: ${source}`);
    +          continue;
    +        }
             try {
               await assertSandboxPath({
                 filePath: source,
    
  • src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts+32 0 modified
    @@ -10,14 +10,19 @@ import {
     const sandboxMocks = vi.hoisted(() => ({
       ensureSandboxWorkspaceForSession: vi.fn(),
     }));
    +const childProcessMocks = vi.hoisted(() => ({
    +  spawn: vi.fn(),
    +}));
     
     vi.mock("../agents/sandbox.js", () => sandboxMocks);
    +vi.mock("node:child_process", () => childProcessMocks);
     
     import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
     import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
     
     afterEach(() => {
       vi.restoreAllMocks();
    +  childProcessMocks.spawn.mockReset();
     });
     
     describe("stageSandboxMedia", () => {
    @@ -86,4 +91,31 @@ describe("stageSandboxMedia", () => {
           expect(ctx.MediaPath).toBe(sensitiveFile);
         });
       });
    +
    +  it("blocks remote SCP staging for non-iMessage attachment paths", async () => {
    +    await withSandboxMediaTempHome("openclaw-triggers-remote-block-", async (home) => {
    +      const sandboxDir = join(home, "sandboxes", "session");
    +      vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
    +        workspaceDir: sandboxDir,
    +        containerWorkdir: "/work",
    +      });
    +
    +      const { ctx, sessionCtx } = createSandboxMediaContexts("/etc/passwd");
    +      ctx.Provider = "imessage";
    +      ctx.MediaRemoteHost = "user@gateway-host";
    +      sessionCtx.Provider = "imessage";
    +      sessionCtx.MediaRemoteHost = "user@gateway-host";
    +
    +      await stageSandboxMedia({
    +        ctx,
    +        sessionCtx,
    +        cfg: createSandboxMediaStageConfig(home),
    +        sessionKey: "agent:main:main",
    +        workspaceDir: join(home, "openclaw"),
    +      });
    +
    +      expect(childProcessMocks.spawn).not.toHaveBeenCalled();
    +      expect(ctx.MediaPath).toBe("/etc/passwd");
    +    });
    +  });
     });
    
  • src/config/config.schema-regressions.test.ts+28 0 modified
    @@ -63,4 +63,32 @@ describe("config schema regressions", () => {
           expect(res.issues[0]?.path).toBe("channels.imessage.remoteHost");
         }
       });
    +
    +  it("accepts iMessage attachment root patterns", () => {
    +    const res = validateConfigObject({
    +      channels: {
    +        imessage: {
    +          attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
    +          remoteAttachmentRoots: ["/Volumes/relay/attachments"],
    +        },
    +      },
    +    });
    +
    +    expect(res.ok).toBe(true);
    +  });
    +
    +  it("rejects relative iMessage attachment roots", () => {
    +    const res = validateConfigObject({
    +      channels: {
    +        imessage: {
    +          attachmentRoots: ["./attachments"],
    +        },
    +      },
    +    });
    +
    +    expect(res.ok).toBe(false);
    +    if (!res.ok) {
    +      expect(res.issues[0]?.path).toBe("channels.imessage.attachmentRoots.0");
    +    }
    +  });
     });
    
  • src/config/types.imessage.ts+4 0 modified
    @@ -50,6 +50,10 @@ export type IMessageAccountConfig = {
       dms?: Record<string, DmConfig>;
       /** Include attachments + reactions in watch payloads. */
       includeAttachments?: boolean;
    +  /** Allowed local iMessage attachment roots (supports single-segment `*` wildcards). */
    +  attachmentRoots?: string[];
    +  /** Allowed remote iMessage attachment roots for SCP fetches (supports `*`). */
    +  remoteAttachmentRoots?: string[];
       /** Max outbound media size in MB. */
       mediaMaxMb?: number;
       /** Timeout for probe/RPC operations in milliseconds (default: 10000). */
    
  • src/config/zod-schema.providers-core.ts+7 0 modified
    @@ -1,5 +1,6 @@
     import { z } from "zod";
     import { isSafeScpRemoteHost } from "../infra/scp-host.js";
    +import { isValidInboundPathRootPattern } from "../media/inbound-path-policy.js";
     import {
       normalizeTelegramCommandDescription,
       normalizeTelegramCommandName,
    @@ -819,6 +820,12 @@ export const IMessageAccountSchemaBase = z
         dmHistoryLimit: z.number().int().min(0).optional(),
         dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
         includeAttachments: z.boolean().optional(),
    +    attachmentRoots: z
    +      .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
    +      .optional(),
    +    remoteAttachmentRoots: z
    +      .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
    +      .optional(),
         mediaMaxMb: z.number().int().positive().optional(),
         textChunkLimit: z.number().int().positive().optional(),
         chunkMode: z.enum(["length", "newline"]).optional(),
    
  • src/imessage/accounts.ts+3 1 modified
    @@ -1,6 +1,6 @@
    -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
     import type { OpenClawConfig } from "../config/config.js";
     import type { IMessageAccountConfig } from "../config/types.js";
    +import { createAccountListHelpers } from "../channels/plugins/account-helpers.js";
     import { normalizeAccountId } from "../routing/session-key.js";
     
     export type ResolvedIMessageAccount = {
    @@ -51,6 +51,8 @@ export function resolveIMessageAccount(params: {
         merged.dmPolicy ||
         merged.groupPolicy ||
         typeof merged.includeAttachments === "boolean" ||
    +    (merged.attachmentRoots && merged.attachmentRoots.length > 0) ||
    +    (merged.remoteAttachmentRoots && merged.remoteAttachmentRoots.length > 0) ||
         typeof merged.mediaMaxMb === "number" ||
         typeof merged.textChunkLimit === "number" ||
         (merged.groups && Object.keys(merged.groups).length > 0),
    
  • src/imessage/monitor/monitor-provider.ts+31 4 modified
    @@ -1,4 +1,5 @@
     import fs from "node:fs/promises";
    +import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
     import { resolveHumanDelayConfig } from "../../agents/identity.js";
     import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
     import { hasControlCommand } from "../../auto-reply/command-detection.js";
    @@ -21,6 +22,11 @@ import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
     import { normalizeScpRemoteHost } from "../../infra/scp-host.js";
     import { waitForTransportReady } from "../../infra/transport-ready.js";
     import { mediaKindFromMime } from "../../media/constants.js";
    +import {
    +  isInboundPathAllowed,
    +  resolveIMessageAttachmentRoots,
    +  resolveIMessageRemoteAttachmentRoots,
    +} from "../../media/inbound-path-policy.js";
     import { buildPairingReply } from "../../pairing/pairing-messages.js";
     import {
       readChannelAllowFromStore,
    @@ -40,7 +46,6 @@ import {
     } from "./inbound-processing.js";
     import { parseIMessageNotification } from "./parse-notification.js";
     import { normalizeAllowList, resolveRuntime } from "./runtime.js";
    -import type { IMessagePayload, MonitorIMessageOpts } from "./types.js";
     
     /**
      * Try to detect remote host from an SSH wrapper script like:
    @@ -146,6 +151,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
       const cliPath = opts.cliPath ?? imessageCfg.cliPath ?? "imsg";
       const dbPath = opts.dbPath ?? imessageCfg.dbPath;
       const probeTimeoutMs = imessageCfg.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS;
    +  const attachmentRoots = resolveIMessageAttachmentRoots({
    +    cfg,
    +    accountId: accountInfo.accountId,
    +  });
    +  const remoteAttachmentRoots = resolveIMessageRemoteAttachmentRoots({
    +    cfg,
    +    accountId: accountInfo.accountId,
    +  });
     
       // Resolve remoteHost: explicit config, or auto-detect from SSH wrapper script.
       // Accept only a safe host token to avoid option/argument injection into SCP.
    @@ -220,16 +233,30 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
         const messageText = (message.text ?? "").trim();
     
         const attachments = includeAttachments ? (message.attachments ?? []) : [];
    -    // Filter to valid attachments with paths
    -    const validAttachments = attachments.filter((entry) => entry?.original_path && !entry?.missing);
    +    const effectiveAttachmentRoots = remoteHost ? remoteAttachmentRoots : attachmentRoots;
    +    const validAttachments = attachments.filter((entry) => {
    +      const attachmentPath = entry?.original_path?.trim();
    +      if (!attachmentPath || entry?.missing) {
    +        return false;
    +      }
    +      if (isInboundPathAllowed({ filePath: attachmentPath, roots: effectiveAttachmentRoots })) {
    +        return true;
    +      }
    +      logVerbose(`imessage: dropping inbound attachment outside allowed roots: ${attachmentPath}`);
    +      return false;
    +    });
         const firstAttachment = validAttachments[0];
         const mediaPath = firstAttachment?.original_path ?? undefined;
         const mediaType = firstAttachment?.mime_type ?? undefined;
         // Build arrays for all attachments (for multi-image support)
         const mediaPaths = validAttachments.map((a) => a.original_path).filter(Boolean) as string[];
         const mediaTypes = validAttachments.map((a) => a.mime_type ?? undefined);
         const kind = mediaKindFromMime(mediaType ?? undefined);
    -    const placeholder = kind ? `<media:${kind}>` : attachments?.length ? "<media:attachment>" : "";
    +    const placeholder = kind
    +      ? `<media:${kind}>`
    +      : validAttachments.length
    +        ? "<media:attachment>"
    +        : "";
         const bodyText = messageText || placeholder;
     
         const storeAllowFrom = await readChannelAllowFromStore("imessage").catch(() => []);
    
  • src/media/inbound-path-policy.test.ts+78 0 added
    @@ -0,0 +1,78 @@
    +import { describe, expect, it } from "vitest";
    +import type { OpenClawConfig } from "../config/config.js";
    +import {
    +  DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
    +  isInboundPathAllowed,
    +  isValidInboundPathRootPattern,
    +  mergeInboundPathRoots,
    +  resolveIMessageAttachmentRoots,
    +  resolveIMessageRemoteAttachmentRoots,
    +} from "./inbound-path-policy.js";
    +
    +describe("inbound-path-policy", () => {
    +  it("validates absolute root patterns", () => {
    +    expect(isValidInboundPathRootPattern("/Users/*/Library/Messages/Attachments")).toBe(true);
    +    expect(isValidInboundPathRootPattern("/Volumes/relay/attachments")).toBe(true);
    +    expect(isValidInboundPathRootPattern("./attachments")).toBe(false);
    +    expect(isValidInboundPathRootPattern("/Users/**/Attachments")).toBe(false);
    +  });
    +
    +  it("matches wildcard roots for iMessage attachment paths", () => {
    +    const roots = ["/Users/*/Library/Messages/Attachments"];
    +    expect(
    +      isInboundPathAllowed({
    +        filePath: "/Users/alice/Library/Messages/Attachments/12/34/ABCDEF/IMG_0001.jpeg",
    +        roots,
    +      }),
    +    ).toBe(true);
    +    expect(
    +      isInboundPathAllowed({
    +        filePath: "/etc/passwd",
    +        roots,
    +      }),
    +    ).toBe(false);
    +  });
    +
    +  it("normalizes and de-duplicates merged roots", () => {
    +    const roots = mergeInboundPathRoots(
    +      ["/Users/*/Library/Messages/Attachments/", "/Users/*/Library/Messages/Attachments"],
    +      ["/Volumes/relay/attachments"],
    +    );
    +    expect(roots).toEqual(["/Users/*/Library/Messages/Attachments", "/Volumes/relay/attachments"]);
    +  });
    +
    +  it("resolves configured roots with account overrides", () => {
    +    const cfg = {
    +      channels: {
    +        imessage: {
    +          attachmentRoots: ["/Users/*/Library/Messages/Attachments"],
    +          remoteAttachmentRoots: ["/Volumes/shared/imessage"],
    +          accounts: {
    +            work: {
    +              attachmentRoots: ["/Users/work/Library/Messages/Attachments"],
    +              remoteAttachmentRoots: ["/srv/work/attachments"],
    +            },
    +          },
    +        },
    +      },
    +    } as OpenClawConfig;
    +    expect(resolveIMessageAttachmentRoots({ cfg, accountId: "work" })).toEqual([
    +      "/Users/work/Library/Messages/Attachments",
    +      "/Users/*/Library/Messages/Attachments",
    +    ]);
    +    expect(resolveIMessageRemoteAttachmentRoots({ cfg, accountId: "work" })).toEqual([
    +      "/srv/work/attachments",
    +      "/Volumes/shared/imessage",
    +      "/Users/work/Library/Messages/Attachments",
    +      "/Users/*/Library/Messages/Attachments",
    +    ]);
    +  });
    +
    +  it("falls back to default iMessage roots", () => {
    +    const cfg = {} as OpenClawConfig;
    +    expect(resolveIMessageAttachmentRoots({ cfg })).toEqual([...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS]);
    +    expect(resolveIMessageRemoteAttachmentRoots({ cfg })).toEqual([
    +      ...DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
    +    ]);
    +  });
    +});
    
  • src/media/inbound-path-policy.ts+150 0 added
    @@ -0,0 +1,150 @@
    +import path from "node:path";
    +import type { OpenClawConfig } from "../config/config.js";
    +
    +const WILDCARD_SEGMENT = "*";
    +const WINDOWS_DRIVE_ABS_RE = /^[A-Za-z]:\//;
    +const WINDOWS_DRIVE_ROOT_RE = /^[A-Za-z]:$/;
    +
    +export const DEFAULT_IMESSAGE_ATTACHMENT_ROOTS = ["/Users/*/Library/Messages/Attachments"] as const;
    +
    +function normalizePosixAbsolutePath(value: string): string | undefined {
    +  const trimmed = value.trim();
    +  if (!trimmed || trimmed.includes("\0")) {
    +    return undefined;
    +  }
    +  const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/"));
    +  const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized);
    +  if (!isAbsolute || normalized === "/") {
    +    return undefined;
    +  }
    +  const withoutTrailingSlash = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
    +  if (WINDOWS_DRIVE_ROOT_RE.test(withoutTrailingSlash)) {
    +    return undefined;
    +  }
    +  return withoutTrailingSlash;
    +}
    +
    +function splitPathSegments(value: string): string[] {
    +  return value.split("/").filter(Boolean);
    +}
    +
    +function matchesRootPattern(params: { candidatePath: string; rootPattern: string }): boolean {
    +  const candidateSegments = splitPathSegments(params.candidatePath);
    +  const rootSegments = splitPathSegments(params.rootPattern);
    +  if (candidateSegments.length < rootSegments.length) {
    +    return false;
    +  }
    +  for (let idx = 0; idx < rootSegments.length; idx += 1) {
    +    const expected = rootSegments[idx];
    +    const actual = candidateSegments[idx];
    +    if (expected === WILDCARD_SEGMENT) {
    +      continue;
    +    }
    +    if (expected !== actual) {
    +      return false;
    +    }
    +  }
    +  return true;
    +}
    +
    +export function isValidInboundPathRootPattern(value: string): boolean {
    +  const normalized = normalizePosixAbsolutePath(value);
    +  if (!normalized) {
    +    return false;
    +  }
    +  const segments = splitPathSegments(normalized);
    +  if (segments.length === 0) {
    +    return false;
    +  }
    +  return segments.every((segment) => segment === WILDCARD_SEGMENT || !segment.includes("*"));
    +}
    +
    +export function normalizeInboundPathRoots(roots?: readonly string[]): string[] {
    +  const normalized: string[] = [];
    +  const seen = new Set<string>();
    +  for (const root of roots ?? []) {
    +    if (typeof root !== "string") {
    +      continue;
    +    }
    +    if (!isValidInboundPathRootPattern(root)) {
    +      continue;
    +    }
    +    const candidate = normalizePosixAbsolutePath(root);
    +    if (!candidate || seen.has(candidate)) {
    +      continue;
    +    }
    +    seen.add(candidate);
    +    normalized.push(candidate);
    +  }
    +  return normalized;
    +}
    +
    +export function mergeInboundPathRoots(
    +  ...rootsLists: Array<readonly string[] | undefined>
    +): string[] {
    +  const merged: string[] = [];
    +  const seen = new Set<string>();
    +  for (const roots of rootsLists) {
    +    const normalized = normalizeInboundPathRoots(roots);
    +    for (const root of normalized) {
    +      if (seen.has(root)) {
    +        continue;
    +      }
    +      seen.add(root);
    +      merged.push(root);
    +    }
    +  }
    +  return merged;
    +}
    +
    +export function isInboundPathAllowed(params: {
    +  filePath: string;
    +  roots: readonly string[];
    +  fallbackRoots?: readonly string[];
    +}): boolean {
    +  const candidatePath = normalizePosixAbsolutePath(params.filePath);
    +  if (!candidatePath) {
    +    return false;
    +  }
    +  const roots = normalizeInboundPathRoots(params.roots);
    +  const effectiveRoots =
    +    roots.length > 0 ? roots : normalizeInboundPathRoots(params.fallbackRoots ?? undefined);
    +  if (effectiveRoots.length === 0) {
    +    return false;
    +  }
    +  return effectiveRoots.some((rootPattern) => matchesRootPattern({ candidatePath, rootPattern }));
    +}
    +
    +function resolveIMessageAccountConfig(params: { cfg: OpenClawConfig; accountId?: string | null }) {
    +  const accountId = params.accountId?.trim();
    +  if (!accountId) {
    +    return undefined;
    +  }
    +  return params.cfg.channels?.imessage?.accounts?.[accountId];
    +}
    +
    +export function resolveIMessageAttachmentRoots(params: {
    +  cfg: OpenClawConfig;
    +  accountId?: string | null;
    +}): string[] {
    +  const accountConfig = resolveIMessageAccountConfig(params);
    +  return mergeInboundPathRoots(
    +    accountConfig?.attachmentRoots,
    +    params.cfg.channels?.imessage?.attachmentRoots,
    +    DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
    +  );
    +}
    +
    +export function resolveIMessageRemoteAttachmentRoots(params: {
    +  cfg: OpenClawConfig;
    +  accountId?: string | null;
    +}): string[] {
    +  const accountConfig = resolveIMessageAccountConfig(params);
    +  return mergeInboundPathRoots(
    +    accountConfig?.remoteAttachmentRoots,
    +    params.cfg.channels?.imessage?.remoteAttachmentRoots,
    +    accountConfig?.attachmentRoots,
    +    params.cfg.channels?.imessage?.attachmentRoots,
    +    DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
    +  );
    +}
    
  • src/media-understanding/apply.ts+11 8 modified
    @@ -1,7 +1,13 @@
     import path from "node:path";
    -import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
     import type { MsgContext } from "../auto-reply/templating.js";
     import type { OpenClawConfig } from "../config/config.js";
    +import type {
    +  MediaUnderstandingCapability,
    +  MediaUnderstandingDecision,
    +  MediaUnderstandingOutput,
    +  MediaUnderstandingProvider,
    +} from "./types.js";
    +import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
     import { logVerbose, shouldLogVerbose } from "../globals.js";
     import {
       extractFileContentFromSource,
    @@ -21,14 +27,9 @@ import {
       buildProviderRegistry,
       createMediaAttachmentCache,
       normalizeMediaAttachments,
    +  resolveMediaAttachmentLocalRoots,
       runCapability,
     } from "./runner.js";
    -import type {
    -  MediaUnderstandingCapability,
    -  MediaUnderstandingDecision,
    -  MediaUnderstandingOutput,
    -  MediaUnderstandingProvider,
    -} from "./types.js";
     
     export type ApplyMediaUnderstandingResult = {
       outputs: MediaUnderstandingOutput[];
    @@ -473,7 +474,9 @@ export async function applyMediaUnderstanding(params: {
     
       const attachments = normalizeMediaAttachments(ctx);
       const providerRegistry = buildProviderRegistry(params.providers);
    -  const cache = createMediaAttachmentCache(attachments);
    +  const cache = createMediaAttachmentCache(attachments, {
    +    localPathRoots: resolveMediaAttachmentLocalRoots({ cfg, ctx }),
    +  });
     
       try {
         const tasks = CAPABILITY_ORDER.map((capability) => async () => {
    
  • src/media-understanding/attachments.ts+60 2 modified
    @@ -7,6 +7,12 @@ import type { MediaAttachment, MediaUnderstandingCapability } from "./types.js";
     import { logVerbose, shouldLogVerbose } from "../globals.js";
     import { isAbortError } from "../infra/unhandled-rejections.js";
     import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js";
    +import {
    +  DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
    +  isInboundPathAllowed,
    +  mergeInboundPathRoots,
    +} from "../media/inbound-path-policy.js";
    +import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
     import { detectMime, getFileExtension, isAudioFileName, kindFromMime } from "../media/mime.js";
     import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js";
     import { MediaUnderstandingSkipError } from "./errors.js";
    @@ -36,6 +42,14 @@ type AttachmentCacheEntry = {
     };
     
     const DEFAULT_MAX_ATTACHMENTS = 1;
    +const DEFAULT_LOCAL_PATH_ROOTS = mergeInboundPathRoots(
    +  getDefaultMediaLocalRoots(),
    +  DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
    +);
    +
    +export type MediaAttachmentCacheOptions = {
    +  localPathRoots?: readonly string[];
    +};
     
     function normalizeAttachmentPath(raw?: string | null): string | undefined {
       const value = raw?.trim();
    @@ -209,9 +223,12 @@ export function selectAttachments(params: {
     export class MediaAttachmentCache {
       private readonly entries = new Map<number, AttachmentCacheEntry>();
       private readonly attachments: MediaAttachment[];
    +  private readonly localPathRoots: readonly string[];
    +  private canonicalLocalPathRoots?: Promise<readonly string[]>;
     
    -  constructor(attachments: MediaAttachment[]) {
    +  constructor(attachments: MediaAttachment[], options?: MediaAttachmentCacheOptions) {
         this.attachments = attachments;
    +    this.localPathRoots = mergeInboundPathRoots(options?.localPathRoots, DEFAULT_LOCAL_PATH_ROOTS);
         for (const attachment of attachments) {
           this.entries.set(attachment.index, { attachment });
         }
    @@ -405,15 +422,37 @@ export class MediaAttachmentCache {
         if (!entry.resolvedPath) {
           return undefined;
         }
    +    if (!isInboundPathAllowed({ filePath: entry.resolvedPath, roots: this.localPathRoots })) {
    +      entry.resolvedPath = undefined;
    +      if (shouldLogVerbose()) {
    +        logVerbose(
    +          `Blocked attachment path outside allowed roots: ${entry.attachment.path ?? entry.attachment.url ?? "(unknown)"}`,
    +        );
    +      }
    +      return undefined;
    +    }
         if (entry.statSize !== undefined) {
           return entry.statSize;
         }
         try {
    -      const stat = await fs.stat(entry.resolvedPath);
    +      const currentPath = entry.resolvedPath;
    +      const stat = await fs.stat(currentPath);
           if (!stat.isFile()) {
             entry.resolvedPath = undefined;
             return undefined;
           }
    +      const canonicalPath = await fs.realpath(currentPath).catch(() => currentPath);
    +      const canonicalRoots = await this.getCanonicalLocalPathRoots();
    +      if (!isInboundPathAllowed({ filePath: canonicalPath, roots: canonicalRoots })) {
    +        entry.resolvedPath = undefined;
    +        if (shouldLogVerbose()) {
    +          logVerbose(
    +            `Blocked canonicalized attachment path outside allowed roots: ${canonicalPath}`,
    +          );
    +        }
    +        return undefined;
    +      }
    +      entry.resolvedPath = canonicalPath;
           entry.statSize = stat.size;
           return stat.size;
         } catch (err) {
    @@ -424,4 +463,23 @@ export class MediaAttachmentCache {
           return undefined;
         }
       }
    +
    +  private async getCanonicalLocalPathRoots(): Promise<readonly string[]> {
    +    if (this.canonicalLocalPathRoots) {
    +      return await this.canonicalLocalPathRoots;
    +    }
    +    this.canonicalLocalPathRoots = (async () =>
    +      mergeInboundPathRoots(
    +        this.localPathRoots,
    +        await Promise.all(
    +          this.localPathRoots.map(async (root) => {
    +            if (root.includes("*")) {
    +              return root;
    +            }
    +            return await fs.realpath(root).catch(() => root);
    +          }),
    +        ),
    +      ))();
    +    return await this.canonicalLocalPathRoots;
    +  }
     }
    
  • src/media-understanding/audio-preflight.ts+5 2 modified
    @@ -1,15 +1,16 @@
     import type { MsgContext } from "../auto-reply/templating.js";
     import type { OpenClawConfig } from "../config/config.js";
    +import type { MediaUnderstandingProvider } from "./types.js";
     import { logVerbose, shouldLogVerbose } from "../globals.js";
     import { isAudioAttachment } from "./attachments.js";
     import {
       type ActiveMediaModel,
       buildProviderRegistry,
       createMediaAttachmentCache,
       normalizeMediaAttachments,
    +  resolveMediaAttachmentLocalRoots,
       runCapability,
     } from "./runner.js";
    -import type { MediaUnderstandingProvider } from "./types.js";
     
     /**
      * Transcribes the first audio attachment BEFORE mention checking.
    @@ -50,7 +51,9 @@ export async function transcribeFirstAudio(params: {
       }
     
       const providerRegistry = buildProviderRegistry(params.providers);
    -  const cache = createMediaAttachmentCache(attachments);
    +  const cache = createMediaAttachmentCache(attachments, {
    +    localPathRoots: resolveMediaAttachmentLocalRoots({ cfg, ctx }),
    +  });
     
       try {
         const result = await runCapability({
    
  • src/media-understanding/media-understanding-misc.test.ts+59 0 modified
    @@ -1,3 +1,6 @@
    +import fs from "node:fs/promises";
    +import os from "node:os";
    +import path from "node:path";
     import { afterEach, describe, expect, it, vi } from "vitest";
     import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
     import { MediaAttachmentCache } from "./attachments.js";
    @@ -39,4 +42,60 @@ describe("media understanding attachments SSRF", () => {
     
         expect(fetchSpy).not.toHaveBeenCalled();
       });
    +
    +  it("reads local attachments inside configured roots", async () => {
    +    const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cache-allowed-"));
    +    try {
    +      const allowedRoot = path.join(base, "allowed");
    +      const attachmentPath = path.join(allowedRoot, "voice-note.m4a");
    +      await fs.mkdir(allowedRoot, { recursive: true });
    +      await fs.writeFile(attachmentPath, "ok");
    +
    +      const cache = new MediaAttachmentCache([{ index: 0, path: attachmentPath }], {
    +        localPathRoots: [allowedRoot],
    +      });
    +
    +      const result = await cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 });
    +      expect(result.buffer.toString()).toBe("ok");
    +    } finally {
    +      await fs.rm(base, { recursive: true, force: true });
    +    }
    +  });
    +
    +  it("blocks local attachments outside configured roots", async () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const cache = new MediaAttachmentCache([{ index: 0, path: "/etc/passwd" }], {
    +      localPathRoots: ["/Users/*/Library/Messages/Attachments"],
    +    });
    +
    +    await expect(
    +      cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 }),
    +    ).rejects.toThrow(/has no path or URL/i);
    +  });
    +
    +  it("blocks symlink escapes that resolve outside configured roots", async () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-cache-symlink-"));
    +    try {
    +      const allowedRoot = path.join(base, "allowed");
    +      const outsidePath = "/etc/passwd";
    +      const symlinkPath = path.join(allowedRoot, "note.txt");
    +      await fs.mkdir(allowedRoot, { recursive: true });
    +      await fs.symlink(outsidePath, symlinkPath);
    +
    +      const cache = new MediaAttachmentCache([{ index: 0, path: symlinkPath }], {
    +        localPathRoots: [allowedRoot],
    +      });
    +
    +      await expect(
    +        cache.getBuffer({ attachmentIndex: 0, maxBytes: 1024, timeoutMs: 1000 }),
    +      ).rejects.toThrow(/has no path or URL/i);
    +    } finally {
    +      await fs.rm(base, { recursive: true, force: true });
    +    }
    +  });
     });
    
  • src/media-understanding/runner.ts+43 17 modified
    @@ -2,21 +2,39 @@ import { constants as fsConstants } from "node:fs";
     import fs from "node:fs/promises";
     import os from "node:os";
     import path from "node:path";
    -import { resolveApiKeyForProvider } from "../agents/model-auth.js";
    -import {
    -  findModelInCatalog,
    -  loadModelCatalog,
    -  modelSupportsVision,
    -} from "../agents/model-catalog.js";
     import type { MsgContext } from "../auto-reply/templating.js";
     import type { OpenClawConfig } from "../config/config.js";
     import type {
       MediaUnderstandingConfig,
       MediaUnderstandingModelConfig,
     } from "../config/types.tools.js";
    +import type {
    +  MediaAttachment,
    +  MediaUnderstandingCapability,
    +  MediaUnderstandingDecision,
    +  MediaUnderstandingModelDecision,
    +  MediaUnderstandingOutput,
    +  MediaUnderstandingProvider,
    +} from "./types.js";
    +import { resolveApiKeyForProvider } from "../agents/model-auth.js";
    +import {
    +  findModelInCatalog,
    +  loadModelCatalog,
    +  modelSupportsVision,
    +} from "../agents/model-catalog.js";
     import { logVerbose, shouldLogVerbose } from "../globals.js";
    +import {
    +  mergeInboundPathRoots,
    +  resolveIMessageAttachmentRoots,
    +} from "../media/inbound-path-policy.js";
    +import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
     import { runExec } from "../process/exec.js";
    -import { MediaAttachmentCache, normalizeAttachments, selectAttachments } from "./attachments.js";
    +import {
    +  MediaAttachmentCache,
    +  type MediaAttachmentCacheOptions,
    +  normalizeAttachments,
    +  selectAttachments,
    +} from "./attachments.js";
     import {
       AUTO_AUDIO_KEY_PROVIDERS,
       AUTO_IMAGE_KEY_PROVIDERS,
    @@ -38,14 +56,6 @@ import {
       runCliEntry,
       runProviderEntry,
     } from "./runner.entries.js";
    -import type {
    -  MediaAttachment,
    -  MediaUnderstandingCapability,
    -  MediaUnderstandingDecision,
    -  MediaUnderstandingModelDecision,
    -  MediaUnderstandingOutput,
    -  MediaUnderstandingProvider,
    -} from "./types.js";
     
     export type ActiveMediaModel = {
       provider: string;
    @@ -69,8 +79,24 @@ export function normalizeMediaAttachments(ctx: MsgContext): MediaAttachment[] {
       return normalizeAttachments(ctx);
     }
     
    -export function createMediaAttachmentCache(attachments: MediaAttachment[]): MediaAttachmentCache {
    -  return new MediaAttachmentCache(attachments);
    +export function resolveMediaAttachmentLocalRoots(params: {
    +  cfg: OpenClawConfig;
    +  ctx: MsgContext;
    +}): readonly string[] {
    +  return mergeInboundPathRoots(
    +    getDefaultMediaLocalRoots(),
    +    resolveIMessageAttachmentRoots({
    +      cfg: params.cfg,
    +      accountId: params.ctx.AccountId,
    +    }),
    +  );
    +}
    +
    +export function createMediaAttachmentCache(
    +  attachments: MediaAttachment[],
    +  options?: MediaAttachmentCacheOptions,
    +): MediaAttachmentCache {
    +  return new MediaAttachmentCache(attachments, options);
     }
     
     const binaryCache = new Map<string, Promise<string | null>>();
    

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

5

News mentions

0

No linked articles in our index yet.