feat: enforce maximum password length of 64 characters in user registration and password update
Build & Push Docker Image / build (push) Successful in 4m19s
Build & Push Docker Image / build (push) Successful in 4m19s
This commit is contained in:
@@ -38,6 +38,9 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
|||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||||
}
|
}
|
||||||
|
if (password.length > 64) {
|
||||||
|
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
// M9: email format validation
|
// M9: email format validation
|
||||||
if (!EMAIL_RE.test(email)) {
|
if (!EMAIL_RE.test(email)) {
|
||||||
@@ -160,6 +163,9 @@ router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, r
|
|||||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||||
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
|
||||||
}
|
}
|
||||||
|
if (newPassword.length > 64) {
|
||||||
|
return res.status(400).json({ error: 'Password must not exceed 64 characters' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const hash = await bcrypt.hash(newPassword, 12);
|
const hash = await bcrypt.hash(newPassword, 12);
|
||||||
|
|||||||
+26
-10
@@ -15,8 +15,10 @@ router.post('/callback/:uid', async (req, res) => {
|
|||||||
const { token } = req.query;
|
const { token } = req.query;
|
||||||
const expectedToken = getAnalyticsToken(req.params.uid);
|
const expectedToken = getAnalyticsToken(req.params.uid);
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks.
|
||||||
if (!token || token.length !== expectedToken.length ||
|
// Reject non-string tokens (e.g. ?token=a&token=b would yield an array and
|
||||||
|
// crash Buffer.from).
|
||||||
|
if (typeof token !== 'string' || token.length !== expectedToken.length ||
|
||||||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
|
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
|
||||||
return res.status(403).json({ error: 'Invalid token' });
|
return res.status(403).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
@@ -216,15 +218,20 @@ router.get('/:id/export/:format', authenticateToken, async (req, res) => {
|
|||||||
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
|
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
|
// Prefix-escape values that start with a formula trigger character so that
|
||||||
|
// Excel/LibreOffice do not evaluate them as formulas (CSV injection).
|
||||||
|
const escapeCsv = (val) => {
|
||||||
|
if (val === null || val === undefined) return '';
|
||||||
|
let s = String(val);
|
||||||
|
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
|
||||||
|
return '"' + s.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
const header = COLUMNS.map(c => c.header).join(',');
|
const header = COLUMNS.map(c => c.header).join(',');
|
||||||
const csvRows = rows.map(r =>
|
const csvRows = rows.map(r =>
|
||||||
COLUMNS.map(c => {
|
COLUMNS.map(c => escapeCsv(r[c.key])).join(',')
|
||||||
const val = r[c.key];
|
|
||||||
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
|
|
||||||
return '"' + val.replace(/"/g, '""') + '"';
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
}).join(',')
|
|
||||||
);
|
);
|
||||||
const csv = [header, ...csvRows].join('\n');
|
const csv = [header, ...csvRows].join('\n');
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
@@ -233,10 +240,19 @@ router.get('/:id/export/:format', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'xlsx') {
|
if (format === 'xlsx') {
|
||||||
|
// Prefix-escape strings that would otherwise be evaluated as formulas.
|
||||||
|
const sanitizeXlsx = (r) => {
|
||||||
|
const out = {};
|
||||||
|
for (const k of Object.keys(r)) {
|
||||||
|
const v = r[k];
|
||||||
|
out[k] = (typeof v === 'string' && /^[=+\-@\t\r]/.test(v)) ? "'" + v : v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const sheet = workbook.addWorksheet('Analytics');
|
const sheet = workbook.addWorksheet('Analytics');
|
||||||
sheet.columns = COLUMNS;
|
sheet.columns = COLUMNS;
|
||||||
rows.forEach(r => sheet.addRow(r));
|
rows.forEach(r => sheet.addRow(sanitizeXlsx(r)));
|
||||||
// Style header row
|
// Style header row
|
||||||
sheet.getRow(1).font = { bold: true };
|
sheet.getRow(1).font = { bold: true };
|
||||||
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
|
|||||||
+25
-1
@@ -43,6 +43,13 @@ const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
|
|||||||
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})$/;
|
||||||
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
// bcrypt only uses the first 72 bytes; cap input to prevent CPU-DoS on hashing.
|
||||||
|
const MAX_PASSWORD_LENGTH = 64;
|
||||||
|
|
||||||
|
// Pre-computed bcrypt hash of a random string used as a dummy comparison
|
||||||
|
// target when the requested account does not exist. Keeps login timing
|
||||||
|
// roughly constant so we do not leak whether an email is registered.
|
||||||
|
const DUMMY_BCRYPT_HASH = bcrypt.hashSync('dummy-password-for-timing-' + Math.random(), 12);
|
||||||
|
|
||||||
// ── Rate Limiters ────────────────────────────────────────────────────────────
|
// ── Rate Limiters ────────────────────────────────────────────────────────────
|
||||||
const loginLimiter = rateLimit({
|
const loginLimiter = rateLimit({
|
||||||
@@ -168,6 +175,9 @@ router.post('/register', registerLimiter, async (req, res) => {
|
|||||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
|
||||||
}
|
}
|
||||||
|
if (password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -351,10 +361,18 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap password length to keep bcrypt CPU work bounded.
|
||||||
|
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||||
|
|
||||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
// Always run bcrypt against either the real hash or a dummy hash so login
|
||||||
|
// timing does not reveal whether the email is registered.
|
||||||
|
const passwordOk = bcrypt.compareSync(password, user?.password_hash || DUMMY_BCRYPT_HASH);
|
||||||
|
if (!user || !passwordOk) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +578,9 @@ router.put('/password', authenticateToken, passwordLimiter, async (req, res) =>
|
|||||||
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
|
||||||
return res.status(400).json({ error: 'Invalid input' });
|
return res.status(400).json({ error: 'Invalid input' });
|
||||||
}
|
}
|
||||||
|
if (currentPassword.length > MAX_PASSWORD_LENGTH || newPassword.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -823,6 +844,9 @@ router.post('/2fa/disable', authenticateToken, twoFaLimiter, async (req, res) =>
|
|||||||
if (!password || !code) {
|
if (!password || !code) {
|
||||||
return res.status(400).json({ error: 'Password and code are required' });
|
return res.status(400).json({ error: 'Password and code are required' });
|
||||||
}
|
}
|
||||||
|
if (typeof password !== 'string' || password.length > MAX_PASSWORD_LENGTH) {
|
||||||
|
return res.status(400).json({ error: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
const user = await db.get('SELECT password_hash, totp_secret, totp_enabled FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
|||||||
@@ -200,7 +200,13 @@ router.get('/callback', callbackLimiter, async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// Link OAuth to existing account
|
// Only auto-link to an existing local account if the IdP has actually
|
||||||
|
// verified the email. Otherwise an attacker who registers at the IdP
|
||||||
|
// with someone else's email could take over the local account.
|
||||||
|
if (userInfo.email_verified !== true) {
|
||||||
|
log.auth.warn(`OAuth account-linking blocked: provider did not assert email_verified=true for ${email}`);
|
||||||
|
return errorRedirect('Your OAuth provider has not verified this email address. Please verify it with the provider before logging in here.');
|
||||||
|
}
|
||||||
await db.run(
|
await db.run(
|
||||||
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
['oidc', sub, user.id],
|
['oidc', sub, user.id],
|
||||||
|
|||||||
+18
-7
@@ -24,6 +24,14 @@ import {
|
|||||||
discoverInstance,
|
discoverInstance,
|
||||||
} from '../config/federation.js';
|
} from '../config/federation.js';
|
||||||
|
|
||||||
|
// Avatar image filenames are produced by the upload endpoint as
|
||||||
|
// "<userId>_<timestamp>.<ext>". Reject anything that doesn't match this shape
|
||||||
|
// so a guest can't smuggle path traversal or arbitrary URL fragments through
|
||||||
|
// the unauthenticated guest-join endpoint.
|
||||||
|
const SAFE_AVATAR_FILENAME_RE = /^[0-9]+_[0-9]+\.(jpg|png|gif|webp)$/;
|
||||||
|
// Mirrors auth.js: only allow hex/hsl/named CSS colors.
|
||||||
|
const SAFE_AVATAR_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})$/;
|
||||||
|
|
||||||
// L6: constant-time string comparison for access/moderator codes
|
// L6: constant-time string comparison for access/moderator codes
|
||||||
function timingSafeEqual(a, b) {
|
function timingSafeEqual(a, b) {
|
||||||
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
@@ -664,15 +672,18 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
|
// Validate client-supplied avatar fields before embedding them in a URL
|
||||||
|
// that BBB will fetch — guest-join is unauthenticated.
|
||||||
|
const safeAvatarImage = (typeof avatar_image === 'string' && SAFE_AVATAR_FILENAME_RE.test(avatar_image))
|
||||||
|
? avatar_image : null;
|
||||||
|
const safeAvatarColor = (typeof avatar_color === 'string' && SAFE_AVATAR_COLOR_RE.test(avatar_color))
|
||||||
|
? avatar_color : null;
|
||||||
let guestAvatarURL;
|
let guestAvatarURL;
|
||||||
if (avatar_image) {
|
if (safeAvatarImage) {
|
||||||
// Use avatar image of the logged-in user
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/${encodeURIComponent(safeAvatarImage)}`;
|
||||||
guestAvatarURL = `${baseUrl}/api/auth/avatar/${avatar_image}`;
|
} else if (safeAvatarColor) {
|
||||||
} else if (avatar_color) {
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(safeAvatarColor)}`;
|
||||||
// Initials with user color
|
|
||||||
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}?color=${encodeURIComponent(avatar_color)}`;
|
|
||||||
} else {
|
} else {
|
||||||
// Default: initials without color
|
|
||||||
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
|
||||||
}
|
}
|
||||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
|
||||||
|
|||||||
Reference in New Issue
Block a user