fix: bound admin image uploads and use a CSPRNG for stored filenames #4

Merged
bermudalamb merged 1 commits from fix/sonarqube-upload-limits-and-weak-rng into main 2026-08-14 09:26:00 -05:00
+40 -5
View File
@@ -1,19 +1,54 @@
import { Router, Request, Response } from 'express';
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import { randomUUID } from 'crypto';
import { pool } from '../db';
const router = Router();
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
// Multer writes to disk with no size cap unless one is given, so a single
// request could fill the uploads volume. Bound every dimension of the
// multipart body: image count, bytes per image, and the small text fields
// (name/description/price) that accompany them.
const MAX_IMAGES_PER_REQUEST = 6;
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const MAX_TEXT_FIELDS = 8;
const MAX_TEXT_FIELD_BYTES = 64 * 1024;
const storage = multer.diskStorage({
destination: UPLOADS_DIR,
// Stored names come from a CSPRNG rather than a timestamp plus Math.random,
// which is predictable enough that a caller could guess (or collide with)
// another upload's path.
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}${ext}`);
cb(null, `${randomUUID()}${ext}`);
}
});
const upload = multer({ storage });
const upload = multer({
storage,
limits: {
fileSize: MAX_IMAGE_BYTES,
files: MAX_IMAGES_PER_REQUEST,
fields: MAX_TEXT_FIELDS,
fieldSize: MAX_TEXT_FIELD_BYTES
}
});
// No error-handling middleware is mounted on the app, so translate multer's
// limit errors here instead of letting them surface as a generic 500.
const uploadImages = (req: Request, res: Response, next: NextFunction) => {
upload.array('images', MAX_IMAGES_PER_REQUEST)(req, res, (err: unknown) => {
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
return next(err);
});
};
const SELECT_WITH_IMAGES = `
SELECT i.*,
@@ -31,7 +66,7 @@ router.get('/items', async (_req: Request, res: Response) => {
res.json(rows);
});
router.post('/items', upload.array('images', 6), async (req: Request, res: Response) => {
router.post('/items', uploadImages, async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const files = (req.files as Express.Multer.File[]) || [];
const client = await pool.connect();
@@ -60,7 +95,7 @@ router.post('/items', upload.array('images', 6), async (req: Request, res: Respo
}
});
router.put('/items/:id', upload.array('images', 6), async (req: Request, res: Response) => {
router.put('/items/:id', uploadImages, async (req: Request, res: Response) => {
const { name, description, price } = req.body;
const files = (req.files as Express.Multer.File[]) || [];
const client = await pool.connect();