* fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes Resolves 12 open code-scanning alerts (#589-600): sharp libheif RCE, nodemailer address-parser ReDoS + domain-validation bypasses, multer upload DoS/race conditions, js-yaml parsing DoS, and joi prototype pollution. All patch/minor bumps within the currently used major version. * fix(backend): set multer's fieldArrayIndexLimit to actually close CVE-2026-82333 The advisory is explicit that the 2.3.0 version bump alone doesn't remediate the array-index DoS — an app must also set limits.fieldArrayIndexLimit. Set it on every multer instance, sized to what each route's form actually needs. --------- Co-authored-by: Paul Nothaft <[email protected]>
203 lines
8.3 KiB
JavaScript
203 lines
8.3 KiB
JavaScript
/**
|
|
* HTTP route tests for backend/src/routes/publicContracts (P0 — #570).
|
|
*
|
|
* Four endpoints on the customer-facing surface:
|
|
* GET /:token — load contract for signing
|
|
* POST /:token/sign — in-browser canvas signature submission
|
|
* POST /:token/upload-signed-pdf — wet-signed PDF upload
|
|
* GET /:token/pdf — download the contract PDF
|
|
*
|
|
* Tests pin the publicTokenGuards.loadActionToken contract per
|
|
* endpoint and a few endpoint-specific shape assertions. Deeper
|
|
* service-layer behaviour (PDF generation, signature attachment,
|
|
* integrity-hash compute) is covered by the contractService unit
|
|
* tests; here we only assert the HTTP contract.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubcontracts-test-'));
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
|
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
|
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
|
|
|
|
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const request = require('supertest');
|
|
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
|
const tokenGuards = require('../../src/utils/publicTokenGuards');
|
|
const { errorHandler } = require('../../src/middleware/errorHandler');
|
|
|
|
describe('publicContracts routes', () => {
|
|
let db;
|
|
let cleanup;
|
|
let app;
|
|
let appWithErrorHandler;
|
|
let customerId;
|
|
let contractId;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
({ customerId } = await seedMinimal(db));
|
|
const inserted = await db('contracts').insert({
|
|
contract_number: 'K-TEST-0001',
|
|
customer_account_id: customerId,
|
|
title: 'Test Booking Confirmation',
|
|
issue_date: new Date().toISOString().slice(0, 10),
|
|
status: 'sent',
|
|
language: 'de',
|
|
created_at: new Date().toISOString(),
|
|
}).returning('id');
|
|
contractId = inserted[0]?.id ?? inserted[0];
|
|
|
|
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
|
|
|
|
// A second app instance wired to the REAL production error handler
|
|
// (buildRouteApp's is a simplified stand-in that only reads
|
|
// err.statusCode/err.status, which a bare MulterError doesn't set).
|
|
// Used below to verify the actual 4xx contract end-to-end, not just
|
|
// that multer aborted the request.
|
|
appWithErrorHandler = express();
|
|
appWithErrorHandler.use(express.json());
|
|
appWithErrorHandler.use(cookieParser());
|
|
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
|
|
appWithErrorHandler.use(errorHandler);
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
if (tokenGuards._internal?.badAttempts) tokenGuards._internal.badAttempts.clear();
|
|
});
|
|
|
|
describe('GET /:token', () => {
|
|
it('returns 404 for an unknown well-formed token', async () => {
|
|
const fakeToken = 'a'.repeat(64);
|
|
const res = await request(app).get(`/api/public/contracts/${fakeToken}`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('rejects malformed tokens with 400 before reaching the guard', async () => {
|
|
const res = await request(app).get('/api/public/contracts/short');
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 410 for an expired token', async () => {
|
|
const past = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
const token = await createPublicToken(db, 'contract_action_tokens', {
|
|
contract_id: contractId, expires_at: past,
|
|
});
|
|
const res = await request(app).get(`/api/public/contracts/${token}`);
|
|
expect(res.status).toBe(410);
|
|
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
|
});
|
|
|
|
it('returns 200 with the contract payload for a valid token', async () => {
|
|
const token = await createPublicToken(db, 'contract_action_tokens', {
|
|
contract_id: contractId,
|
|
});
|
|
const res = await request(app).get(`/api/public/contracts/${token}`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.contract).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('POST /:token/sign', () => {
|
|
it('rejects missing required fields (name, accepted) with 400', async () => {
|
|
const token = await createPublicToken(db, 'contract_action_tokens', {
|
|
contract_id: contractId,
|
|
});
|
|
const res = await request(app)
|
|
.post(`/api/public/contracts/${token}/sign`)
|
|
.send({}); // missing name + accepted
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 404 for an unknown token on sign', async () => {
|
|
const fakeToken = 'b'.repeat(64);
|
|
const res = await request(app)
|
|
.post(`/api/public/contracts/${fakeToken}/sign`)
|
|
.send({ name: 'Jane Doe', accepted: true });
|
|
// Either 404 (token not found) or service-level error mapped to
|
|
// 4xx — what matters is the request didn't slip past validation.
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect(res.status).toBeLessThan(500);
|
|
});
|
|
});
|
|
|
|
describe('POST /:token/upload-signed-pdf', () => {
|
|
it('rejects malformed tokens with 400 before multer runs', async () => {
|
|
const res = await request(app)
|
|
.post('/api/public/contracts/bad-token/upload-signed-pdf')
|
|
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 404 for an unknown but well-formed token', async () => {
|
|
const fakeToken = 'c'.repeat(64);
|
|
const res = await request(app)
|
|
.post(`/api/public/contracts/${fakeToken}/upload-signed-pdf`)
|
|
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
|
|
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
|
|
// field-parser DoS — the version bump alone does nothing. This route is
|
|
// unauthenticated (token-in-URL only), so it's the sharpest place to
|
|
// prove a crafted request with an oversized array-index field name
|
|
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
|
|
it('rejects a multipart request with an oversized array-index field name', async () => {
|
|
const token = await createPublicToken(db, 'contract_action_tokens', {
|
|
contract_id: contractId,
|
|
});
|
|
const res = await request(app)
|
|
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
|
.field('evil[999999999]', 'x')
|
|
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
|
// multer aborts the request before the handler runs; buildRouteApp's
|
|
// generic error handler falls back to 500 for a bare MulterError
|
|
// (see appWithErrorHandler test below for the real 4xx contract), so
|
|
// here we only assert the upload was NOT accepted/processed.
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect(res.body.error).not.toBe(undefined);
|
|
});
|
|
|
|
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
|
|
const token = await createPublicToken(db, 'contract_action_tokens', {
|
|
contract_id: contractId,
|
|
});
|
|
const res = await request(appWithErrorHandler)
|
|
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
|
|
.field('evil[999999999]', 'x')
|
|
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('VALIDATION_ERROR');
|
|
});
|
|
});
|
|
|
|
describe('GET /:token/pdf', () => {
|
|
it('returns 404 for an unknown token on PDF download', async () => {
|
|
const fakeToken = 'd'.repeat(64);
|
|
const res = await request(app).get(`/api/public/contracts/${fakeToken}/pdf`);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('returns 410 for an expired token on PDF download', async () => {
|
|
const past = new Date(Date.now() - 1000);
|
|
const token = await createPublicToken(db, 'contract_action_tokens', {
|
|
contract_id: contractId, expires_at: past,
|
|
});
|
|
const res = await request(app).get(`/api/public/contracts/${token}/pdf`);
|
|
expect(res.status).toBe(410);
|
|
expect(res.body.code).toBe('TOKEN_EXPIRED');
|
|
});
|
|
});
|
|
});
|