diff --git a/README.md b/README.md index 1fa015b..51ee5e9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, - ✉️ **Email Verification** – Optional SMTP-based email verification for user registration - 👤 **User Profiles** – Customizable avatars, themes, and language preferences - 📱 **Responsive Design** – Works seamlessly on mobile, tablet, and desktop -- 🌐 **Federation** – Invite users from remote Redlight instances via RSA-signed messages +- 🌐 **Federation** – Invite users from remote Redlight instances via Ed25519-signed messages - 🐉 **DragonflyDB / Redis** – JWT blacklisting for secure token revocation on logout ### Admin Features @@ -376,7 +376,7 @@ Federation allows users on different Redlight instances to invite each other int ### Setup 1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`. -2. On first start, an RSA 2048-bit key pair is generated automatically and stored in `server/config/federation_key.pem`. +2. On first start, an Ed25519 key pair is generated automatically and stored in `server/config/federation_key.pem`. 3. Other instances discover your public key via `GET /.well-known/redlight`. ### How it works @@ -384,7 +384,7 @@ Federation allows users on different Redlight instances to invite each other int 1. **User A** on `instance-a.com` sends an invite to `userB@instance-b.com`. 2. Redlight looks up `instance-b.com/.well-known/redlight` to discover the federation API. 3. The invite payload is signed with instance A's private key and POSTed to instance B's `/api/federation/receive`. -4. Instance B verifies the RSA signature against instance A's public key. +4. Instance B verifies the Ed25519 signature against instance A's public key. 5. **User B** sees the invitation and can accept or decline. Accepting provides a join link to the remote room. --- diff --git a/server/config/federation.js b/server/config/federation.js index d746533..84b956a 100644 --- a/server/config/federation.js +++ b/server/config/federation.js @@ -10,7 +10,7 @@ const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || ''; let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || ''; let publicKeyPem = ''; -// Load or generate RSA keys +// Load or generate Ed25519 keys if (FEDERATION_DOMAIN) { const keyPath = path.join(__dirname, 'federation_key.pem'); @@ -19,9 +19,8 @@ if (FEDERATION_DOMAIN) { } if (!privateKeyPem) { - console.log('Generating new RSA federation key pair...'); - const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { - modulusLength: 2048, + console.log('Generating new Ed25519 federation key pair...'); + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); @@ -47,7 +46,7 @@ export function getFederationDomain() { } /** - * Get this instance's RSA public key (PEM format). + * Get this instance's Ed25519 public key (PEM format). */ export function getPublicKey() { return publicKeyPem; @@ -61,21 +60,18 @@ export function isFederationEnabled() { } /** - * RSA sign a JSON payload. + * Ed25519 sign a JSON payload. * @param {object} payload * @returns {string} base64 signature */ export function signPayload(payload) { if (!privateKeyPem) throw new Error("Federation private key not available"); const data = Buffer.from(JSON.stringify(payload)); - const sign = crypto.createSign('SHA256'); - sign.update(data); - sign.end(); - return sign.sign(privateKeyPem, 'base64'); + return crypto.sign(null, data, privateKeyPem).toString('base64'); } /** - * Verify an RSA signature against a JSON payload using a remote public key. + * Verify an Ed25519 signature against a JSON payload using a remote public key. * @param {object} payload * @param {string} signature base64 signature * @param {string} remotePublicKeyPem @@ -85,10 +81,7 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) { if (!remotePublicKeyPem || !signature) return false; try { const data = Buffer.from(JSON.stringify(payload)); - const verify = crypto.createVerify('SHA256'); - verify.update(data); - verify.end(); - return verify.verify(remotePublicKeyPem, signature, 'base64'); + return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64')); } catch (e) { console.error('Signature verification error:', e.message); return false; diff --git a/server/config/mailer.js b/server/config/mailer.js index 82339e7..aba8a04 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -49,19 +49,25 @@ export function isMailerConfigured() { * @param {string} verifyUrl – full verification URL * @param {string} appName – branding app name (default "Redlight") */ +// S3: sanitize name for use in email From header (strip quotes, newlines, control chars) +function sanitizeHeaderValue(str) { + return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100); +} + export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') { if (!transporter) { throw new Error('SMTP not configured'); } const from = process.env.SMTP_FROM || process.env.SMTP_USER; + const headerAppName = sanitizeHeaderValue(appName); const safeName = escapeHtml(name); const safeAppName = escapeHtml(appName); await transporter.sendMail({ - from: `"${appName}" <${from}>`, + from: `"${headerAppName}" <${from}>`, to, - subject: `${appName} – Verify your email`, + subject: `${headerAppName} – Verify your email`, html: `

Hey ${safeName} 👋

@@ -99,6 +105,7 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me if (!transporter) return; // silently skip if SMTP not configured const from = process.env.SMTP_FROM || process.env.SMTP_USER; + const headerAppName = sanitizeHeaderValue(appName); const safeName = escapeHtml(name); const safeFromUser = escapeHtml(fromUser); const safeRoomName = escapeHtml(roomName); @@ -106,9 +113,9 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me const safeAppName = escapeHtml(appName); await transporter.sendMail({ - from: `"${appName}" <${from}>`, + from: `"${headerAppName}" <${from}>`, to, - subject: `${appName} – Meeting invitation from ${fromUser}`, + subject: `${headerAppName} – Meeting invitation from ${sanitizeHeaderValue(fromUser)}`, html: `

Hey ${safeName} 👋

