mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 03:50:33 +00:00
* 🚑 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
190 lines
5.9 KiB
JavaScript
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");
|
|
});
|
|
});
|