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

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

View File

@@ -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,