Update README and configuration to replace RSA with Ed25519 for federation security
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s

This commit is contained in:
2026-02-28 20:19:59 +01:00
parent 2831f80ab4
commit c281628fdc
8 changed files with 74 additions and 34 deletions

View File

@@ -19,7 +19,7 @@ A modern, self-hosted BigBlueButton frontend with beautiful themes, federation,
- ✉️ **Email Verification** Optional SMTP-based email verification for user registration - ✉️ **Email Verification** Optional SMTP-based email verification for user registration
- 👤 **User Profiles** Customizable avatars, themes, and language preferences - 👤 **User Profiles** Customizable avatars, themes, and language preferences
- 📱 **Responsive Design** Works seamlessly on mobile, tablet, and desktop - 📱 **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 - 🐉 **DragonflyDB / Redis** JWT blacklisting for secure token revocation on logout
### Admin Features ### Admin Features
@@ -376,7 +376,7 @@ Federation allows users on different Redlight instances to invite each other int
### Setup ### Setup
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`. 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`. 3. Other instances discover your public key via `GET /.well-known/redlight`.
### How it works ### 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`. 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. 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`. 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. 5. **User B** sees the invitation and can accept or decline. Accepting provides a join link to the remote room.
--- ---

View File

@@ -10,7 +10,7 @@ const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || '';
let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || ''; let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || '';
let publicKeyPem = ''; let publicKeyPem = '';
// Load or generate RSA keys // Load or generate Ed25519 keys
if (FEDERATION_DOMAIN) { if (FEDERATION_DOMAIN) {
const keyPath = path.join(__dirname, 'federation_key.pem'); const keyPath = path.join(__dirname, 'federation_key.pem');
@@ -19,9 +19,8 @@ if (FEDERATION_DOMAIN) {
} }
if (!privateKeyPem) { if (!privateKeyPem) {
console.log('Generating new RSA federation key pair...'); console.log('Generating new Ed25519 federation key pair...');
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', 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() { export function getPublicKey() {
return publicKeyPem; return publicKeyPem;
@@ -61,21 +60,18 @@ export function isFederationEnabled() {
} }
/** /**
* RSA sign a JSON payload. * Ed25519 sign a JSON payload.
* @param {object} payload * @param {object} payload
* @returns {string} base64 signature * @returns {string} base64 signature
*/ */
export function signPayload(payload) { export function signPayload(payload) {
if (!privateKeyPem) throw new Error("Federation private key not available"); if (!privateKeyPem) throw new Error("Federation private key not available");
const data = Buffer.from(JSON.stringify(payload)); const data = Buffer.from(JSON.stringify(payload));
const sign = crypto.createSign('SHA256'); return crypto.sign(null, data, privateKeyPem).toString('base64');
sign.update(data);
sign.end();
return sign.sign(privateKeyPem, '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 {object} payload
* @param {string} signature base64 signature * @param {string} signature base64 signature
* @param {string} remotePublicKeyPem * @param {string} remotePublicKeyPem
@@ -85,10 +81,7 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
if (!remotePublicKeyPem || !signature) return false; if (!remotePublicKeyPem || !signature) return false;
try { try {
const data = Buffer.from(JSON.stringify(payload)); const data = Buffer.from(JSON.stringify(payload));
const verify = crypto.createVerify('SHA256'); return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64'));
verify.update(data);
verify.end();
return verify.verify(remotePublicKeyPem, signature, 'base64');
} catch (e) { } catch (e) {
console.error('Signature verification error:', e.message); console.error('Signature verification error:', e.message);
return false; return false;

View File

@@ -49,19 +49,25 @@ export function isMailerConfigured() {
* @param {string} verifyUrl full verification URL * @param {string} verifyUrl full verification URL
* @param {string} appName branding app name (default "Redlight") * @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') { export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
if (!transporter) { if (!transporter) {
throw new Error('SMTP not configured'); throw new Error('SMTP not configured');
} }
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeAppName = escapeHtml(appName); const safeAppName = escapeHtml(appName);
await transporter.sendMail({ await transporter.sendMail({
from: `"${appName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${appName} Verify your email`, subject: `${headerAppName} Verify your email`,
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
@@ -99,6 +105,7 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
if (!transporter) return; // silently skip if SMTP not configured if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER; const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser); const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName); const safeRoomName = escapeHtml(roomName);
@@ -106,9 +113,9 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
const safeAppName = escapeHtml(appName); const safeAppName = escapeHtml(appName);
await transporter.sendMail({ await transporter.sendMail({
from: `"${appName}" <${from}>`, from: `"${headerAppName}" <${from}>`,
to, to,
subject: `${appName} Meeting invitation from ${fromUser}`, subject: `${headerAppName} Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
html: ` html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;"> <div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2> <h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>

View File

@@ -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]); 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]); 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 }); res.json({ user: updated });
} catch (err) { } catch (err) {
console.error('Update role error:', err); console.error('Update role error:', err);

View File

@@ -12,6 +12,7 @@ import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js'; import { authenticateToken, generateToken } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js'; import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
import { themes } from '../../src/themes/index.js'; import { themes } from '../../src/themes/index.js';
import { languages } from '../../src/i18n/index.js';
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.'); 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 EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
const VALID_THEMES = new Set(themes.map(t => t.id)); 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 // 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 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:'), 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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars'); const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
@@ -224,7 +235,7 @@ router.get('/verify-email', async (req, res) => {
}); });
// POST /api/auth/resend-verification // POST /api/auth/resend-verification
router.post('/resend-verification', async (req, res) => { router.post('/resend-verification', resendVerificationLimiter, async (req, res) => {
try { try {
const { email } = req.body; const { email } = req.body;
if (!email) { if (!email) {
@@ -494,8 +505,9 @@ router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
const db = getDb(); const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]); const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) { if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image); // S8: defense-in-depth path traversal check on DB-stored filename
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); const oldPath = path.resolve(uploadsDir, current.avatar_image);
if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
fs.writeFileSync(filepath, buffer); fs.writeFileSync(filepath, buffer);
@@ -515,8 +527,9 @@ router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
const db = getDb(); const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]); const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) { if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image); // S8: defense-in-depth path traversal check on DB-stored filename
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); 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]); 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]); 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]);

View File

@@ -117,7 +117,7 @@ router.post('/logo', authenticateToken, requireAdmin, (req, res) => {
upload.single('logo')(req, res, async (err) => { upload.single('logo')(req, res, async (err) => {
if (err) { if (err) {
if (err instanceof multer.MulterError) { 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 }); return res.status(400).json({ error: err.message });
} }

View File

@@ -147,6 +147,12 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
return res.status(400).json({ error: 'Incomplete invitation payload' }); 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 // Fetch the sender's public key dynamically
const { domain: senderDomain } = parseAddress(from_user); const { domain: senderDomain } = parseAddress(from_user);
if (!senderDomain) { if (!senderDomain) {
@@ -159,7 +165,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
} }
if (!verifyPayload(payload, signature, publicKey)) { 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 // Parse the target address and find local user

View File

@@ -172,6 +172,13 @@ router.post('/', authenticateToken, async (req, res) => {
if (moderator_code && moderator_code.length > 50) { if (moderator_code && moderator_code.length > 50) {
return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); 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 uid = crypto.randomBytes(8).toString('hex');
const db = getDb(); const db = getDb();
@@ -240,6 +247,13 @@ router.put('/:uid', authenticateToken, async (req, res) => {
if (moderator_code && moderator_code.length > 50) { if (moderator_code && moderator_code.length > 50) {
return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' }); 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(` await db.run(`
UPDATE rooms SET UPDATE rooms SET
@@ -641,8 +655,9 @@ router.post('/:uid/presentation', authenticateToken, async (req, res) => {
// Remove old presentation file if exists // Remove old presentation file if exists
if (room.presentation_file) { if (room.presentation_file) {
const oldPath = path.join(presentationsDir, room.presentation_file); // S8: defense-in-depth path traversal check
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); const oldPath = path.resolve(presentationsDir, room.presentation_file);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
} }
fs.writeFileSync(filepath, buffer); 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) return res.status(404).json({ error: 'Room not found or no permission' });
if (room.presentation_file) { if (room.presentation_file) {
const filepath = path.join(presentationsDir, room.presentation_file); // S8: defense-in-depth path traversal check
if (fs.existsSync(filepath)) fs.unlinkSync(filepath); 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]); await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);