mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix: enforce inbound attachment root policy across pipelines
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ? `<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(() => []);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
78
src/media/inbound-path-policy.test.ts
Normal file
78
src/media/inbound-path-policy.test.ts
Normal file
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
150
src/media/inbound-path-policy.ts
Normal file
150
src/media/inbound-path-policy.ts
Normal file
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user