fix: enforce inbound attachment root policy across pipelines

This commit is contained in:
Peter Steinberger
2026-02-19 14:15:34 +01:00
parent cfe8457a0f
commit 1316e57403
16 changed files with 555 additions and 37 deletions

View File

@@ -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 () => {

View File

@@ -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;
}
}

View File

@@ -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({

View File

@@ -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 });
}
});
});

View File

@@ -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>>();