fix(sandbox): normalize /workspace media paths to host sandbox root

Co-authored-by: echo931 <echo931@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-02-22 20:30:48 +01:00
parent 0932adf361
commit eefbf3dc5a
4 changed files with 74 additions and 0 deletions

View File

@@ -62,6 +62,26 @@ describe("resolveSandboxedMediaSource", () => {
});
});
it("maps container /workspace absolute paths into sandbox root", async () => {
await withSandboxRoot(async (sandboxDir) => {
const result = await resolveSandboxedMediaSource({
media: "/workspace/media/pic.png",
sandboxRoot: sandboxDir,
});
expect(result).toBe(path.join(sandboxDir, "media", "pic.png"));
});
});
it("maps file:// URLs under /workspace into sandbox root", async () => {
await withSandboxRoot(async (sandboxDir) => {
const result = await resolveSandboxedMediaSource({
media: "file:///workspace/media/pic.png",
sandboxRoot: sandboxDir,
});
expect(result).toBe(path.join(sandboxDir, "media", "pic.png"));
});
});
// Group 3: Rejections (security)
it.each([
{
@@ -69,6 +89,11 @@ describe("resolveSandboxedMediaSource", () => {
media: "/etc/passwd",
expected: /sandbox/i,
},
{
name: "paths under similarly named container roots",
media: "/workspace-two/secret.txt",
expected: /sandbox/i,
},
{
name: "path traversal through tmpdir",
media: path.join(os.tmpdir(), "..", "etc", "passwd"),

View File

@@ -7,6 +7,7 @@ import { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
const HTTP_URL_RE = /^https?:\/\//i;
const DATA_URL_RE = /^data:/i;
const SANDBOX_CONTAINER_WORKDIR = "/workspace";
function normalizeUnicodeSpaces(str: string): string {
return str.replace(UNICODE_SPACES, " ");
@@ -90,6 +91,13 @@ export async function resolveSandboxedMediaSource(params: {
throw new Error(`Invalid file:// URL for sandboxed media: ${raw}`);
}
}
const containerWorkspaceMapped = mapContainerWorkspacePath({
candidate,
sandboxRoot: params.sandboxRoot,
});
if (containerWorkspaceMapped) {
candidate = containerWorkspaceMapped;
}
const tmpMediaPath = await resolveAllowedTmpMediaPath({
candidate,
sandboxRoot: params.sandboxRoot,
@@ -105,6 +113,25 @@ export async function resolveSandboxedMediaSource(params: {
return sandboxResult.resolved;
}
function mapContainerWorkspacePath(params: {
candidate: string;
sandboxRoot: string;
}): string | undefined {
const normalized = params.candidate.replace(/\\/g, "/");
if (normalized === SANDBOX_CONTAINER_WORKDIR) {
return path.resolve(params.sandboxRoot);
}
const prefix = `${SANDBOX_CONTAINER_WORKDIR}/`;
if (!normalized.startsWith(prefix)) {
return undefined;
}
const rel = normalized.slice(prefix.length);
if (!rel) {
return path.resolve(params.sandboxRoot);
}
return path.resolve(params.sandboxRoot, ...rel.split("/").filter(Boolean));
}
async function resolveAllowedTmpMediaPath(params: {
candidate: string;
sandboxRoot: string;

View File

@@ -585,6 +585,27 @@ describe("runMessageAction sandboxed media validation", () => {
});
});
it("rewrites /workspace media paths to host sandbox root", async () => {
await withSandbox(async (sandboxDir) => {
const result = await runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
media: "/workspace/data/file.txt",
message: "",
},
sandboxRoot: sandboxDir,
});
expect(result.kind).toBe("send");
if (result.kind !== "send") {
throw new Error("expected send result");
}
expect(result.sendResult?.mediaUrl).toBe(path.join(sandboxDir, "data", "file.txt"));
});
});
it("rewrites MEDIA directives under sandbox", async () => {
await withSandbox(async (sandboxDir) => {
const result = await runDrySend({