penpot/backend/test/e2e/import-binfile-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

111 lines
3.5 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"];
}
describe("import-binfile path traversal", () => {
let profile;
let cookie;
before(async () => {
const setup = await setupTestProfile();
profile = setup.profile;
cookie = setup.cookie;
});
it("rejects a hand-forged :file path in a transit body", async () => {
// ATTACK SCENARIO:
// 1. The attacker has a valid session and a valid project-id.
// 2. Instead of a real multipart upload, they hand-write a transit
// body (plain JSON, no transit library needed) with a forged
// :file 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.
// 3. import-binfile then uses (:path file) directly as input-path
// (parse-file-format / get-manifest / import-files!).
//
// EXPECTED BEHAVIOR AFTER FIX: 400 validation/params-validation,
// without touching the file. While vulnerable, the request passes
// validation and fails later (zip/manifest error) or hangs in SSE,
// proving the file was opened.
const transitBody = JSON.stringify({
"~:project-id": `~u${profile.defaultProjectId}`,
"~:name": "pwned",
"~:version": 3,
"~:file": {
"~:filename": "evil.zip",
"~:size": 123,
"~:path": ["~#path", "/etc/passwd"],
},
});
const res = await rpcPost("import-binfile", transitBody, {
cookieToken: cookie,
contentType: "application/transit+json",
accept: "application/json",
});
assert.equal(
res.status,
400,
`forged :file 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 :file 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(
"import-binfile",
{
projectId: profile.defaultProjectId,
name: "pwned",
version: 3,
file: { filename: "evil.zip", size: 123, path: "/etc/passwd" },
},
{ cookieToken: cookie }
);
assert.equal(res.status, 400);
assert.equal(errorType(res.body), "validation");
assert.equal(errorCode(res.body), "params-validation");
});
});