UNPKG

@nanggo/social-preview

Version:

Generate beautiful social media preview images from any URL

510 lines (509 loc) 19.8 kB
"use strict"; /** * Enhanced Secure HTTP/HTTPS Agent implementation * * Addresses TOCTOU (Time-of-Check-Time-of-Use) vulnerabilities in DNS rebinding protection * by implementing DNS result caching and socket-level IP validation */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.cleanup = void 0; exports.createEnhancedSecureHttpAgent = createEnhancedSecureHttpAgent; exports.createEnhancedSecureHttpsAgent = createEnhancedSecureHttpsAgent; exports.getEnhancedSecureHttpAgent = getEnhancedSecureHttpAgent; exports.getEnhancedSecureHttpsAgent = getEnhancedSecureHttpsAgent; exports.getEnhancedSecureAgentForUrl = getEnhancedSecureAgentForUrl; exports.getDNSCacheStats = getDNSCacheStats; exports.invalidateDNSCache = invalidateDNSCache; exports.validateRequestSecurity = validateRequestSecurity; const http_1 = __importDefault(require("http")); const https_1 = __importDefault(require("https")); const dns_1 = __importDefault(require("dns")); const net_1 = __importDefault(require("net")); const tls_1 = __importDefault(require("tls")); const ip_validation_1 = require("./ip-validation"); const logger_1 = require("./logger"); const security_1 = require("../constants/security"); /** * DNS Cache with TTL support * Prevents TOCTOU attacks by ensuring consistent IP resolution */ class SecureDNSCache { cache = new Map(); defaultTTL = 5 * 60 * 1000; // 5 minutes maxCacheSize = 1000; cleanupInterval; constructor() { // Adaptive cleanup interval based on TTL (cleanup every TTL/10, min 10s, max 60s) const cleanupIntervalMs = Math.max(10000, Math.min(60000, this.defaultTTL / 10)); this.cleanupInterval = setInterval(() => this.cleanup(), cleanupIntervalMs); this.cleanupInterval.unref(); } /** * Get cached DNS result or perform fresh lookup */ async lookup(hostname) { const cacheKey = hostname.toLowerCase(); const cached = this.cache.get(cacheKey); const now = Date.now(); // Return cached result if valid if (cached && now - cached.timestamp < cached.ttl) { return cached.addresses; } // Perform fresh DNS lookup return new Promise((resolve, reject) => { const lookupOptions = { all: true, family: 0 // Both IPv4 and IPv6 }; dns_1.default.lookup(hostname, lookupOptions, (err, addresses) => { if (err) { return reject(err); } const addressList = Array.isArray(addresses) ? addresses : [addresses]; if (addressList.length === 0) { return reject(new Error(`No IP addresses resolved for hostname: ${hostname}`)); } // Cache the result const cacheEntry = { addresses: addressList, timestamp: now, ttl: this.defaultTTL, hostname }; // Manage cache size with LRU eviction if (this.cache.size >= this.maxCacheSize) { // Remove multiple old entries if near capacity to prevent frequent evictions const entriesToRemove = Math.max(1, Math.floor(this.maxCacheSize * 0.1)); // Remove 10% let removed = 0; for (const [key] of this.cache.entries()) { if (removed >= entriesToRemove) break; this.cache.delete(key); removed++; } } this.cache.set(cacheKey, cacheEntry); resolve(addressList); }); }); } /** * Get cached IP addresses for hostname (used for socket validation) */ getCachedIPs(hostname) { const cached = this.cache.get(hostname.toLowerCase()); const now = Date.now(); if (cached && now - cached.timestamp < cached.ttl) { return cached.addresses.map(addr => addr.address); } return null; } /** * Invalidate cache entry for hostname */ invalidate(hostname) { this.cache.delete(hostname.toLowerCase()); } /** * Clean up expired entries and enforce size limits */ cleanup() { const now = Date.now(); const toDelete = []; // Find expired entries for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp >= entry.ttl) { toDelete.push(key); } } // Remove expired entries toDelete.forEach(key => this.cache.delete(key)); // If still over capacity after expiry cleanup, perform LRU eviction if (this.cache.size > this.maxCacheSize * 0.9) { // Start cleanup at 90% capacity const excessEntries = this.cache.size - Math.floor(this.maxCacheSize * 0.8); // Target 80% capacity let removed = 0; for (const [key] of this.cache.entries()) { if (removed >= excessEntries) break; this.cache.delete(key); removed++; } logger_1.logger.debug(`DNS cache cleanup: removed ${toDelete.length} expired + ${removed} LRU entries, ${this.cache.size} remaining`); } else { logger_1.logger.debug(`DNS cache cleanup: removed ${toDelete.length} expired entries, ${this.cache.size} remaining`); } } /** * Get cache statistics */ getStats() { return { size: this.cache.size, maxSize: this.maxCacheSize, entries: Array.from(this.cache.entries()).map(([hostname, entry]) => ({ hostname, addresses: entry.addresses.map(a => a.address), age: Date.now() - entry.timestamp, ttl: entry.ttl })) }; } /** * Clear cache and cleanup */ destroy() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } this.cache.clear(); } } // Global DNS cache instance const dnsCache = new SecureDNSCache(); /** * Validate IP addresses for security violations */ function validateIPAddresses(addresses) { const blockedIPs = []; const allowedIPs = []; for (const addr of addresses) { // Treat malformed IPs as blocked to avoid bypassing security checks if (net_1.default.isIP(addr.address) === 0) { blockedIPs.push(addr.address); continue; } // Check for various dangerous IP ranges if ((0, ip_validation_1.isPrivateOrReservedIP)(addr.address)) { blockedIPs.push(addr.address); } // Additional IPv6 checks for IPv4-mapped addresses else if (addr.family === 6 && addr.address.startsWith('::ffff:')) { const ipv4Part = addr.address.substring(7); // Remove '::ffff:' prefix if ((0, ip_validation_1.isPrivateOrReservedIP)(ipv4Part)) { blockedIPs.push(addr.address); } else { allowedIPs.push(addr.address); } } // Additional dangerous IPv6 ranges else if (addr.family === 6) { const ip = addr.address.toLowerCase(); if (ip === '::1' || // Localhost (exact match) ip.startsWith('fe80:') || // Link-local ip.startsWith('fc00:') || // Unique local ip.startsWith('fd00:') || // Unique local ip.startsWith('ff00:') // Multicast ) { blockedIPs.push(addr.address); } else { allowedIPs.push(addr.address); } } else { allowedIPs.push(addr.address); } } return { allowed: blockedIPs.length === 0, blockedIPs, allowedIPs, reason: blockedIPs.length > 0 ? `Blocked private or reserved IPs: ${blockedIPs.join(', ')}` : undefined }; } /** * Enhanced secure DNS lookup with caching and comprehensive validation */ const enhancedSecureLookup = (hostname, options, callback) => { // Normalize parameters let actualCallback; if (typeof options === 'function') { actualCallback = options; } else { actualCallback = (callback || (() => { })); } // Use cached/fresh DNS lookup dnsCache.lookup(hostname) .then(addresses => { // Validate all resolved addresses const validation = validateIPAddresses(addresses); if (!validation.allowed) { logger_1.logger.warn('DNS lookup blocked', { hostname, reason: validation.reason, blockedIPs: validation.blockedIPs, allowedIPs: validation.allowedIPs }); const securityError = new Error(`Connection blocked: ${validation.reason}. ` + `Total resolved: ${addresses.length}, blocked: ${validation.blockedIPs.length}`); securityError.code = 'ECONNREFUSED'; // Never return actual IPs in security errors - use placeholder values return actualCallback(securityError, '0.0.0.0', 4); } // Return first safe address - only from validated allowed IPs const firstSafeAddress = addresses.find(addr => validation.allowedIPs.includes(addr.address)); // If no safe address found, this is a critical error if (!firstSafeAddress) { const criticalError = new Error(`Critical security error: No safe addresses found for ${hostname}`); criticalError.code = 'ECONNREFUSED'; return actualCallback(criticalError, '0.0.0.0', 4); } logger_1.logger.debug('DNS lookup successful', { hostname, address: firstSafeAddress.address, family: firstSafeAddress.family, totalAddresses: addresses.length }); actualCallback(null, firstSafeAddress.address, firstSafeAddress.family); }) .catch(err => { logger_1.logger.error('DNS lookup failed', { hostname, error: err }); actualCallback(err, '0.0.0.0', 4); }); }; /** * Socket-level IP validation for TOCTOU protection * Re-validates the actual connected IP against cached DNS results */ function validateSocketIP(socket, hostname) { const actualIP = socket.remoteAddress; if (!actualIP) { logger_1.logger.warn('Socket IP validation failed: no remote address', { hostname }); return false; } // Get the cached IP addresses for this hostname const cachedIPs = dnsCache.getCachedIPs(hostname); if (!cachedIPs) { logger_1.logger.warn('Socket IP validation failed: no cached DNS results', { hostname, actualIP }); return false; } // Check if the actual connected IP matches one of the cached DNS results if (!cachedIPs.includes(actualIP)) { logger_1.logger.warn('Socket IP validation failed: IP mismatch', { hostname, actualIP, cachedIPs, reason: 'Connected IP does not match DNS resolution' }); return false; } // Re-validate the actual IP for security if ((0, ip_validation_1.isPrivateOrReservedIP)(actualIP)) { logger_1.logger.warn('Socket IP validation failed: private/reserved IP', { hostname, actualIP, reason: 'Connected to private/reserved IP address' }); return false; } return true; } function getHostnameForValidation(options) { if (!options || typeof options !== 'object') return ''; const opts = options; const rawHost = typeof opts.hostname === 'string' ? opts.hostname : typeof opts.host === 'string' ? opts.host : ''; if (!rawHost) return ''; // Bracketed IPv6, optionally with port: [::1] or [::1]:443 if (rawHost.startsWith('[')) { const endBracket = rawHost.indexOf(']'); if (endBracket !== -1) { return rawHost.slice(1, endBracket); } } // Unbracketed IPv6 (no port): ::1 if (net_1.default.isIP(rawHost) === 6) return rawHost; // host:port (single colon). If multiple colons, assume it's IPv6 and return as-is. const firstColon = rawHost.indexOf(':'); const lastColon = rawHost.lastIndexOf(':'); if (firstColon !== -1 && firstColon === lastColon) { return rawHost.slice(0, firstColon); } return rawHost; } /** * Create enhanced secure HTTP agent */ function createEnhancedSecureHttpAgent() { const agent = new http_1.default.Agent({ keepAlive: true, keepAliveMsecs: 30000, maxSockets: security_1.SECURITY_CONFIG.MAX_CONCURRENT_CONNECTIONS, maxFreeSockets: Math.floor(security_1.SECURITY_CONFIG.MAX_CONCURRENT_CONNECTIONS / 5), timeout: security_1.SECURITY_CONFIG.HTTP_TIMEOUT, lookup: enhancedSecureLookup, }); // Override createConnection for socket-level validation const originalCreateConnection = agent.createConnection; // eslint-disable-next-line @typescript-eslint/no-explicit-any agent.createConnection = function (options, callback) { const normalizedOptions = options && typeof options === 'object' && typeof options.hostname === 'string' && !options.host ? { ...options, host: options.hostname } : options; const hostname = getHostnameForValidation(normalizedOptions); const socket = originalCreateConnection.call(this, normalizedOptions, callback); // Listen for the 'connect' event to perform validation after connection is established socket.on('connect', () => { // Perform socket-level IP validation after connection if (!validateSocketIP(socket, hostname)) { const validationError = new Error(`Connection rejected: socket IP validation failed for ${hostname}`); logger_1.logger.warn('TOCTOU protection triggered: destroying connection', { hostname, remoteAddress: socket.remoteAddress }); socket.destroy(validationError); return; } logger_1.logger.debug('Socket IP validation passed', { hostname, remoteAddress: socket.remoteAddress, remotePort: socket.remotePort }); }); return socket; }; return agent; } /** * Create enhanced secure HTTPS agent */ function createEnhancedSecureHttpsAgent() { const agent = new https_1.default.Agent({ keepAlive: true, keepAliveMsecs: 30000, maxSockets: security_1.SECURITY_CONFIG.MAX_CONCURRENT_CONNECTIONS, maxFreeSockets: Math.floor(security_1.SECURITY_CONFIG.MAX_CONCURRENT_CONNECTIONS / 5), timeout: security_1.SECURITY_CONFIG.HTTP_TIMEOUT, lookup: enhancedSecureLookup, // Additional TLS security settings secureProtocol: 'TLSv1_2_method', ciphers: 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384', honorCipherOrder: true, checkServerIdentity: (hostname, cert) => { // Perform additional hostname verification const result = tls_1.default.checkServerIdentity(hostname, cert); if (result) return result; // Additional checks can be added here logger_1.logger.debug('TLS certificate validation passed', { hostname }); return undefined; } }); // Override createConnection for socket-level validation const originalCreateConnection = agent.createConnection; // eslint-disable-next-line @typescript-eslint/no-explicit-any agent.createConnection = function (options, callback) { const normalizedOptions = options && typeof options === 'object' && typeof options.hostname === 'string' && !options.host ? { ...options, host: options.hostname } : options; const hostname = getHostnameForValidation(normalizedOptions); const socket = originalCreateConnection.call(this, normalizedOptions, callback); // Listen for the 'secureConnect' event for TLS sockets socket.on('secureConnect', () => { // Perform socket-level IP validation after TLS connection if (!validateSocketIP(socket, hostname)) { const validationError = new Error(`TLS connection rejected: socket IP validation failed for ${hostname}`); logger_1.logger.warn('TOCTOU protection triggered: destroying TLS connection', { hostname, remoteAddress: socket.remoteAddress }); socket.destroy(validationError); return; } logger_1.logger.debug('TLS socket IP validation passed', { hostname, remoteAddress: socket.remoteAddress, remotePort: socket.remotePort }); }); return socket; }; return agent; } /** * Singleton instances for performance */ let defaultEnhancedHttpAgent; let defaultEnhancedHttpsAgent; function getEnhancedSecureHttpAgent() { if (!defaultEnhancedHttpAgent) { defaultEnhancedHttpAgent = createEnhancedSecureHttpAgent(); } return defaultEnhancedHttpAgent; } function getEnhancedSecureHttpsAgent() { if (!defaultEnhancedHttpsAgent) { defaultEnhancedHttpsAgent = createEnhancedSecureHttpsAgent(); } return defaultEnhancedHttpsAgent; } /** * Get appropriate enhanced secure agent based on protocol */ function getEnhancedSecureAgentForUrl(url) { const urlObj = new URL(url); return urlObj.protocol === 'https:' ? getEnhancedSecureHttpsAgent() : getEnhancedSecureHttpAgent(); } /** * DNS cache management functions */ function getDNSCacheStats() { return dnsCache.getStats(); } function invalidateDNSCache(hostname) { if (hostname) { dnsCache.invalidate(hostname); logger_1.logger.info(`DNS cache invalidated for hostname: ${hostname}`); } else { dnsCache.destroy(); logger_1.logger.info('DNS cache completely cleared'); } } /** * Advanced security validation for HTTP requests */ async function validateRequestSecurity(url) { try { const urlObj = new URL(url); const hostname = urlObj.hostname; // Perform DNS lookup and validation const addresses = await dnsCache.lookup(hostname); const validation = validateIPAddresses(addresses); if (!validation.allowed) { logger_1.logger.warn('Request blocked by security validation', { url, hostname, reason: validation.reason, blockedIPs: validation.blockedIPs }); } return validation; } catch (error) { logger_1.logger.error('Security validation failed', { url, error: error }); return { allowed: false, blockedIPs: [], allowedIPs: [], reason: `Security validation error: ${error.message}` }; } } // Export cleanup function for application-level resource management // Applications should call this during graceful shutdown const cleanup = () => dnsCache.destroy(); exports.cleanup = cleanup;