@nanggo/social-preview
Version:
Generate beautiful social media preview images from any URL
428 lines (427 loc) • 21.2 kB
JavaScript
;
/**
* 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),
};