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
151 lines
5.3 KiB
JavaScript
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,
|
|
};
|