UNPKG

html2canvas-pro

Version:

Screenshots with JavaScript. Next generation!

501 lines 18.2 kB
"use strict"; /** * Input Validator * * Provides validation and sanitization for user inputs to prevent security vulnerabilities * including SSRF, XSS, and injection attacks. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.Validator = void 0; exports.createDefaultValidator = createDefaultValidator; exports.createStrictValidator = createStrictValidator; /** * Input Validator * * Validates and sanitizes user inputs for security and correctness. */ class Validator { constructor(config = {}) { this.config = { maxImageTimeout: 300000, // 5 minutes default allowDataUrls: true, ...config }; } /** * Validate a URL * * @param url - URL to validate * @param context - Context for validation (e.g., 'proxy', 'image') * @returns Validation result */ validateUrl(url, context = 'general') { if (!url || typeof url !== 'string') { return { valid: false, error: 'URL must be a non-empty string' }; } // Check for data URLs if (url.startsWith('data:')) { if (!this.config.allowDataUrls) { return { valid: false, error: 'Data URLs are not allowed' }; } return { valid: true, sanitized: url }; } // Check for blob URLs if (url.startsWith('blob:')) { return { valid: true, sanitized: url }; } // Validate URL format try { const parsedUrl = new URL(url); // Only allow http and https protocols if (!['http:', 'https:'].includes(parsedUrl.protocol)) { return { valid: false, error: `Protocol ${parsedUrl.protocol} is not allowed. Only http and https are permitted.` }; } // For proxy URLs, check domain whitelist if (context === 'proxy' && this.config.allowedProxyDomains && this.config.allowedProxyDomains.length > 0) { const hostname = parsedUrl.hostname.toLowerCase(); const isAllowed = this.config.allowedProxyDomains.some((domain) => { const normalizedDomain = domain.toLowerCase(); return hostname === normalizedDomain || hostname.endsWith('.' + normalizedDomain); }); if (!isAllowed) { return { valid: false, error: `Proxy domain ${parsedUrl.hostname} is not in the allowed list` }; } } // Check for localhost/private IPs to prevent SSRF (skip when allowLocalhostProxy for dev/test) if (context === 'proxy') { if (!this.config.allowLocalhostProxy) { const hostname = parsedUrl.hostname.toLowerCase(); // Check for localhost if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { return { valid: false, error: 'Localhost is not allowed for proxy URLs' }; } // For private IP ranges (simplified check) if (this.isPrivateIP(hostname)) { return { valid: false, error: 'Private IP addresses are not allowed for proxy URLs' }; } // For link-local addresses if (hostname.startsWith('169.254.') || hostname.startsWith('fe80:')) { return { valid: false, error: 'Link-local addresses are not allowed for proxy URLs' }; } } // For proxy URLs, mark that runtime validation is recommended // to prevent DNS rebinding attacks return { valid: true, sanitized: url, requiresRuntimeCheck: true }; } return { valid: true, sanitized: url }; } catch (e) { return { valid: false, error: `Invalid URL format: ${e instanceof Error ? e.message : 'Unknown error'}` }; } } /** * Check if a hostname is a private IP address */ isPrivateIP(hostname) { // IPv4 private ranges const privateIPv4Patterns = [ /^0\./, // 0.0.0.0/8 (This network) /^10\./, // 10.0.0.0/8 (Private) /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // 100.64.0.0/10 (CGNAT) /^127\./, // 127.0.0.0/8 (Loopback) /^169\.254\./, // 169.254.0.0/16 (Link-local) /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 (Private) /^192\.0\.0\./, // 192.0.0.0/24 (IETF Protocol Assignments) /^192\.0\.2\./, // 192.0.2.0/24 (TEST-NET-1) /^192\.168\./, // 192.168.0.0/16 (Private) /^198\.(1[8-9])\./, // 198.18.0.0/15 (Network benchmark) /^198\.51\.100\./, // 198.51.100.0/24 (TEST-NET-2) /^203\.0\.113\./, // 203.0.113.0/24 (TEST-NET-3) /^2(2[4-9]|3[0-9])\./, // 224.0.0.0/4 (Multicast) /^24[0-9]\./, // 240.0.0.0/4 (Reserved) /^255\.255\.255\.255$/ // 255.255.255.255/32 (Broadcast) ]; // Check IPv4 if (privateIPv4Patterns.some((pattern) => pattern.test(hostname))) { return true; } // IPv6 private ranges and special addresses if (hostname.includes(':')) { return this.isPrivateIPv6(hostname); } return false; } /** * Check if an IPv6 address is private or special * Handles compressed IPv6 addresses (e.g., ::1, fc00::1) */ isPrivateIPv6(hostname) { const normalizedHost = hostname.toLowerCase().trim(); // Remove square brackets if present (e.g., [::1]) const addr = normalizedHost.replace(/^\[|\]$/g, ''); // Remove zone ID if present (e.g., fe80::1%eth0) const addrWithoutZone = addr.split('%')[0]; // Loopback ::1 (also matches 0:0:0:0:0:0:0:1) if (/^(0:){7}1$/.test(addrWithoutZone) || addrWithoutZone === '::1') { return true; } // Unspecified address :: (also matches 0:0:0:0:0:0:0:0) if (/^(0:){7}0$/.test(addrWithoutZone) || addrWithoutZone === '::') { return true; } // Expand :: compression to check prefixes // This handles cases like fc00::1, fe80::, etc. const expandedAddr = this.expandIPv6(addrWithoutZone); if (!expandedAddr) { // If we can't expand it, fall back to prefix matching return this.isPrivateIPv6Prefix(addrWithoutZone); } // fc00::/7 (Unique Local Address) // Check if first byte is in range fc00-fdff const firstByte = parseInt(expandedAddr.substring(0, 2), 16); if (firstByte >= 0xfc && firstByte <= 0xfd) { return true; } // fe80::/10 (Link-local) // First 10 bits should be 1111 1110 10 if (firstByte === 0xfe) { const secondByte = parseInt(expandedAddr.substring(2, 4), 16); // Check if bits 11-12 are 10 (0x80-0xbf) if (secondByte >= 0x80 && secondByte <= 0xbf) { return true; } } // ff00::/8 (Multicast) if (firstByte === 0xff) { return true; } return false; } /** * Expand compressed IPv6 address to full form * e.g., "::1" -> "0000:0000:0000:0000:0000:0000:0000:0001" */ expandIPv6(addr) { try { // Handle :: compression if (addr.includes('::')) { const parts = addr.split('::'); if (parts.length > 2) { return null; // Invalid: more than one :: } const leftParts = parts[0] ? parts[0].split(':') : []; const rightParts = parts[1] ? parts[1].split(':') : []; const missingParts = 8 - leftParts.length - rightParts.length; if (missingParts < 0) { return null; // Invalid } const middleParts = Array(missingParts).fill('0000'); const allParts = [...leftParts, ...middleParts, ...rightParts]; return allParts.map((p) => p.padStart(4, '0')).join(':'); } else { // No compression, just normalize const parts = addr.split(':'); if (parts.length !== 8) { return null; // Invalid } return parts.map((p) => p.padStart(4, '0')).join(':'); } } catch { return null; } } /** * Fallback prefix matching for IPv6 when expansion fails */ isPrivateIPv6Prefix(addr) { // fc00::/7 (Unique Local Address) if (/^fc[0-9a-f]{0,2}:?/i.test(addr) || /^fd[0-9a-f]{0,2}:?/i.test(addr)) { return true; } // fe80::/10 (Link-local) if (/^fe[89ab][0-9a-f]:?/i.test(addr)) { return true; } // ff00::/8 (Multicast) if (/^ff[0-9a-f]{0,2}:?/i.test(addr)) { return true; } return false; } /** * Validate CSP nonce * * @param nonce - CSP nonce to validate * @returns Validation result */ validateCspNonce(nonce) { if (!nonce || typeof nonce !== 'string') { return { valid: false, error: 'CSP nonce must be a non-empty string' }; } // Basic format validation - nonce should be base64-like // Typical format: base64 string, often 32+ characters if (nonce.length < 16) { return { valid: false, error: 'CSP nonce is too short (minimum 16 characters recommended)' }; } // Check for suspicious characters if (!/^[A-Za-z0-9+/=_-]+$/.test(nonce)) { return { valid: false, error: 'CSP nonce contains invalid characters' }; } return { valid: true, sanitized: nonce }; } /** * Validate image timeout * * @param timeout - Timeout in milliseconds * @returns Validation result */ validateImageTimeout(timeout) { if (typeof timeout !== 'number' || isNaN(timeout)) { return { valid: false, error: 'Image timeout must be a number' }; } if (timeout < 0) { return { valid: false, error: 'Image timeout cannot be negative' }; } if (this.config.maxImageTimeout && timeout > this.config.maxImageTimeout) { return { valid: false, error: `Image timeout ${timeout}ms exceeds maximum allowed ${this.config.maxImageTimeout}ms` }; } return { valid: true, sanitized: timeout }; } /** * Validate window dimensions * * @param width - Window width * @param height - Window height * @returns Validation result */ validateDimensions(width, height) { if (typeof width !== 'number' || typeof height !== 'number') { return { valid: false, error: 'Dimensions must be numbers' }; } if (isNaN(width) || isNaN(height)) { return { valid: false, error: 'Dimensions cannot be NaN' }; } if (width <= 0 || height <= 0) { return { valid: false, error: 'Dimensions must be positive' }; } // Reasonable maximum to prevent memory issues const MAX_DIMENSION = 32767; // Common canvas limit if (width > MAX_DIMENSION || height > MAX_DIMENSION) { return { valid: false, error: `Dimensions exceed maximum allowed (${MAX_DIMENSION}px)` }; } return { valid: true, sanitized: { width, height } }; } /** * Validate scale factor * * @param scale - Scale factor * @returns Validation result */ validateScale(scale) { if (typeof scale !== 'number' || isNaN(scale)) { return { valid: false, error: 'Scale must be a number' }; } if (scale <= 0) { return { valid: false, error: 'Scale must be positive' }; } // Reasonable scale limits if (scale > 10) { return { valid: false, error: 'Scale factor too large (maximum 10x)' }; } return { valid: true, sanitized: scale }; } /** * Validate HTML element * * @param element - Element to validate * @returns Validation result */ validateElement(element) { if (!element) { return { valid: false, error: 'Element is required' }; } if (typeof element !== 'object') { return { valid: false, error: 'Element must be an object' }; } // Accept real HTMLElement, or any element-like object with the minimal shape // required by the implementation (ownerDocument + defaultView) for backward // compatibility and test environments. if (typeof HTMLElement !== 'undefined' && element instanceof HTMLElement) { // Real DOM element if (!element.ownerDocument) { return { valid: false, error: 'Element must be attached to a document' }; } return { valid: true }; } // Duck-typing: accept object with ownerDocument and defaultView (minimal contract) if (!element.ownerDocument) { return { valid: false, error: 'Element must be attached to a document (ownerDocument required)' }; } if (!element.ownerDocument.defaultView) { return { valid: false, error: 'Document must be attached to a window (ownerDocument.defaultView required)' }; } return { valid: true }; } /** * Validate entire options object * * @param options - Options to validate * @returns Validation result with all errors */ validateOptions(options) { const errors = []; // Validate proxy URL only when a non-empty string (allow null/undefined to mean "no proxy") const proxyUrl = options.proxy; if (proxyUrl !== undefined && proxyUrl !== null && typeof proxyUrl === 'string' && proxyUrl.length > 0) { const proxyResult = this.validateUrl(proxyUrl, 'proxy'); if (!proxyResult.valid) { errors.push(`Proxy: ${proxyResult.error}`); } // Note: Proxy URLs are marked with requiresRuntimeCheck to prevent DNS rebinding // Consider implementing runtime IP validation in production environments } // Validate image timeout if (options.imageTimeout !== undefined) { const timeoutResult = this.validateImageTimeout(options.imageTimeout); if (!timeoutResult.valid) { errors.push(`Image timeout: ${timeoutResult.error}`); } } // Validate dimensions if (options.width !== undefined || options.height !== undefined) { const width = options.width ?? 800; const height = options.height ?? 600; const dimensionsResult = this.validateDimensions(width, height); if (!dimensionsResult.valid) { errors.push(`Dimensions: ${dimensionsResult.error}`); } } // Validate scale if (options.scale !== undefined) { const scaleResult = this.validateScale(options.scale); if (!scaleResult.valid) { errors.push(`Scale: ${scaleResult.error}`); } } // Validate CSP nonce if (options.cspNonce !== undefined) { const nonceResult = this.validateCspNonce(options.cspNonce); if (!nonceResult.valid) { errors.push(`CSP nonce: ${nonceResult.error}`); } } // Custom validation if (this.config.customValidator) { const customResult = this.config.customValidator(options, 'options'); if (!customResult.valid) { errors.push(`Custom validation: ${customResult.error}`); } } if (errors.length > 0) { return { valid: false, error: errors.join('; ') }; } return { valid: true }; } } exports.Validator = Validator; /** * Create a default validator instance */ function createDefaultValidator(config = {}) { return new Validator({ allowDataUrls: true, maxImageTimeout: 300000, // 5 minutes ...config }); } /** * Create a strict validator with security-focused settings */ function createStrictValidator(allowedProxyDomains) { return new Validator({ allowedProxyDomains, allowDataUrls: false, maxImageTimeout: 60000 // 1 minute }); } //# sourceMappingURL=validator.js.map