diff --git a/server/config/bbb.js b/server/config/bbb.js index 692b8f1..869db04 100644 --- a/server/config/bbb.js +++ b/server/config/bbb.js @@ -81,10 +81,16 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati params.lockSettingsLockOnJoin = 'true'; } - // Build optional presentation XML body - const xmlBody = presentationUrl - ? `` - : null; + // Build optional presentation XML body – escape URL to prevent XML injection + let xmlBody = null; + if (presentationUrl) { + const safeUrl = presentationUrl + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + xmlBody = ``; + } return apiCall('create', params, xmlBody); } @@ -132,6 +138,17 @@ export async function getRecordings(meetingID) { return Array.isArray(recordings) ? recordings : [recordings]; } +export async function getRecordingByRecordId(recordID) { + const result = await apiCall('getRecordings', { recordID }); + if (result.returncode !== 'SUCCESS' || !result.recordings) { + return null; + } + const recordings = result.recordings.recording; + if (!recordings) return null; + const arr = Array.isArray(recordings) ? recordings : [recordings]; + return arr[0] || null; +} + export async function deleteRecording(recordID) { return apiCall('deleteRecordings', { recordID }); } diff --git a/server/config/mailer.js b/server/config/mailer.js index b8420ee..82339e7 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -2,6 +2,17 @@ import nodemailer from 'nodemailer'; let transporter; +// Escape HTML special characters to prevent injection in email bodies +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export function initMailer() { const host = process.env.SMTP_HOST; const port = parseInt(process.env.SMTP_PORT || '587', 10); @@ -44,6 +55,8 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl } const from = process.env.SMTP_FROM || process.env.SMTP_USER; + const safeName = escapeHtml(name); + const safeAppName = escapeHtml(appName); await transporter.sendMail({ from: `"${appName}" <${from}>`, @@ -51,7 +64,7 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl subject: `${appName} – Verify your email`, html: `
-

Hey ${name} 👋

+

Hey ${safeName} 👋

Please verify your email address by clicking the button below:

${safeRoomName}

+ ${safeMessage ? `

"${safeMessage}"

` : ''}

