mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 06:07:28 +00:00
fix: enforce inbound attachment root policy across pipelines
This commit is contained in:
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