Enhance security and validation across multiple routes:
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
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:
@@ -81,10 +81,16 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
|
||||
params.lockSettingsLockOnJoin = 'true';
|
||||
}
|
||||
|
||||
// Build optional presentation XML body
|
||||
const xmlBody = presentationUrl
|
||||
? `<modules><module name="presentation"><document url="${presentationUrl}" /></module></modules>`
|
||||
: 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, '<')
|
||||
.replace(/>/g, '>');
|
||||
xmlBody = `<modules><module name="presentation"><document url="${safeUrl}" /></module></modules>`;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.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: `
|
||||
<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 ${name} 👋</h2>
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
<p>Please verify your email address by clicking the button below:</p>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${verifyUrl}"
|
||||
@@ -61,7 +74,7 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">
|
||||
Or copy this link in your browser:<br/>
|
||||
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${verifyUrl}</a>
|
||||
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
|
||||
</p>
|
||||
<p style="font-size:13px;color:#7f849c;">This link is valid for 24 hours.</p>
|
||||
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
|
||||
@@ -86,6 +99,11 @@ 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 safeName = escapeHtml(name);
|
||||
const safeFromUser = escapeHtml(fromUser);
|
||||
const safeRoomName = escapeHtml(roomName);
|
||||
const safeMessage = message ? escapeHtml(message) : null;
|
||||
const safeAppName = escapeHtml(appName);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${appName}" <${from}>`,
|
||||
@@ -93,12 +111,12 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
|
||||
subject: `${appName} – Meeting invitation from ${fromUser}`,
|
||||
html: `
|
||||
<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 ${name} 👋</h2>
|
||||
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${fromUser}</strong>.</p>
|
||||
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
|
||||
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
|
||||
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
|
||||
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p>
|
||||
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${roomName}</p>
|
||||
${message ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${message}"</p>` : ''}
|
||||
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
|
||||
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">"${safeMessage}"</p>` : ''}
|
||||
</div>
|
||||
<p style="text-align:center;margin:28px 0;">
|
||||
<a href="${inboxUrl}"
|
||||
|
||||
@@ -25,7 +25,11 @@ const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : r
|
||||
app.set('trust proxy', trustProxy);
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
// M10: restrict CORS in production; allow all in development
|
||||
const corsOptions = process.env.APP_URL
|
||||
? { origin: process.env.APP_URL, credentials: true }
|
||||
: {};
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize database & start server
|
||||
|
||||
@@ -3,7 +3,11 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../config/database.js';
|
||||
import redis from '../config/redis.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;
|
||||
|
||||
export async function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
@@ -3,6 +3,8 @@ import bcrypt from 'bcryptjs';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/admin/users - Create user (admin)
|
||||
@@ -14,13 +16,23 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
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();
|
||||
|
||||
@@ -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, '"')
|
||||
.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 = `<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' });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user