From 1316e5740382926e45a42097b4bfe0aef7d63e8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:15:34 +0100 Subject: [PATCH] fix: enforce inbound attachment root policy across pipelines --- docs/channels/imessage.md | 12 +- docs/gateway/configuration-reference.md | 3 + ...bound-media-into-sandbox-workspace.test.ts | 32 ++++ src/auto-reply/reply/stage-sandbox-media.ts | 32 +++- src/config/config.schema-regressions.test.ts | 28 ++++ src/config/types.imessage.ts | 4 + src/config/zod-schema.providers-core.ts | 7 + src/imessage/accounts.ts | 4 +- src/imessage/monitor/monitor-provider.ts | 35 +++- src/media-understanding/apply.ts | 19 ++- src/media-understanding/attachments.ts | 62 +++++++- src/media-understanding/audio-preflight.ts | 7 +- .../media-understanding-misc.test.ts | 59 +++++++ src/media-understanding/runner.ts | 60 +++++-- src/media/inbound-path-policy.test.ts | 78 +++++++++ src/media/inbound-path-policy.ts | 150 ++++++++++++++++++ 16 files changed, 555 insertions(+), 37 deletions(-) create mode 100644 src/media/inbound-path-policy.test.ts create mode 100644 src/media/inbound-path-policy.ts diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 4adffd7f41..d7a1b63359 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -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`). @@ -233,7 +238,7 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" 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. @@ -244,6 +249,10 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" - 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) @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 0ca00016ee..8f31cea128 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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:` 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`. diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index f938977c66..671c94bb10 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -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"); + }); + }); }); diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 73a2f69e94..b8f1ff9b5b 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -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(); const staged = new Map(); // 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, diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 0639bc3eb0..b211b8808a 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -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"); + } + }); }); diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index fc4fd4d54b..f3a225bdd8 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -50,6 +50,10 @@ export type IMessageAccountConfig = { dms?: Record; /** 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). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 24ac98c584..3bf1fa66ea 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), diff --git a/src/imessage/accounts.ts b/src/imessage/accounts.ts index 764c1dd39e..c9c0447fee 100644 --- a/src/imessage/accounts.ts +++ b/src/imessage/accounts.ts @@ -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), diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts index a21da04a20..80be651930 100644 --- a/src/imessage/monitor/monitor-provider.ts +++ b/src/imessage/monitor/monitor-provider.ts @@ -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,8 +233,18 @@ 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; @@ -229,7 +252,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P 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 ? `` : attachments?.length ? "" : ""; + const placeholder = kind + ? `` + : validAttachments.length + ? "" + : ""; const bodyText = messageText || placeholder; const storeAllowFrom = await readChannelAllowFromStore("imessage").catch(() => []); diff --git a/src/media-understanding/apply.ts b/src/media-understanding/apply.ts index 6de29873bb..331122665b 100644 --- a/src/media-understanding/apply.ts +++ b/src/media-understanding/apply.ts @@ -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 () => { diff --git a/src/media-understanding/attachments.ts b/src/media-understanding/attachments.ts index 14952b5319..62d7862734 100644 --- a/src/media-understanding/attachments.ts +++ b/src/media-understanding/attachments.ts @@ -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(); private readonly attachments: MediaAttachment[]; + private readonly localPathRoots: readonly string[]; + private canonicalLocalPathRoots?: Promise; - 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 { + 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; + } } diff --git a/src/media-understanding/audio-preflight.ts b/src/media-understanding/audio-preflight.ts index 2dc5157e7c..8d730eeaf7 100644 --- a/src/media-understanding/audio-preflight.ts +++ b/src/media-understanding/audio-preflight.ts @@ -1,5 +1,6 @@ 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 { @@ -7,9 +8,9 @@ import { 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({ diff --git a/src/media-understanding/media-understanding-misc.test.ts b/src/media-understanding/media-understanding-misc.test.ts index b48b9a2117..32e38577b5 100644 --- a/src/media-understanding/media-understanding-misc.test.ts +++ b/src/media-understanding/media-understanding-misc.test.ts @@ -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 }); + } + }); }); diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index bbe23a7b00..48f781e3c8 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -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>(); diff --git a/src/media/inbound-path-policy.test.ts b/src/media/inbound-path-policy.test.ts new file mode 100644 index 0000000000..3d7d300ec4 --- /dev/null +++ b/src/media/inbound-path-policy.test.ts @@ -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, + ]); + }); +}); diff --git a/src/media/inbound-path-policy.ts b/src/media/inbound-path-policy.ts new file mode 100644 index 0000000000..7b98ece42f --- /dev/null +++ b/src/media/inbound-path-policy.ts @@ -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(); + 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 +): string[] { + const merged: string[] = []; + const seen = new Set(); + 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, + ); +}