penpot/backend/test/e2e/upload-file-media-path-traversal.test.mjs
Andrey Antukh 70b317a976
🐛 Harden upload path handling and validation error bodies (#12067)
* 🚑 Reject forged upload paths outside multipart

Transit bodies can smuggle upload maps whose path points at any
server file; the schema only checks the shape and handlers open
the path directly.

app.rpc now injects a server-side multipart flag that bodies cannot
forge, and methods declare their upload keys with the new
multipart-only-params metadata, enforced centrally after schema
validation with a params-validation error. Covers all nine upload
methods, including chunked and management flows.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Sanitize raw explain in generic validation error bodies

The generic :validation branch returned ex-data untouched, so errors
carrying the raw malli explain (live schema objects, e.g. storage
invalid-storage-metadata) broke JSON encoding mid-stream and clients
got truncated bodies. Apply the same clean treatment the known codes
already use: drop the raw explain and attach its humanized text.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Add e2e path traversal tests for binfile and media upload

Add two e2e suites that hand-write transit bodies with a forged
["~#path", ...] upload path (no transit library needed) and expect
400 validation/params-validation. They cover import-binfile :file,
upload-file-media-object :content and upload-chunk :content, plus a
plain-JSON string-path control case. Before the multipart-path fix
they failed (500 io-exception, invalid-image, 200 stored chunk);
after it they pass.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Use multipart helper in binfile SVG test setup

The SVG import setup uploaded through plain command!, so the new
multipart-only guard rejected it with params-validation. Switch
the setup to multipart-command! like the other upload tests.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Use multipart helper in remaining upload tests

The multipart-only upload guard rejects plain RPC calls carrying
upload maps. Five tests with real files still used command! and
failed with params-validation. Switch them to multipart-command!
while keeping the forged-content tests on plain command!.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-17 16:01:04 +02:00

190 lines
5.9 KiB
JavaScript

import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { setupTestProfile } from "./helpers/auth.mjs";
import { rpcPost } from "./helpers/client.mjs";
function errorCode(body) {
if (body == null) return undefined;
if (typeof body === "string") {
try {
return errorCode(JSON.parse(body));
} catch {
return undefined;
}
}
return body.code ?? body["~:code"];
}
function errorType(body) {
if (body == null) return undefined;
if (typeof body === "string") {
try {
return errorType(JSON.parse(body));
} catch {
return undefined;
}
}
return body.type ?? body["~:type"];
}
async function createFile(cookie, projectId, name = "E2E Media Test File") {
const res = await rpcPost(
"create-file",
{ name, projectId },
{ cookieToken: cookie }
);
assert.equal(res.status, 200, `create-file failed: ${JSON.stringify(res.body)}`);
return res.body;
}
async function createUploadSession(cookie) {
const res = await rpcPost(
"create-upload-session",
{ totalChunks: 1 },
{ cookieToken: cookie }
);
assert.equal(
res.status,
200,
`create-upload-session failed: ${JSON.stringify(res.body)}`
);
return res.body.sessionId ?? res.body["~:session-id"];
}
describe("upload-file-media path traversal", () => {
let profile;
let cookie;
let fileId;
before(async () => {
const setup = await setupTestProfile();
profile = setup.profile;
cookie = setup.cookie;
const file = await createFile(cookie, profile.defaultProjectId);
fileId = file.id;
});
it("rejects a hand-forged :content path in a transit body", async () => {
// ATTACK SCENARIO:
// 1. The attacker has a valid session and a file they can edit.
// 2. Instead of a real multipart upload, they hand-write a transit
// body (plain JSON, no transit library needed) with a forged
// :content map that points to an internal server file.
// The transit tag ["~#path", "/etc/passwd"] becomes a real
// java.nio.file.Path on the server (see app.common.transit
// read handlers) and passes media.v/schema:upload, which only
// checks the shape, not where the path came from. The attacker
// also controls :size and :mtype, so validate-media-type! and
// validate-media-size! pass with "image/png" and a small size.
// 3. upload-file-media-object then reads (:path content) in
// process-image (media/run :info) and stores the bytes as a
// media object, which is later downloadable by URL.
//
// EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation,
// without touching the file. While vulnerable, the request passes
// validation and fails later in media processing, proving the
// file was opened.
const transitBody = JSON.stringify({
"~:file-id": `~u${fileId}`,
"~:is-local": true,
"~:name": "pwned",
"~:content": {
"~:filename": "evil.png",
"~:size": 123,
"~:mtype": "image/png",
"~:path": ["~#path", "/etc/passwd"],
},
});
const res = await rpcPost("upload-file-media-object", transitBody, {
cookieToken: cookie,
contentType: "application/transit+json",
accept: "application/json",
});
assert.equal(
res.status,
400,
`forged :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`
);
assert.equal(
errorType(res.body),
"validation",
`expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}`
);
assert.equal(
errorCode(res.body),
"params-validation",
`forged :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}`
);
});
it("rejects a hand-forged chunk :content path in a transit body", async () => {
// Same vector through upload-chunk: the forged path is read with
// sto/content and stored as a chunk blob. While vulnerable this
// returns 200 (the internal file bytes are stored under the
// attacker session, retrievable via assemble) — proving the file
// was read and kept. After the fix it must be 400
// validation/params-validation.
const sessionId = await createUploadSession(cookie);
const transitBody = JSON.stringify({
"~:session-id": `~u${sessionId}`,
"~:index": 0,
"~:content": {
"~:filename": "c",
"~:size": 123,
"~:mtype": "application/octet-stream",
"~:path": ["~#path", "/etc/passwd"],
},
});
const res = await rpcPost("upload-chunk", transitBody, {
cookieToken: cookie,
contentType: "application/transit+json",
accept: "application/json",
});
assert.equal(
res.status,
400,
`forged chunk :content path must be rejected with 400, got ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`
);
assert.equal(
errorType(res.body),
"validation",
`expected validation error, got: ${JSON.stringify(res.body).slice(0, 500)}`
);
assert.equal(
errorCode(res.body),
"params-validation",
`forged chunk :content path must fail at params validation, got: ${JSON.stringify(res.body).slice(0, 500)}`
);
});
it("plain JSON string path stays rejected", async () => {
// Control case: without the transit "~#path" tag the path arrives
// as a plain string and ::fs/path already rejects it. This test
// documents that only the transit vector bypasses the check.
const res = await rpcPost(
"upload-file-media-object",
{
fileId,
isLocal: true,
name: "pwned",
content: {
filename: "evil.png",
size: 123,
mtype: "image/png",
path: "/etc/passwd",
},
},
{ cookieToken: cookie }
);
assert.equal(res.status, 400);
assert.equal(errorType(res.body), "validation");
assert.equal(errorCode(res.body), "params-validation");
});
});