mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 21:31:25 +00:00
fix: enforce inbound attachment root policy across pipelines
This commit is contained in:
@@ -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>>();
|
||||
|
||||
Reference in New Issue
Block a user