Files
picpeak/backend/__tests__/routes/adminPhotoUploadSizeLimit.test.js
T
Paul Nothaft 0ac006bb95 fix(security): chunked-upload init checks the size cap before the type allow-list
Keeps the size error first, as before the allow-list landed, and pins the
allow-list gate in the size-limit suite: a .html filename is refused
whatever MIME the client declares.
2026-09-03 10:58:11 +02:00

186 lines
7.1 KiB
JavaScript

/**
* Per-file upload size limit on the admin photo routes.
*
* `general_max_file_size_mb` (Settings → General, default 50MB) is what the
* dropzone advertises ("max. 50MB per file"), but the admin upload route
* hardcoded multer's cap at 10GB and the chunked-upload init route at 10GB
* too — so the advertised limit was never enforced anywhere server-side and a
* 50.74MB JPEG uploaded cleanly.
*
* Pins:
* - a file over the configured cap is rejected with a 400 naming the limit
* - the chunked-upload init route honours the same cap (it would otherwise
* be a trivial bypass of the multipart route's cap)
* - the chunk route enforces the cap on the bytes actually received, so a
* client can't declare `fileSize: 1` at init and stream past the limit
* - a file under the cap still gets past the size gate
* - the limit is read per request, so an admin raising it takes effect
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-upload-size-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'upload-size-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-upload-size-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'upload-size-test-event';
describe('admin upload per-file size limit (general_max_file_size_mb)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
let uploadSettings;
const setLimitMb = async (mb) => {
await db('app_settings')
.insert({
setting_key: 'general_max_file_size_mb',
setting_value: JSON.stringify(mb),
setting_type: 'general',
updated_at: new Date().toISOString(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify(mb) });
uploadSettings.clearMaxFileSizeCache();
};
const postUpload = (bytes, filename = 'shot.jpg') => request(app)
.post(`/api/admin/photos/${eventId}/upload`)
.set('Authorization', `Bearer ${adminToken}`)
.attach('photos', Buffer.alloc(bytes, 0x41), { filename, contentType: 'image/jpeg' });
const postChunkedInit = (fileSize, filename = 'clip.mp4') => request(app)
.post(`/api/admin/photos/${eventId}/chunked-upload/init`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ filename, fileSize, mimeType: 'video/mp4', totalChunks: 1 });
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Upload Size Test',
event_date: '2026-09-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'upload-size-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'upload-size-admin',
email: 'upload-size-admin@example.com',
password_hash: await bcrypt.hash('UploadSize123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'upload-size-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
uploadSettings = require('../../src/services/uploadSettings');
// chunked-upload/init now enforces the admin allow-list on the filename
// extension (the declared mimeType is ignored), exactly like the
// multipart path; the default list is images only, so admit mp4 here.
await db('app_settings')
.insert({
setting_key: 'general_allowed_file_types',
setting_value: JSON.stringify('jpg,jpeg,png,webp,mp4'),
setting_type: 'general',
updated_at: new Date().toISOString(),
})
.onConflict('setting_key')
.merge({ setting_value: JSON.stringify('jpg,jpeg,png,webp,mp4') });
uploadSettings.clearAllowedTypesCache();
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a file over the configured limit with a 400 naming the limit', async () => {
await setLimitMb(1);
const res = await postUpload(2 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('rejects an over-limit chunked upload at init instead of allowing 10GB', async () => {
await setLimitMb(1);
const res = await postChunkedInit(200 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('rejects a chunked upload whose extension is not on the allow-list, whatever MIME it declares', async () => {
await setLimitMb(50);
const res = await postChunkedInit(1024, 'page.html');
expect(res.status).toBe(400);
expect(res.body.error).toBe('File type not allowed');
});
it('rejects chunk bytes over the limit regardless of the declared fileSize', async () => {
await setLimitMb(1);
const initRes = await postChunkedInit(1);
expect(initRes.status).toBe(200);
const res = await request(app)
.post(`/api/admin/photos/${eventId}/chunked-upload/${initRes.body.uploadId}/chunk/0`)
.set('Authorization', `Bearer ${adminToken}`)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.alloc(2 * 1024 * 1024, 0x41));
expect(res.status).toBe(413);
expect(res.body.error).toBe('File too large. Maximum size is 1 MB per file.');
});
it('lets a file under the limit past the size gate', async () => {
await setLimitMb(1);
// Junk bytes, so it still fails downstream on the content check — that is
// the point: the failure is no longer about size.
const res = await postUpload(64 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: shot.jpg');
});
it('reads the limit per request, so raising it takes effect immediately', async () => {
await setLimitMb(1);
expect((await postUpload(2 * 1024 * 1024)).status).toBe(400);
await setLimitMb(10);
const res = await postUpload(2 * 1024 * 1024);
expect(res.status).toBe(400);
expect(res.body.error).toBe('File content does not match declared type: shot.jpg');
});
});