Files
hope-events/backend/src/controllers/uploadController.js
T
joshuaandClaude Sonnet 5 032d3c032e Fix upload path-traversal RCE vector, patch all known-vulnerable deps
Path traversal (CWE-22/CWE-73): event-image, branding (logo/favicon),
and event-attachment uploads built the saved filename from the
client-supplied original filename with no sanitization, and multer's
diskStorage joins that straight into the destination path. A crafted
filename containing `../` sequences could write the uploaded file
anywhere the server process has write access — reachable by any
supervisor-level account, and briefly pre-auth via the branding
uploads during initial /setup. Filenames are now always server-
generated (random bytes + validated extension); the original name is
kept only as display metadata.

Dependencies: express-rate-limit was declared only at the repo root
despite being required directly by backend/src/index.js, so a plain
`cd backend && npm install` (per the deployment doc) would never
install it — moved it into backend/package.json. Bumped next off a
version affected by a critical unauthenticated RCE (React Flight
protocol) and switched it from an exact pin to a caret range so future
patches install automatically. Bumped multer/nodemailer/jsonwebtoken/
uuid to patched versions, with an override forcing the vulnerable
nested uuid inside exceljs and the vulnerable postcss bundled inside
next to the patched versions too. `npm audit` is now clean (0
vulnerabilities) across root, backend, and frontend.

Hardening: jwt.verify() now pins algorithms: ['HS256'] instead of
trusting the token header; /uploads now serves with a restrictive CSP
and X-Content-Type-Options: nosniff so an uploaded SVG containing
<script> can't execute if opened directly.

Verified: backend's Jest suite passes, the backend boots and serves
real requests on the bumped deps, and `next build` compiles/type-
checks cleanly on the bumped frontend deps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSWFWQsjTc9GyffPiXEDQT
2026-08-28 11:33:05 +02:00

151 lines
5.3 KiB
JavaScript

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const multer = require('multer');
// Builds a filename multer can never be tricked into escaping the upload
// directory with — extension only, no attacker-controlled path segments.
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
function safeFilename(prefix, ext) {
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
}
// Setup multer storage
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..','public', 'uploads', 'events');
// Check if the upload location exists
try {
if (!fs.existsSync(uploadPath)) {
console.log(`Upload directory does not exist. Creating: ${uploadPath}`);
fs.mkdirSync(uploadPath, { recursive: true });
} else {
// Verify we have write permissions to the directory
fs.accessSync(uploadPath, fs.constants.W_OK);
console.log(`Upload directory exists and is writable: ${uploadPath}`);
}
cb(null, uploadPath);
} catch (error) {
console.error(`Error with upload directory: ${error.message}`);
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
}
});
// Multer middleware
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) {
return cb(new Error('Only images are allowed'), false);
}
cb(null, true);
}
});
// Logo storage (separate subfolder)
const logoStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (error) {
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
}
});
const uploadLogo = multer({
storage: logoStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp', '.svg'].includes(ext)) {
return cb(new Error('Only image files are allowed'), false);
}
cb(null, true);
}
});
// Favicon storage (same subfolder as the logo — both are "branding" assets)
const faviconStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (error) {
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
}
});
const uploadFavicon = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.ico', '.png', '.svg'].includes(ext)) {
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
}
cb(null, true);
}
});
// Controller function
const uploadEventImage = (req, res) => {
// Check for multer errors which would be passed in req.multerError
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
const imageUrl = `/uploads/events/${req.file.filename}`;
res.status(200).json({ url: imageUrl });
};
const uploadLogoImage = (req, res) => {
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
const uploadFaviconImage = (req, res) => {
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
module.exports = {
upload,
uploadEventImage,
uploadLogo,
uploadLogoImage,
uploadFavicon,
uploadFaviconImage,
};