Enhance security and validation across multiple routes:
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s

- Escape XML and HTML special characters to prevent injection attacks.
- Implement rate limiting for various endpoints to mitigate abuse.
- Add validation for email formats, password lengths, and field limits.
- Ensure proper access control for recordings and room management.
This commit is contained in:
2026-02-28 19:49:29 +01:00
parent 616442a82a
commit 7466f3513d
10 changed files with 398 additions and 47 deletions

View File

@@ -12,7 +12,11 @@ import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
// ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) {
@@ -26,6 +30,22 @@ function makeRedisStore(prefix) {
}
}
// ── Validation helpers ─────────────────────────────────────────────────────
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
const VALID_THEMES = new Set([
'light', 'dark', 'dracula', 'mocha', 'latte', 'nord', 'tokyo-night',
'gruvbox-dark', 'gruvbox-light', 'rose-pine', 'rose-pine-dawn',
'solarized-dark', 'solarized-light', 'one-dark', 'github-dark', 'scrunkly-cat',
]);
const VALID_LANGUAGES = new Set(['en', 'de']);
// Allowlist for CSS color values only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8;
// ── Rate Limiters ────────────────────────────────────────────────────────────
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
@@ -44,6 +64,33 @@ const registerLimiter = rateLimit({
store: makeRedisStore('rl:register:'),
});
const profileLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many profile update attempts. Please try again later.' },
store: makeRedisStore('rl:profile:'),
});
const passwordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many password change attempts. Please try again later.' },
store: makeRedisStore('rl:password:'),
});
const avatarLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many avatar upload attempts. Please try again later.' },
store: makeRedisStore('rl:avatar:'),
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -64,13 +111,24 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(400).json({ error: 'All fields are required' });
}
// L3: display_name length limit (consistent with profile update)
if (display_name.length > 100) {
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
}
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters long' });
// M1: email format
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
// M4: minimum password length
if (password.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const db = getDb();
@@ -238,6 +296,11 @@ router.post('/login', loginLimiter, async (req, res) => {
return res.status(400).json({ error: 'Email and password are required' });
}
// M1: basic email format check invalid format can never match a real account
if (!EMAIL_RE.test(email)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
@@ -290,11 +353,16 @@ router.get('/me', authenticateToken, (req, res) => {
});
// PUT /api/auth/profile
router.put('/profile', authenticateToken, async (req, res) => {
router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
try {
const { name, display_name, email, theme, language, avatar_color } = req.body;
const db = getDb();
// M1: validate new email format
if (email && !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
if (email && email !== req.user.email) {
const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
if (existing) {
@@ -302,6 +370,26 @@ router.put('/profile', authenticateToken, async (req, res) => {
}
}
// M2: display_name length limit
if (display_name !== undefined && display_name !== null && display_name.length > 100) {
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
}
// M2: theme and language allowlists
if (theme !== undefined && theme !== null && !VALID_THEMES.has(theme)) {
return res.status(400).json({ error: 'Invalid theme' });
}
if (language !== undefined && language !== null && !VALID_LANGUAGES.has(language)) {
return res.status(400).json({ error: 'Invalid language' });
}
// L5: validate avatar_color format/length
if (avatar_color !== undefined && avatar_color !== null) {
if (typeof avatar_color !== 'string' || !SAFE_COLOR_RE.test(avatar_color)) {
return res.status(400).json({ error: 'Invalid avatar color' });
}
}
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
@@ -334,9 +422,18 @@ router.put('/profile', authenticateToken, async (req, res) => {
});
// PUT /api/auth/password
router.put('/password', authenticateToken, async (req, res) => {
router.put('/password', authenticateToken, passwordLimiter, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
// M6: guard against missing/non-string body values
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'currentPassword and newPassword are required' });
}
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const db = getDb();
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
@@ -344,8 +441,9 @@ router.put('/password', authenticateToken, async (req, res) => {
return res.status(401).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'New password must be at least 6 characters long' });
// M4: minimum password length
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const hash = bcrypt.hashSync(newPassword, 12);
@@ -359,23 +457,35 @@ router.put('/password', authenticateToken, async (req, res) => {
});
// POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, async (req, res) => {
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try {
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
// Validate content type
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Only image files are allowed' });
}
// Max 2MB
if (buffer.length > 2 * 1024 * 1024) {
// M15: stream-level size limit abort as soon as 2 MB is exceeded
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
req.on('data', chunk => {
totalSize += chunk.length;
if (totalSize > MAX_AVATAR_SIZE) {
req.destroy();
return reject(new Error('LIMIT_EXCEEDED'));
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
}).catch(err => {
if (err.message === 'LIMIT_EXCEEDED') return null;
throw err;
});
if (!buffer) {
return res.status(400).json({ error: 'Image must not exceed 2MB' });
}
@@ -403,7 +513,7 @@ router.post('/avatar', authenticateToken, async (req, res) => {
});
// DELETE /api/auth/avatar - Remove avatar image
router.delete('/avatar', authenticateToken, async (req, res) => {
router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try {
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
@@ -420,19 +530,35 @@ router.delete('/avatar', authenticateToken, async (req, res) => {
}
});
// Escape XML special characters to prevent XSS in SVG text/attribute contexts
function escapeXml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// GET /api/auth/avatar/initials/:name - Generate SVG avatar from initials (public, BBB fetches this)
router.get('/avatar/initials/:name', (req, res) => {
const name = decodeURIComponent(req.params.name).trim();
const color = req.query.color || generateColorFromName(name);
const initials = name
// C1 fix: validate color against a strict allowlist before embedding in SVG attribute
const rawColor = req.query.color || '';
const color = SAFE_COLOR_RE.test(rawColor) ? rawColor : generateColorFromName(name);
// C2 fix: XML-escape initials before embedding in SVG text node
const rawInitials = name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2) || '?';
const initials = escapeXml(rawInitials);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="64" fill="${color}"/>
<rect width="128" height="128" rx="64" fill="${escapeXml(color)}"/>
<text x="64" y="64" dy=".35em" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="52" font-weight="bold">${initials}</text>
</svg>`;
@@ -452,7 +578,11 @@ function generateColorFromName(name) {
// GET /api/auth/avatar/:filename - Serve avatar image
router.get('/avatar/:filename', (req, res) => {
const filepath = path.join(uploadsDir, req.params.filename);
// H1 fix: resolve the path and ensure it stays inside uploadsDir (prevent path traversal)
const filepath = path.resolve(uploadsDir, req.params.filename);
if (!filepath.startsWith(uploadsDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'Avatar not found' });
}