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
123 lines
3.7 KiB
JavaScript
123 lines
3.7 KiB
JavaScript
const rateLimit = require("express-rate-limit");
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const prisma = require('../config/db');
|
|
|
|
// Protect routes - verify token
|
|
const protect = async (req, res, next) => {
|
|
let token;
|
|
|
|
// Check if token exists in headers
|
|
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
|
try {
|
|
// Get token from header
|
|
token = req.headers.authorization.split(' ')[1];
|
|
|
|
// Verify token — pin the algorithm so a token signed with an
|
|
// unexpected/attacker-chosen algorithm is never accepted.
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
|
|
// Get user from the token (exclude password)
|
|
req.user = await prisma.user.findUnique({
|
|
where: { id: decoded.id },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
isActive: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
phoneNumber: true,
|
|
tokenVersion: true
|
|
}
|
|
});
|
|
|
|
if (!req.user) {
|
|
res.status(401);
|
|
return next(new Error('User not found'));
|
|
}
|
|
|
|
if (!req.user.isActive) {
|
|
res.status(401);
|
|
return next(new Error('User account is deactivated'));
|
|
}
|
|
|
|
// Revocation check — tokenVersion in JWT must match DB
|
|
// Old tokens without tokenVersion are treated as version 0
|
|
const tokenVer = decoded.tokenVersion ?? 0;
|
|
if (tokenVer !== req.user.tokenVersion) {
|
|
res.status(401);
|
|
return next(new Error('Session has been revoked. Please log in again.'));
|
|
}
|
|
|
|
next();
|
|
} catch (error) {
|
|
console.error(error);
|
|
res.status(401);
|
|
return next(new Error('Not authorized, token failed'));
|
|
}
|
|
} else {
|
|
res.status(401);
|
|
return next(new Error('Not authorized, no token'));
|
|
}
|
|
};
|
|
|
|
// Admin only middleware
|
|
const admin = (req, res, next) => {
|
|
if (req.user && req.user.role === 'admin') {
|
|
next();
|
|
} else {
|
|
res.status(403);
|
|
return next(new Error('Not authorized as an admin'));
|
|
}
|
|
};
|
|
|
|
// Staff or higher middleware
|
|
const staff = (req, res, next) => {
|
|
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor' || req.user.role === 'staff')) {
|
|
next();
|
|
} else {
|
|
res.status(403);
|
|
return next(new Error('Not authorized as staff'));
|
|
}
|
|
};
|
|
|
|
// Supervisor or higher middleware
|
|
const supervisor = (req, res, next) => {
|
|
if (req.user && (req.user.role === 'admin' || req.user.role === 'supervisor')) {
|
|
next();
|
|
} else {
|
|
res.status(403);
|
|
return next(new Error('Not authorized as a supervisor'));
|
|
}
|
|
};
|
|
|
|
const loginLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 15,
|
|
message: "Too many login attempts. Try again later.",
|
|
});
|
|
|
|
// Optional auth — populates req.user if a valid token is present, but never rejects the request
|
|
const optionalAuth = async (req, res, next) => {
|
|
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer')) {
|
|
return next();
|
|
}
|
|
try {
|
|
const token = req.headers.authorization.split(' ')[1];
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: decoded.id },
|
|
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
|
});
|
|
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
|
|
req.user = user;
|
|
}
|
|
} catch {
|
|
// Token invalid or expired — proceed without user
|
|
}
|
|
next();
|
|
};
|
|
|
|
module.exports = { protect, admin, staff, supervisor, loginLimiter, optionalAuth }; |