Files
hope-events/backend/src/middleware/authMiddleware.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

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 };