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
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user