fix(backend): validate contract id before using it in the signed-PDF storage filename

Backport of the same fix on main: same pattern as the event-logo fix
(GHSA-9q5j-vqfw-32hr) in a different file this branch never touched —
multer's filename callback ran before express-validator's :id check,
letting a traversal payload escape uploads/contracts/signed/.
This commit is contained in:
Paul Nothaft
2026-09-10 23:29:29 +02:00
parent faec7bb48b
commit 79ed199013
2 changed files with 131 additions and 1 deletions
@@ -0,0 +1,126 @@
/**
* Same bug class as GHSA-9q5j-vqfw-32hr (fixed in adminEvents/logo.js) —
* the signed-PDF upload's multer `filename` callback built the stored
* path directly from `req.params.id` with no integer validation:
*
* filename: (req, file, cb) => {
* cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
* }
*
* `POST /:id/upload-signed-pdf` declares `param('id').isInt({ min: 1 })`,
* but express-validator's check only runs inside the route handler via
* validateRequest(req) — AFTER multer has already parsed the multipart
* body and invoked the filename callback. A traversal payload in the raw
* `:id` URL segment reaches multer completely unvalidated.
*
* Fixed by rejecting any non-positive-integer id before it is used to
* build the filename, independent of the declared-but-too-late
* express-validator check.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
// ALLOWED_MEDIA_TYPES in fileSecurityUtils.js only defines image/video
// entries, so the route's real fileFilter (validateFileType(..., ['application/pdf']))
// rejects every PDF upload with "Only PDF files are allowed" — a
// separate, pre-existing bug unrelated to the path-traversal fix under
// test here (also present in publicContracts.js, which is why neither
// suite exercises a successful upload). Stub validateFileType so this
// suite can drive the full route, including the filename-callback fix,
// end-to-end.
jest.mock('../../src/utils/fileSecurityUtils', () => {
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
return {
...actual,
validateFileType: (filename, mimetype, allowedTypes) => allowedTypes.includes(mimetype),
};
});
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-contracts-signed-pdf-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-contracts-signed-pdf-test-secret';
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
describe('POST /api/admin/contracts/:id/upload-signed-pdf — path traversal guard', () => {
let db; let cleanup; let app; let adminId; let customerId; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
// Feature flag defaults OFF on a fresh install — the contracts
// router 403s every route until it's on.
await db('feature_flags').where({ key: 'contracts' }).update({ value: true });
app = buildRouteApp('/api/admin/contracts', require('../../src/routes/adminContracts'));
}, 120000);
afterAll(async () => { await cleanup(); });
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
const signedDir = () => path.join(process.env.STORAGE_PATH, 'uploads/contracts/signed');
async function insertContract(over = {}) {
const base = {
contract_number: `K-TEST-${Math.random().toString(16).slice(2, 8)}`,
customer_account_id: customerId,
title: 'Test Contract',
issue_date: new Date().toISOString().slice(0, 10),
status: 'sent',
language: 'de',
created_at: new Date().toISOString(),
...over,
};
const inserted = await db('contracts').insert(base).returning('id');
return inserted[0]?.id ?? inserted[0];
}
it('rejects a traversal payload in the id param instead of writing outside uploads/contracts/signed', async () => {
// '../../../../tmp/pwned' URL-encoded so the raw request path still
// has a single segment (matches Express's `:id`), but Express
// decodes the param back into literal '../' sequences before the
// route sees it.
const traversalId = encodeURIComponent('../../../../tmp/pwned');
const res = await auth(
request(app).post(`/api/admin/contracts/${traversalId}/upload-signed-pdf`)
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.body.error).toMatch(/invalid contract id/i);
// No file should have been written anywhere — the filename callback
// must error out before multer opens a write stream.
const escapedFile = path.join(os.tmpdir(), 'pwned');
expect(fs.existsSync(escapedFile)).toBe(false);
if (fs.existsSync(signedDir())) {
expect(fs.readdirSync(signedDir())).toHaveLength(0);
}
});
it('still accepts a normal numeric contract id', async () => {
const id = await insertContract();
const res = await auth(
request(app).post(`/api/admin/contracts/${id}/upload-signed-pdf`)
).attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(200);
const files = fs.readdirSync(signedDir());
expect(files.some((f) => f.startsWith(`contract-${id}-`))).toBe(true);
const row = await db('contracts').where({ id }).first();
expect(row.status).toBe('fully_signed');
expect(row.signed_pdf_path).toMatch(new RegExp(`contract-${id}-`));
});
});
+5 -1
View File
@@ -65,8 +65,12 @@ const signedPdfStorage = multer.diskStorage({
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const contractId = Number(req.params.id);
if (!Number.isInteger(contractId) || contractId <= 0) {
return cb(new Error('Invalid contract id'));
}
const ext = path.extname(file.originalname) || '.pdf';
cb(null, `contract-${req.params.id}-${Date.now()}${ext}`);
cb(null, `contract-${contractId}-${Date.now()}${ext}`);
},
});