UNPKG

@nanggo/social-preview

Version:

Generate beautiful social media preview images from any URL

428 lines (427 loc) 21.2 kB
"use strict"; /** * Image security utilities and Sharp configuration * Prevents pixel bomb attacks and validates image dimensions */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.IMAGE_SECURITY_LIMITS = void 0; exports.initializeSharpSecurity = initializeSharpSecurity; exports.validateImageBuffer = validateImageBuffer; exports.validateSvgContent = validateSvgContent; exports.sanitizeSvgContent = sanitizeSvgContent; exports.createSecureSharpInstance = createSecureSharpInstance; exports.withSecureSharp = withSecureSharp; exports.processImageWithTimeout = processImageWithTimeout; exports.secureResize = secureResize; exports.createSecureSharpWithCleanMetadata = createSecureSharpWithCleanMetadata; exports.withSecureSharpCleanMetadata = withSecureSharpCleanMetadata; exports.validateSharpLimits = validateSharpLimits; const sharp_1 = __importDefault(require("sharp")); const types_1 = require("../types"); const jsdom_1 = require("jsdom"); const dompurify_1 = __importDefault(require("dompurify")); const os = __importStar(require("os")); const sharp_cache_1 = require("./sharp-cache"); const logger_1 = require("./logger"); // Sharp pooling removed - use direct instantiation for better reliability // Cache file-type module to avoid repeated dynamic imports // Use typeof import to ensure type safety with actual module structure let fileTypeModule = null; let fileTypeImportPromise = null; const security_1 = require("../constants/security"); /** * Initialize Sharp with security settings * Should be called once at application startup */ function initializeSharpSecurity() { try { // Set global pixel limit to prevent pixel bomb attacks // Note: sharp.limitInputPixels() might not be available in all versions if (typeof sharp_1.default .limitInputPixels === 'function') { sharp_1.default.limitInputPixels(security_1.MAX_INPUT_PIXELS); } // Set concurrency limit to prevent resource exhaustion // Lower concurrency for security (prevents DoS through resource exhaustion) sharp_1.default.concurrency(Math.max(1, Math.min(4, Math.floor(os.cpus().length / 2)))); // Set global Sharp settings for security sharp_1.default.simd(true); // Enable SIMD acceleration for performance // Cache configuration: Balance security and performance // - Use limited memory cache for performance (controlled memory usage) // - Disable file cache for security (prevent cache-based attacks) sharp_1.default.cache({ memory: security_1.SHARP_CACHE_CONFIG.memory, // 150MB memory cache for performance files: 0, // Disable file cache for security items: security_1.SHARP_CACHE_CONFIG.items // 300 operations cache }); } catch (error) { // Silently fail if Sharp configuration is not supported // eslint-disable-next-line no-console console.warn('Could not configure Sharp security settings:', error); } } /** * Validate image buffer before processing */ async function validateImageBuffer(imageBuffer, allowSvg = false) { // Check file size if (imageBuffer.length > security_1.MAX_FILE_SIZE) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Image file too large: ${imageBuffer.length} bytes. Maximum allowed: ${security_1.MAX_FILE_SIZE} bytes.`); } // Special-case SVG first (text-based, not reliably detected by magic bytes) const isSvgCandidate = allowSvg && imageBuffer.toString('utf8', 0, 512).toLowerCase().includes('<svg'); if (isSvgCandidate) { await validateSvgContent(imageBuffer); return; // Skip Sharp validation for SVG } // Hybrid validation: try file-type first, fallback to magic bytes await validateImageFormat(imageBuffer); try { // Get metadata with strict error handling for security - use cached version for performance // Use imported getCachedMetadata function const metadata = await (0, sharp_cache_1.getCachedMetadata)(imageBuffer); // Check if dimensions are valid if (!metadata.width || !metadata.height) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'Could not determine image dimensions'); } // Check maximum dimensions if (metadata.width > security_1.MAX_IMAGE_WIDTH || metadata.height > security_1.MAX_IMAGE_HEIGHT) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Image dimensions too large: ${metadata.width}x${metadata.height}. Maximum allowed: ${security_1.MAX_IMAGE_WIDTH}x${security_1.MAX_IMAGE_HEIGHT}`); } // Check total pixel count (additional protection) const totalPixels = metadata.width * metadata.height; if (totalPixels > security_1.MAX_INPUT_PIXELS) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Image has too many pixels: ${totalPixels}. Maximum allowed: ${security_1.MAX_INPUT_PIXELS}`); } // Sharp format validation - ensure it's a recognized image format (whitelist approach) if (metadata.format && !security_1.ALLOWED_IMAGE_FORMATS.has(metadata.format)) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Unsupported image format detected by Sharp: ${metadata.format}. Allowed formats: ${Array.from(security_1.ALLOWED_IMAGE_FORMATS).join(', ')}`); } // Additional security checks on metadata if (metadata.density && metadata.density > security_1.MAX_DPI) { // Extremely high DPI can cause memory exhaustion throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Image DPI too high: ${metadata.density}. Maximum allowed: ${security_1.MAX_DPI} DPI`); } } catch (error) { if (error instanceof types_1.PreviewGeneratorError) { throw error; } // Sharp couldn't process the file - likely malformed or not an image throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Invalid or corrupted image file: ${error instanceof Error ? error.message : String(error)}`); } } /** * Validate image format using hybrid approach: file-type with magic bytes fallback */ async function validateImageFormat(imageBuffer) { const ALLOWED_MIME_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'image/tiff', ]); // First attempt: Use file-type library for robust detection try { // Use cached module or import if not cached if (!fileTypeModule) { if (!fileTypeImportPromise) { fileTypeImportPromise = Promise.resolve().then(() => __importStar(require('file-type'))); } fileTypeModule = await fileTypeImportPromise; } const { fileTypeFromBuffer } = fileTypeModule; const detected = await fileTypeFromBuffer(imageBuffer); if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Unsupported image format detected by file-type: ${detected?.mime || 'unknown'}. Only JPEG, PNG, WebP, GIF, BMP, and TIFF are supported.`); } // file-type succeeded return; } catch (error) { // If it's already our error, re-throw if (error instanceof types_1.PreviewGeneratorError) { throw error; } // file-type failed (module not found, etc.), try magic bytes fallback logger_1.logger.debug('file-type validation failed, falling back to magic bytes', { operation: 'image-validation', error: error instanceof Error ? error.message : String(error), }); } // Fallback: Manual magic bytes validation const header = imageBuffer.slice(0, 16); const isJPEG = header[0] === 0xFF && header[1] === 0xD8; const isPNG = header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47; const isWebP = header.indexOf(Buffer.from('WEBP')) !== -1; const isGIF = header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46; const isBMP = header[0] === 0x42 && header[1] === 0x4D; const isTIFF = (header[0] === 0x49 && header[1] === 0x49 && header[2] === 0x2A && header[3] === 0x00) || (header[0] === 0x4D && header[1] === 0x4D && header[2] === 0x00 && header[3] === 0x2A); if (!isJPEG && !isPNG && !isWebP && !isGIF && !isBMP && !isTIFF) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'Unsupported image format detected by magic bytes fallback. Only JPEG, PNG, WebP, GIF, BMP, and TIFF are supported.'); } } /** * Validate SVG content for security risks using DOMPurify */ async function validateSvgContent(svgBuffer) { const svgContent = svgBuffer.toString('utf8'); // Check SVG size limits first if (svgContent.length > security_1.MAX_SVG_SIZE) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `SVG content too large: ${svgContent.length} characters. Maximum allowed: ${security_1.MAX_SVG_SIZE / (1024 * 1024)}MB`); } try { // Create JSDOM window for DOMPurify const window = new jsdom_1.JSDOM('').window; const purify = (0, dompurify_1.default)(window); // Configure DOMPurify for strict SVG sanitization with detailed reporting const cleanSvg = purify.sanitize(svgContent, { USE_PROFILES: { svg: true, svgFilters: true }, ALLOWED_TAGS: [...security_1.ALLOWED_SVG_TAGS], ALLOWED_ATTR: [...security_1.ALLOWED_SVG_ATTRIBUTES], // Only allow fragment identifiers (internal document links), no external URIs ALLOWED_URI_REGEXP: security_1.ALLOWED_SVG_URI_PATTERN, // Explicitly forbid dangerous tags (in addition to not allowing them) FORBID_TAGS: [...security_1.FORBIDDEN_SVG_TAGS], // Explicitly forbid dangerous attributes FORBID_ATTR: [...security_1.FORBIDDEN_SVG_ATTRIBUTES], // Security-hardened configuration KEEP_CONTENT: false, // Don't keep content of removed elements RETURN_DOM: false, // Return string, not DOM RETURN_DOM_FRAGMENT: false, // Return string, not DOM fragment SANITIZE_DOM: true, // Sanitize DOM properties WHOLE_DOCUMENT: false, // Only sanitize fragment FORCE_BODY: false, // Don't wrap in body SAFE_FOR_TEMPLATES: false, // More restrictive parsing ALLOW_DATA_ATTR: false, // Block data-* attributes (can store scripts) ALLOW_UNKNOWN_PROTOCOLS: false, // Block unknown protocols ALLOWED_NAMESPACES: [...security_1.ALLOWED_SVG_NAMESPACES], // Only SVG namespace }); // Extract sanitization information from DOMPurify result const sanitizationInfo = purify.removed; // Check if DOMPurify removed any potentially malicious content if (sanitizationInfo && sanitizationInfo.length > 0) { // Log what was removed for security monitoring const removedElements = sanitizationInfo .map((item) => { if (!item || typeof item !== 'object') { return 'unknown'; } const record = item; const element = record.element; const attribute = record.attribute; if (element && typeof element === 'object') { const tagName = element.tagName; if (typeof tagName === 'string') { return `<${tagName.toLowerCase()}>`; } } if (attribute && typeof attribute === 'object') { const name = attribute.name; const value = attribute.value; if (typeof name === 'string' && typeof value === 'string') { return `${name}="${value}"`; } } return 'unknown'; }) .filter((item) => item !== 'unknown'); // Block SVG if dangerous elements/attributes were removed // Only block for truly dangerous content, not just structural HTML elements const criticalDangerousTags = ['<script', '<object', '<embed', '<iframe', '<link', '<meta']; const hasDangerousContent = removedElements.some((item) => { const lowerItem = item.toLowerCase(); if (lowerItem.startsWith('<')) { // Tag elements: only check for critical security threats return criticalDangerousTags.some(tag => lowerItem.startsWith(tag)); } // Attributes: check for event handlers or dangerous external references const trimmedItem = lowerItem.trim(); return trimmedItem.startsWith('on') || trimmedItem.startsWith('href=') || trimmedItem.startsWith('xlink:href=') || trimmedItem.startsWith('style='); }); if (hasDangerousContent) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `SVG blocked: potentially malicious content removed - ${removedElements.join(', ')}`); } // Log warning for other removed content (might be overly strict filtering) logger_1.logger.warn('SVG sanitization removed elements', { operation: 'svg-sanitization', metadata: { removedElements }, }); } // Validate that the result is still a valid SVG if (!cleanSvg.includes('<svg') && !cleanSvg.toLowerCase().includes('svg')) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, 'SVG validation failed: content does not appear to be a valid SVG after sanitization'); } } catch (error) { if (error instanceof types_1.PreviewGeneratorError) { throw error; } // DOMPurify failed - likely malformed or malicious SVG throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `SVG sanitization failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Sanitize SVG content and return cleaned result for testing */ function sanitizeSvgContent(svgContent) { try { // Create JSDOM window for DOMPurify const window = new jsdom_1.JSDOM('').window; const purify = (0, dompurify_1.default)(window); // Use the same configuration as validateSvgContent - simplified for debugging const cleanSvg = purify.sanitize(svgContent, { USE_PROFILES: { svg: true, svgFilters: true }, ALLOWED_TAGS: [...security_1.ALLOWED_SVG_TAGS], ALLOWED_ATTR: [...security_1.ALLOWED_SVG_ATTRIBUTES], ALLOWED_URI_REGEXP: security_1.ALLOWED_SVG_URI_PATTERN, FORBID_TAGS: [...security_1.FORBIDDEN_SVG_TAGS], FORBID_ATTR: [...security_1.FORBIDDEN_SVG_ATTRIBUTES], KEEP_CONTENT: false, RETURN_DOM: false, SANITIZE_DOM: true, ALLOW_DATA_ATTR: false, ALLOW_UNKNOWN_PROTOCOLS: false, }); return cleanSvg; } catch (error) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `SVG sanitization failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Create a secure Sharp instance with safety checks */ function createSecureSharpInstance(imageBuffer) { // This will be called after validateImageBuffer, so we know it's safe return (0, sharp_1.default)(imageBuffer, security_1.SHARP_SECURITY_CONFIG); } /** * Execute a Sharp operation with direct instance creation * Use this for one-shot operations */ async function withSecureSharp(imageBuffer, operation) { const sharpInstance = (0, sharp_1.default)(imageBuffer, security_1.SHARP_SECURITY_CONFIG); return await operation(sharpInstance); } /** * Process image with timeout protection to prevent DoS attacks */ async function processImageWithTimeout(operation, timeoutMs = security_1.PROCESSING_TIMEOUT) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Image processing timed out after ${timeoutMs}ms. This may indicate a malicious image designed to cause resource exhaustion.`)); }, timeoutMs); operation() .then(result => { clearTimeout(timer); resolve(result); }) .catch(error => { clearTimeout(timer); reject(error); }); }); } /** * Safely resize image with dimension validation */ function secureResize(sharpInstance, width, height, options = {}) { // Validate output dimensions if (width > security_1.MAX_IMAGE_WIDTH || height > security_1.MAX_IMAGE_HEIGHT) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.VALIDATION_ERROR, `Output dimensions too large: ${width}x${height}. Maximum allowed: ${security_1.MAX_IMAGE_WIDTH}x${security_1.MAX_IMAGE_HEIGHT}`); } if (width < 1 || height < 1) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.VALIDATION_ERROR, `Invalid output dimensions: ${width}x${height}. Must be positive integers.`); } return sharpInstance.resize(width, height, { fit: 'cover', position: 'center', withoutEnlargement: false, ...options, }); } /** * Create a Sharp instance with metadata removal for privacy and security */ function createSecureSharpWithCleanMetadata(imageBuffer) { return (0, sharp_1.default)(imageBuffer, security_1.SHARP_SECURITY_CONFIG) // Remove EXIF and other metadata by default - use empty metadata .withMetadata({}); } /** * Execute a Sharp operation with direct instance creation and clean metadata * Use this for one-shot operations that need automatic cleanup */ async function withSecureSharpCleanMetadata(imageBuffer, operation) { const sharpInstance = (0, sharp_1.default)(imageBuffer, security_1.SHARP_SECURITY_CONFIG).withMetadata({}); return await operation(sharpInstance); } /** * Validate Sharp processing limits before operations */ function validateSharpLimits(width, height) { const totalPixels = width * height; if (totalPixels > security_1.MAX_INPUT_PIXELS) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Operation would exceed pixel limit: ${totalPixels} > ${security_1.MAX_INPUT_PIXELS}`); } if (width > security_1.MAX_IMAGE_WIDTH || height > security_1.MAX_IMAGE_HEIGHT) { throw new types_1.PreviewGeneratorError(types_1.ErrorType.IMAGE_ERROR, `Dimensions exceed limits: ${width}x${height}. Max: ${security_1.MAX_IMAGE_WIDTH}x${security_1.MAX_IMAGE_HEIGHT}`); } } /** * Export security constants for use in other modules */ exports.IMAGE_SECURITY_LIMITS = { MAX_INPUT_PIXELS: security_1.MAX_INPUT_PIXELS, MAX_IMAGE_WIDTH: security_1.MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT: security_1.MAX_IMAGE_HEIGHT, MAX_FILE_SIZE: security_1.MAX_FILE_SIZE, MAX_SVG_SIZE: security_1.MAX_SVG_SIZE, MAX_DPI: security_1.MAX_DPI, PROCESSING_TIMEOUT: security_1.PROCESSING_TIMEOUT, ALLOWED_IMAGE_FORMATS: Array.from(security_1.ALLOWED_IMAGE_FORMATS), };