feat(security): enhance input validation and security measures across various routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s
This commit is contained in:
@@ -5,6 +5,20 @@ import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParam
|
||||
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
|
||||
const BBB_SECRET = process.env.BBB_SECRET || '';
|
||||
|
||||
if (!BBB_SECRET) {
|
||||
log.bbb.warn('WARNING: BBB_SECRET is not set. BBB API calls will use an empty secret.');
|
||||
}
|
||||
|
||||
// HTML-escape for safe embedding in BBB welcome messages
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getChecksum(apiCall, params) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const raw = apiCall + queryString + BBB_SECRET;
|
||||
@@ -63,13 +77,13 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
|
||||
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
||||
|
||||
// Build welcome message with guest invite link
|
||||
let welcome = room.welcome_message || t('defaultWelcome');
|
||||
// HTML-escape user-controlled content to prevent stored XSS via BBB
|
||||
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome');
|
||||
if (logoutURL) {
|
||||
const guestLink = `${logoutURL}/join/${room.uid}`;
|
||||
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`;
|
||||
if (room.access_code) {
|
||||
welcome += `<br>Access Code: <b>${room.access_code}</b>`;
|
||||
}
|
||||
welcome += `<br><br>To invite other participants, share this link:<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
|
||||
// Access code is intentionally NOT shown in the welcome message to prevent
|
||||
// leaking it to all meeting participants.
|
||||
}
|
||||
|
||||
const params = {
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { log } from './logger.js';
|
||||
|
||||
import dns from 'dns';
|
||||
import net from 'net';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -93,13 +96,69 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain resolves to a private/internal IP address (SSRF protection).
|
||||
* Blocks RFC 1918, loopback, link-local, and cloud metadata IPs.
|
||||
* @param {string} domain
|
||||
* @returns {Promise<void>} throws if domain resolves to a blocked IP
|
||||
*/
|
||||
async function assertPublicDomain(domain) {
|
||||
// Allow localhost only in development
|
||||
if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('Federation to localhost is blocked in production');
|
||||
}
|
||||
return; // allow in dev
|
||||
}
|
||||
|
||||
// If domain is a raw IP, check it directly
|
||||
if (net.isIP(domain)) {
|
||||
if (isPrivateIP(domain)) {
|
||||
throw new Error(`Federation blocked: ${domain} resolves to a private IP`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve domain and check all resulting IPs
|
||||
const { resolve4, resolve6 } = dns.promises;
|
||||
const ips = [];
|
||||
try { ips.push(...await resolve4(domain)); } catch {}
|
||||
try { ips.push(...await resolve6(domain)); } catch {}
|
||||
|
||||
if (ips.length === 0) {
|
||||
throw new Error(`Federation blocked: could not resolve ${domain}`);
|
||||
}
|
||||
|
||||
for (const ip of ips) {
|
||||
if (isPrivateIP(ip)) {
|
||||
throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIP(ip) {
|
||||
// IPv4 private ranges
|
||||
if (/^10\./.test(ip)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
||||
if (/^192\.168\./.test(ip)) return true;
|
||||
if (/^127\./.test(ip)) return true;
|
||||
if (/^0\./.test(ip)) return true;
|
||||
if (/^169\.254\./.test(ip)) return true; // link-local
|
||||
if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a remote Redlight instance's federation API base URL.
|
||||
* Fetches https://{domain}/.well-known/redlight and caches the result.
|
||||
* Includes SSRF protection: blocks private/internal IPs.
|
||||
* @param {string} domain
|
||||
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
|
||||
*/
|
||||
export async function discoverInstance(domain) {
|
||||
// SSRF protection: validate domain doesn't resolve to internal IP
|
||||
await assertPublicDomain(domain);
|
||||
|
||||
const cached = discoveryCache.get(domain);
|
||||
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
|
||||
return cached;
|
||||
@@ -112,7 +171,8 @@ export async function discoverInstance(domain) {
|
||||
try {
|
||||
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
} catch (e) {
|
||||
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) {
|
||||
// HTTP fallback only allowed in development for localhost
|
||||
if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') {
|
||||
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||
} else throw e;
|
||||
}
|
||||
@@ -128,7 +188,9 @@ export async function discoverInstance(domain) {
|
||||
|
||||
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
|
||||
const result = {
|
||||
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'),
|
||||
baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production')
|
||||
? baseUrl.replace('https://localhost', 'http://localhost')
|
||||
: baseUrl,
|
||||
publicKey: data.public_key,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user