@nanggo/social-preview
Version:
Generate beautiful social media preview images from any URL
510 lines (509 loc) • 19.8 kB
JavaScript
;
/**
* 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;