fix(security): share outbound media source handling

This commit is contained in:
Gustavo Madeira Santana
2026-04-13 17:55:25 -04:00
parent 75dfb15781
commit 030dab372f
7 changed files with 55 additions and 22 deletions

View File

@@ -209,6 +209,7 @@ Docs: https://docs.openclaw.ai
- Agents/failover: scope assistant-side fallback classification and surfaced provider errors to the current attempt instead of stale session history, so cross-provider fallback runs stop inheriting the previous provider's failure. (#62907) Thanks @stainlu.
- MiniMax/OAuth: write `api: "anthropic-messages"` and `authHeader: true` into the `minimax-portal` config patch during `openclaw configure`, so re-authenticated portal setups keep Bearer auth routing working. (#64964) Thanks @ryanlee666.
- Agents/tools: stop repeated unavailable-tool retries from escaping loop detection when the model changes arguments, and rewrite over-threshold unknown tool calls into plain assistant text before dispatch. (#65922) Thanks @dutifulbob.
- Matrix/security: normalize sandboxed profile avatar params, preserve `mxc://` avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.
## 2026.4.10

View File

@@ -130,6 +130,16 @@ describe("resolveSandboxedMediaSource", () => {
});
});
it("preserves remote mxc:// media sources", async () => {
await withSandboxRoot(async (sandboxDir) => {
const result = await resolveSandboxedMediaSource({
media: "mxc://matrix.org/abc123def456",
sandboxRoot: sandboxDir,
});
expect(result).toBe("mxc://matrix.org/abc123def456");
});
});
// Group 3: Rejections (security)
it.each([
{

View File

@@ -10,11 +10,10 @@ import {
import { assertNoPathAliasEscape, type PathAliasPolicy } from "../infra/path-alias-guards.js";
import { isPathInside } from "../infra/path-guards.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { isPassThroughRemoteMediaSource } from "../media/media-source-url.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const HTTP_URL_RE = /^https?:\/\//i;
const DATA_URL_RE = /^data:/i;
const MXC_URL_RE = /^mxc:\/\//i;
const SANDBOX_CONTAINER_WORKDIR = "/workspace";
function normalizeUnicodeSpaces(str: string): string {
@@ -109,10 +108,7 @@ export async function resolveSandboxedMediaSource(params: {
if (!raw) {
return raw;
}
if (HTTP_URL_RE.test(raw)) {
return raw;
}
if (MXC_URL_RE.test(raw)) {
if (isPassThroughRemoteMediaSource(raw)) {
return raw;
}
let candidate = raw;

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
collectActionMediaSourceHints,
hydrateAttachmentParamsForAction,
normalizeSandboxMediaList,
normalizeSandboxMediaParams,
@@ -129,6 +130,24 @@ describe("message action media helpers", () => {
}
});
it("collects host media source hints from the shared media-source key set", () => {
expect(
collectActionMediaSourceHints({
media: " /workspace/uploads/photo.png ",
filePath: "",
image: "file:///workspace/assets/event-cover.png",
avatarPath: "/workspace/avatars/profile.png",
avatar_url: "mxc://matrix.org/abc123def456",
ignored: "/workspace/not-included.png",
}),
).toEqual([
" /workspace/uploads/photo.png ",
"file:///workspace/assets/event-cover.png",
"/workspace/avatars/profile.png",
"mxc://matrix.org/abc123def456",
]);
});
maybeIt("normalizes Matrix snake_case avatar_path and avatar_url aliases", async () => {
const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "msg-params-avatar-snake-"));
try {

View File

@@ -13,10 +13,11 @@ import {
import { extensionForMime } from "../../media/mime.js";
import { loadWebMedia } from "../../media/web-media.js";
import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
export const readBooleanParam = readBooleanParamShared;
const SANDBOX_MEDIA_PARAM_KEYS = [
export const ACTION_MEDIA_SOURCE_PARAM_KEYS = [
"media",
"path",
"filePath",
@@ -31,11 +32,22 @@ const SANDBOX_MEDIA_PARAM_KEYS = [
function readMediaParam(
args: Record<string, unknown>,
key: (typeof SANDBOX_MEDIA_PARAM_KEYS)[number],
key: (typeof ACTION_MEDIA_SOURCE_PARAM_KEYS)[number],
): string | undefined {
return readStringParam(args, key, { trim: false });
}
export function collectActionMediaSourceHints(args: Record<string, unknown>): string[] {
const sources: string[] = [];
for (const key of ACTION_MEDIA_SOURCE_PARAM_KEYS) {
const source = typeof args[key] === "string" ? args[key] : undefined;
if (normalizeOptionalString(source)) {
sources.push(source);
}
}
return sources;
}
function readAttachmentMediaHint(args: Record<string, unknown>): string | undefined {
return readMediaParam(args, "media") ?? readMediaParam(args, "mediaUrl");
}
@@ -236,7 +248,7 @@ export async function normalizeSandboxMediaParams(params: {
}): Promise<void> {
const sandboxRoot =
params.mediaPolicy.mode === "sandbox" ? params.mediaPolicy.sandboxRoot.trim() : undefined;
for (const key of SANDBOX_MEDIA_PARAM_KEYS) {
for (const key of ACTION_MEDIA_SOURCE_PARAM_KEYS) {
const raw = readMediaParam(params.args, key);
if (!raw) {
continue;

View File

@@ -42,6 +42,7 @@ import {
import type { OutboundSendDeps } from "./deliver.js";
import { normalizeMessageActionInput } from "./message-action-normalization.js";
import {
collectActionMediaSourceHints,
hydrateAttachmentParamsForAction,
normalizeSandboxMediaList,
normalizeSandboxMediaParams,
@@ -203,19 +204,6 @@ async function resolveGatewayActionIdempotencyKey(idempotencyKey?: string): Prom
const { randomIdempotencyKey } = await loadMessageActionGatewayRuntime();
return randomIdempotencyKey();
}
function collectActionMediaSourceHints(params: Record<string, unknown>): string[] {
const sources: string[] = [];
for (const key of ["media", "mediaUrl", "path", "filePath", "fileUrl", "image", "avatarPath", "avatar_path", "avatarUrl", "avatar_url"] as const) {
const source = typeof params[key] === "string" ? params[key] : undefined;
const normalized = normalizeOptionalString(source);
if (normalized && source) {
sources.push(source);
}
}
return sources;
}
function applyCrossContextMessageDecoration({
params,
message,

View File

@@ -0,0 +1,7 @@
const HTTP_URL_RE = /^https?:\/\//i;
const MXC_URL_RE = /^mxc:\/\//i;
export function isPassThroughRemoteMediaSource(value: string | null | undefined): boolean {
const normalized = value?.trim() ?? "";
return Boolean(normalized) && (HTTP_URL_RE.test(normalized) || MXC_URL_RE.test(normalized));
}