{ return res.status(400).json({ error: 'All fields are required' }); } + // L4: display_name length limit + 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(name)) { return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (3–30 chars)' }); } - if (password.length < 6) { - return res.status(400).json({ error: 'Password must be at least 6 characters long' }); + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters long' }); + } + + // M9: email format validation + if (!EMAIL_RE.test(email)) { + return res.status(400).json({ error: 'Invalid email address' }); } const validRole = ['user', 'admin'].includes(role) ? role : 'user'; @@ -131,8 +143,8 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, res) => { try { const { newPassword } = req.body; - if (!newPassword || newPassword.length < 6) { - return res.status(400).json({ error: 'Password must be at least 6 characters long' }); + if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters long' }); } const db = getDb(); diff --git a/server/routes/auth.js b/server/routes/auth.js index b9b54a9..117f0a9 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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 - (3–30 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // 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 = ` - + ${initials} `; @@ -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' }); } diff --git a/server/routes/branding.js b/server/routes/branding.js index c09a334..0349633 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -11,6 +11,13 @@ const __dirname = path.dirname(__filename); const router = Router(); +// Allowlist of valid theme IDs (keep in sync with src/themes/index.js) +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', +]); + // Ensure uploads/branding directory exists const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding'); if (!fs.existsSync(brandingDir)) { @@ -97,6 +104,15 @@ router.get('/logo', (req, res) => { if (!logoFile) { return res.status(404).json({ error: 'No logo found' }); } + // H5: serve SVG as attachment (Content-Disposition) to prevent in-browser script execution. + // For non-SVG images, inline display is fine. + const ext = path.extname(logoFile).toLowerCase(); + if (ext === '.svg') { + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Content-Disposition', 'attachment; filename="logo.svg"'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + return res.sendFile(logoFile); + } res.sendFile(logoFile); }); @@ -150,6 +166,9 @@ router.put('/name', authenticateToken, requireAdmin, async (req, res) => { if (!appName || !appName.trim()) { return res.status(400).json({ error: 'App name is required' }); } + if (appName.trim().length > 100) { + return res.status(400).json({ error: 'App name must not exceed 100 characters' }); + } await setSetting('app_name', appName.trim()); res.json({ appName: appName.trim() }); } catch (err) { @@ -165,6 +184,10 @@ router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) = if (!defaultTheme || !defaultTheme.trim()) { return res.status(400).json({ error: 'defaultTheme is required' }); } + // H4: validate against known theme IDs + if (!VALID_THEMES.has(defaultTheme.trim())) { + return res.status(400).json({ error: 'Invalid theme' }); + } await setSetting('default_theme', defaultTheme.trim()); res.json({ defaultTheme: defaultTheme.trim() }); } catch (err) { diff --git a/server/routes/federation.js b/server/routes/federation.js index 47215da..7e575d5 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -1,8 +1,19 @@ import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; +import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { sendFederationInviteEmail } from '../config/mailer.js'; + +// M13: rate limit the unauthenticated federation receive endpoint +const federationReceiveLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many federation requests. Please try again later.' }, +}); + import { getFederationDomain, isFederationEnabled, @@ -116,7 +127,7 @@ router.post('/invite', authenticateToken, async (req, res) => { }); // ── POST /api/federation/receive — Accept incoming invitation from remote ─── -router.post('/receive', async (req, res) => { +router.post('/receive', federationReceiveLimiter, async (req, res) => { try { if (!isFederationEnabled()) { return res.status(400).json({ error: 'Federation is not configured on this instance' }); diff --git a/server/routes/recordings.js b/server/routes/recordings.js index 0fcfcb0..f4b8cc8 100644 --- a/server/routes/recordings.js +++ b/server/routes/recordings.js @@ -3,6 +3,7 @@ import { authenticateToken } from '../middleware/auth.js'; import { getDb } from '../config/database.js'; import { getRecordings, + getRecordingByRecordId, deleteRecording, publishRecording, } from '../config/bbb.js'; @@ -13,6 +14,25 @@ const router = Router(); router.get('/', authenticateToken, async (req, res) => { try { const { meetingID } = req.query; + + // M11: verify user has access to the room if a meetingID is specified + if (meetingID) { + const db = getDb(); + const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [meetingID]); + if (!room) { + return res.status(404).json({ error: 'Room not found' }); + } + if (room.user_id !== req.user.id && req.user.role !== 'admin') { + const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); + if (!share) { + return res.status(403).json({ error: 'No permission to view recordings for this room' }); + } + } + } else if (req.user.role !== 'admin') { + // Non-admins must specify a meetingID + return res.status(400).json({ error: 'meetingID query parameter is required' }); + } + const recordings = await getRecordings(meetingID || undefined); // Format recordings @@ -60,6 +80,14 @@ router.get('/room/:uid', authenticateToken, async (req, res) => { return res.status(404).json({ error: 'Room not found' }); } + // H9: verify requesting user has access to this room + if (room.user_id !== req.user.id && req.user.role !== 'admin') { + const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); + if (!share) { + return res.status(403).json({ error: 'No permission to view recordings for this room' }); + } + } + const recordings = await getRecordings(room.uid); const formatted = recordings.map(rec => { const playback = rec.playback?.format; @@ -97,6 +125,25 @@ router.get('/room/:uid', authenticateToken, async (req, res) => { // DELETE /api/recordings/:recordID router.delete('/:recordID', authenticateToken, async (req, res) => { try { + // M14 fix: look up the recording from BBB to find its meetingID (room UID), + // then verify the user owns or shares that room. + if (req.user.role !== 'admin') { + const rec = await getRecordingByRecordId(req.params.recordID); + if (!rec) { + return res.status(404).json({ error: 'Recording not found' }); + } + const db = getDb(); + const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); + if (!room) { + return res.status(404).json({ error: 'Room not found' }); + } + if (room.user_id !== req.user.id) { + const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); + if (!share) { + return res.status(403).json({ error: 'No permission to delete this recording' }); + } + } + } await deleteRecording(req.params.recordID); res.json({ message: 'Recording deleted' }); } catch (err) { @@ -108,6 +155,25 @@ router.delete('/:recordID', authenticateToken, async (req, res) => { // PUT /api/recordings/:recordID/publish router.put('/:recordID/publish', authenticateToken, async (req, res) => { try { + // M14 fix: look up the recording from BBB to find its meetingID (room UID), + // then verify the user owns or shares that room. + if (req.user.role !== 'admin') { + const rec = await getRecordingByRecordId(req.params.recordID); + if (!rec) { + return res.status(404).json({ error: 'Recording not found' }); + } + const db = getDb(); + const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]); + if (!room) { + return res.status(404).json({ error: 'Room not found' }); + } + if (room.user_id !== req.user.id) { + const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]); + if (!share) { + return res.status(403).json({ error: 'No permission to update this recording' }); + } + } + } const { publish } = req.body; await publishRecording(req.params.recordID, publish); res.json({ message: publish ? 'Recording published' : 'Recording unpublished' }); diff --git a/server/routes/rooms.js b/server/routes/rooms.js index 8992549..ad95993 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -3,6 +3,7 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { @@ -13,11 +14,29 @@ import { isMeetingRunning, } from '../config/bbb.js'; +// L6: constant-time string comparison for access/moderator codes +function timingSafeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations'); if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true }); +// M8: rate limit unauthenticated guest-join to prevent access_code brute-force +const guestJoinLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 15, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many join attempts. Please try again later.' }, +}); + const router = Router(); // Build avatar URL for a user (uploaded image or generated initials) @@ -140,6 +159,20 @@ router.post('/', authenticateToken, async (req, res) => { return res.status(400).json({ error: 'Room name is required' }); } + // M7: field length limits + if (name.trim().length > 100) { + return res.status(400).json({ error: 'Room name must not exceed 100 characters' }); + } + if (welcome_message && welcome_message.length > 2000) { + return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' }); + } + if (access_code && access_code.length > 50) { + return res.status(400).json({ error: 'Access code must not exceed 50 characters' }); + } + if (moderator_code && moderator_code.length > 50) { + return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); + } + const uid = crypto.randomBytes(8).toString('hex'); const db = getDb(); @@ -194,6 +227,20 @@ router.put('/:uid', authenticateToken, async (req, res) => { moderator_code, } = req.body; + // M12: field length limits (same as create) + if (name && name.trim().length > 100) { + return res.status(400).json({ error: 'Room name must not exceed 100 characters' }); + } + if (welcome_message && welcome_message.length > 2000) { + return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' }); + } + if (access_code && access_code.length > 50) { + return res.status(400).json({ error: 'Access code must not exceed 50 characters' }); + } + if (moderator_code && moderator_code.length > 50) { + return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); + } + await db.run(` UPDATE rooms SET name = COALESCE(?, name), @@ -376,7 +423,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => { } // Check access code if set - if (room.access_code && req.body.access_code !== room.access_code) { + if (room.access_code && !timingSafeEqual(req.body.access_code || '', room.access_code)) { return res.status(403).json({ error: 'Wrong access code' }); } @@ -464,7 +511,7 @@ router.get('/:uid/public', async (req, res) => { }); // POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed) -router.post('/:uid/guest-join', async (req, res) => { +router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => { try { const { name, access_code, moderator_code } = req.body; @@ -472,6 +519,11 @@ router.post('/:uid/guest-join', async (req, res) => { return res.status(400).json({ error: 'Name is required' }); } + // L1: limit guest name length + if (name.trim().length > 100) { + return res.status(400).json({ error: 'Name must not exceed 100 characters' }); + } + const db = getDb(); const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]); @@ -480,7 +532,7 @@ router.post('/:uid/guest-join', async (req, res) => { } // Check access code if set - if (room.access_code && access_code !== room.access_code) { + if (room.access_code && !timingSafeEqual(access_code || '', room.access_code)) { return res.status(403).json({ error: 'Wrong access code' }); } @@ -499,7 +551,7 @@ router.post('/:uid/guest-join', async (req, res) => { // Check moderator code let isModerator = !!room.all_join_moderator; - if (!isModerator && moderator_code && room.moderator_code && moderator_code === room.moderator_code) { + if (!isModerator && moderator_code && room.moderator_code && timingSafeEqual(moderator_code, room.moderator_code)) { isModerator = true; } @@ -542,13 +594,30 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => { const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]); if (!room) return res.status(404).json({ error: 'Room not found or no permission' }); + // M16: stream-level size limit – abort as soon as 50 MB is exceeded + const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024; const buffer = await new Promise((resolve, reject) => { const chunks = []; - req.on('data', chunk => chunks.push(chunk)); + let totalSize = 0; + req.on('data', chunk => { + totalSize += chunk.length; + if (totalSize > MAX_PRESENTATION_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: 'File must not exceed 50MB' }); + } + const contentType = req.headers['content-type'] || ''; const extMap = { 'application/pdf': 'pdf', @@ -561,9 +630,6 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => { const ext = extMap[contentType]; if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' }); - // Max 50MB - if (buffer.length > 50 * 1024 * 1024) return res.status(400).json({ error: 'File must not exceed 50MB' }); - // Preserve original filename (sent as X-Filename header) const rawName = req.headers['x-filename']; const originalName = rawName