diff --git a/server/routes/admin.js b/server/routes/admin.js index e738f03..70249bc 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -102,6 +102,11 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res) await db.run('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [role, req.params.id]); const updated = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [req.params.id]); + // S7: verify user actually exists + if (!updated) { + return res.status(404).json({ error: 'User not found' }); + } + res.json({ user: updated }); } catch (err) { console.error('Update role error:', err); diff --git a/server/routes/auth.js b/server/routes/auth.js index 8c5137c..ec74c91 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -12,6 +12,7 @@ import redis from '../config/redis.js'; import { authenticateToken, generateToken } from '../middleware/auth.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { themes } from '../../src/themes/index.js'; +import { languages } from '../../src/i18n/index.js'; if (!process.env.JWT_SECRET) { console.error('FATAL: JWT_SECRET environment variable is not set.'); @@ -35,7 +36,7 @@ function makeRedisStore(prefix) { const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; const VALID_THEMES = new Set(themes.map(t => t.id)); -const VALID_LANGUAGES = new Set(['en', 'de']); +const VALID_LANGUAGES = new Set(Object.keys(languages)); // 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})$/; @@ -88,6 +89,16 @@ const avatarLimiter = rateLimit({ store: makeRedisStore('rl:avatar:'), }); +// S1: rate limit resend-verification to prevent SMTP abuse +const resendVerificationLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests. Please try again later.' }, + store: makeRedisStore('rl:resend:'), +}); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); @@ -224,7 +235,7 @@ router.get('/verify-email', async (req, res) => { }); // POST /api/auth/resend-verification -router.post('/resend-verification', async (req, res) => { +router.post('/resend-verification', resendVerificationLimiter, async (req, res) => { try { const { email } = req.body; if (!email) { @@ -494,8 +505,9 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => { const db = getDb(); const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]); if (current?.avatar_image) { - const oldPath = path.join(uploadsDir, current.avatar_image); - if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + // S8: defense-in-depth path traversal check on DB-stored filename + const oldPath = path.resolve(uploadsDir, current.avatar_image); + if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } fs.writeFileSync(filepath, buffer); @@ -515,8 +527,9 @@ router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => { const db = getDb(); const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]); if (current?.avatar_image) { - const oldPath = path.join(uploadsDir, current.avatar_image); - if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + // S8: defense-in-depth path traversal check on DB-stored filename + const oldPath = path.resolve(uploadsDir, current.avatar_image); + if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]); const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]); diff --git a/server/routes/branding.js b/server/routes/branding.js index 19fb580..452b2ac 100644 --- a/server/routes/branding.js +++ b/server/routes/branding.js @@ -117,7 +117,7 @@ router.post('/logo', authenticateToken, requireAdmin, (req, res) => { upload.single('logo')(req, res, async (err) => { if (err) { if (err instanceof multer.MulterError) { - return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 2MB)' : err.message }); + return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.message }); } return res.status(400).json({ error: err.message }); } diff --git a/server/routes/federation.js b/server/routes/federation.js index 7e575d5..55c4ec2 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -147,6 +147,12 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { return res.status(400).json({ error: 'Incomplete invitation payload' }); } + // S4: validate field lengths from remote to prevent oversized DB entries + if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 || + room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) { + return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' }); + } + // Fetch the sender's public key dynamically const { domain: senderDomain } = parseAddress(from_user); if (!senderDomain) { @@ -159,7 +165,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => { } if (!verifyPayload(payload, signature, publicKey)) { - return res.status(403).json({ error: 'Invalid federation RSA signature' }); + return res.status(403).json({ error: 'Invalid federation signature' }); } // Parse the target address and find local user diff --git a/server/routes/rooms.js b/server/routes/rooms.js index ad95993..a074f08 100644 --- a/server/routes/rooms.js +++ b/server/routes/rooms.js @@ -172,6 +172,13 @@ router.post('/', authenticateToken, async (req, res) => { if (moderator_code && moderator_code.length > 50) { return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); } + // S2: validate max_participants as non-negative integer + if (max_participants !== undefined && max_participants !== null) { + const mp = Number(max_participants); + if (!Number.isInteger(mp) || mp < 0 || mp > 10000) { + return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' }); + } + } const uid = crypto.randomBytes(8).toString('hex'); const db = getDb(); @@ -240,6 +247,13 @@ router.put('/:uid', authenticateToken, async (req, res) => { if (moderator_code && moderator_code.length > 50) { return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); } + // S2: validate max_participants on update + if (max_participants !== undefined && max_participants !== null) { + const mp = Number(max_participants); + if (!Number.isInteger(mp) || mp < 0 || mp > 10000) { + return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' }); + } + } await db.run(` UPDATE rooms SET @@ -641,8 +655,9 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => { // Remove old presentation file if exists if (room.presentation_file) { - const oldPath = path.join(presentationsDir, room.presentation_file); - if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + // S8: defense-in-depth path traversal check + const oldPath = path.resolve(presentationsDir, room.presentation_file); + if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath); } fs.writeFileSync(filepath, buffer); @@ -663,8 +678,9 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => { if (!room) return res.status(404).json({ error: 'Room not found or no permission' }); if (room.presentation_file) { - const filepath = path.join(presentationsDir, room.presentation_file); - if (fs.existsSync(filepath)) fs.unlinkSync(filepath); + // S8: defense-in-depth path traversal check + const filepath = path.resolve(presentationsDir, room.presentation_file); + if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath); } await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);