@mvp-factory/holy-upload
Version:
File upload processing system extracted from Holy Habit project with security validation and image optimization
367 lines • 13 kB
JavaScript
;
/**
* File Validator
*
* Validates uploaded files for security and compliance
* Extracted from Holy Habit upload system with enhanced security
*/
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;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileValidator = void 0;
const Upload_1 = require("../types/Upload");
const mimeTypes = __importStar(require("mime-types"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
class FileValidator {
/**
* Validate uploaded file
*
* @param file - Multer file object
* @param options - Validation options
* @returns Validation result
*/
static async validate(file, options = {}) {
const errors = [];
let mimeType;
let extension;
try {
// Basic file checks
if (!file) {
errors.push('No file provided');
return { isValid: false, errors };
}
if (!file.buffer && !file.path) {
errors.push('File data not available');
return { isValid: false, errors };
}
// File size validation
if (options.maxSize && file.size > options.maxSize) {
errors.push(`File size ${this.formatBytes(file.size)} exceeds limit of ${this.formatBytes(options.maxSize)}`);
}
// Get file extension
extension = this.getFileExtension(file.originalname);
if (!extension) {
errors.push('File has no extension');
}
// Extension validation
if (options.allowedExtensions && extension) {
if (!options.allowedExtensions.includes(extension.toLowerCase())) {
errors.push(`File extension '${extension}' is not allowed`);
}
}
// MIME type detection and validation
mimeType = await this.detectMimeType(file);
if (!mimeType) {
errors.push('Could not determine file type');
}
else if (options.allowedMimeTypes) {
if (!options.allowedMimeTypes.includes(mimeType)) {
errors.push(`File type '${mimeType}' is not allowed`);
}
}
// Magic number validation for images
if (mimeType && this.isImageType(mimeType)) {
const magicValid = await this.validateMagicNumber(file, mimeType);
if (!magicValid) {
errors.push('File header does not match declared type');
}
}
// Malicious content detection
if (options.checkMalicious !== false) {
const maliciousCheck = await this.checkMaliciousContent(file);
if (!maliciousCheck.isClean) {
errors.push(`Potentially malicious content detected: ${maliciousCheck.reason}`);
}
}
// Filename validation
const filenameValid = this.validateFilename(file.originalname);
if (!filenameValid.isValid) {
errors.push(...filenameValid.errors);
}
}
catch (error) {
errors.push(`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
return {
isValid: errors.length === 0,
mimeType,
extension,
size: file.size,
errors
};
}
/**
* Detect MIME type using multiple methods
*
* @param file - Multer file object
* @returns Detected MIME type
*/
static async detectMimeType(file) {
let mimeType;
// Method 1: Use provided MIME type if available
if (file.mimetype) {
mimeType = file.mimetype;
}
// Method 2: Detect from file extension
if (!mimeType || mimeType === 'application/octet-stream') {
const detectedType = mimeTypes.lookup(file.originalname);
if (detectedType) {
mimeType = detectedType;
}
}
// Method 3: Magic number detection for images
if (file.buffer || file.path) {
const magicType = await this.detectFromMagicNumber(file);
if (magicType) {
// Prefer magic number detection for security
mimeType = magicType;
}
}
return mimeType;
}
/**
* Detect MIME type from magic number
*
* @param file - Multer file object
* @returns Detected MIME type
*/
static async detectFromMagicNumber(file) {
try {
let buffer;
if (file.buffer) {
buffer = file.buffer.slice(0, 16); // First 16 bytes
}
else if (file.path) {
const fd = await fs.promises.open(file.path, 'r');
const { buffer: readBuffer } = await fd.read(Buffer.alloc(16), 0, 16, 0);
await fd.close();
buffer = readBuffer;
}
else {
return undefined;
}
const hex = buffer.toString('hex').toUpperCase();
for (const [mimeType, signatures] of Object.entries(this.MAGIC_NUMBERS)) {
for (const signature of signatures) {
if (hex.startsWith(signature)) {
return mimeType;
}
}
}
return undefined;
}
catch (error) {
return undefined;
}
}
/**
* Validate magic number against MIME type
*
* @param file - Multer file object
* @param expectedMimeType - Expected MIME type
* @returns True if magic number matches
*/
static async validateMagicNumber(file, expectedMimeType) {
const detectedType = await this.detectFromMagicNumber(file);
return detectedType === expectedMimeType;
}
/**
* Check for malicious content
*
* @param file - Multer file object
* @returns Malicious check result
*/
static async checkMaliciousContent(file) {
try {
let content;
// Read file content
if (file.buffer) {
content = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)); // First 8KB
}
else if (file.path) {
const buffer = Buffer.alloc(8192);
const fd = await fs.promises.open(file.path, 'r');
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
await fd.close();
content = buffer.toString('utf8', 0, bytesRead);
}
else {
return { isClean: true };
}
// Check against malicious patterns
for (const pattern of this.MALICIOUS_PATTERNS) {
if (pattern.test(content)) {
return { isClean: false, reason: 'Suspicious script content detected' };
}
}
// Check filename for executable extensions
if (/\.(exe|bat|cmd|scr|pif|com|php|jsp|asp)$/i.test(file.originalname)) {
return { isClean: false, reason: 'Executable file extension detected' };
}
return { isClean: true };
}
catch (error) {
// If we can't read the file, assume it's suspicious
return { isClean: false, reason: 'Unable to scan file content' };
}
}
/**
* Validate filename
*
* @param filename - Original filename
* @returns Validation result
*/
static validateFilename(filename) {
const errors = [];
if (!filename) {
errors.push('Filename is required');
return { isValid: false, errors };
}
// Check for path traversal attempts
if (filename.includes('../') || filename.includes('..\\')) {
errors.push('Filename contains path traversal characters');
}
// Check for invalid characters
const invalidChars = /[<>:"|*?]/;
if (invalidChars.test(filename)) {
errors.push('Filename contains invalid characters');
}
// Check for reserved names (Windows)
const reservedNames = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
if (reservedNames.test(filename)) {
errors.push('Filename uses reserved system name');
}
// Check for control characters
if (/[\x00-\x1f\x80-\x9f]/.test(filename)) {
errors.push('Filename contains control characters');
}
// Check length
if (filename.length > 255) {
errors.push('Filename is too long (max 255 characters)');
}
return { isValid: errors.length === 0, errors };
}
/**
* Get file extension
*
* @param filename - Filename
* @returns File extension (without dot)
*/
static getFileExtension(filename) {
const extension = path.extname(filename).toLowerCase().slice(1);
return extension || undefined;
}
/**
* Check if MIME type is an image
*
* @param mimeType - MIME type
* @returns True if image type
*/
static isImageType(mimeType) {
return mimeType.startsWith('image/');
}
/**
* Format bytes to human readable format
*
* @param bytes - Bytes
* @returns Formatted string
*/
static formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Create validation error
*
* @param result - Validation result
* @returns Validation error
*/
static createError(result) {
const message = result.errors.join(', ');
if (result.errors.some(e => e.includes('size'))) {
return new Upload_1.FileSizeError(message);
}
if (result.errors.some(e => e.includes('type') || e.includes('extension'))) {
return new Upload_1.FileTypeError(message);
}
return new Upload_1.ValidationError(message);
}
/**
* Quick validation for common cases
*
* @param file - Multer file object
* @param maxSize - Maximum file size
* @param allowedTypes - Allowed MIME types
* @returns True if valid
*/
static async quickValidate(file, maxSize = 10 * 1024 * 1024, allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']) {
const result = await this.validate(file, {
maxSize,
allowedMimeTypes: allowedTypes,
checkMalicious: true
});
return result.isValid;
}
}
exports.FileValidator = FileValidator;
FileValidator.MAGIC_NUMBERS = {
'image/jpeg': ['FFD8FF'],
'image/png': ['89504E47'],
'image/gif': ['474946383761', '474946383961'], // GIF87a, GIF89a
'image/webp': ['52494646']
};
FileValidator.MALICIOUS_PATTERNS = [
// Script injections
/<script[^>]*>/i,
/javascript:/i,
/vbscript:/i,
/onload=/i,
/onerror=/i,
// PHP tags
/<\?php/i,
/<\?=/i,
// Server-side includes
/<!--#exec/i,
/<!--#include/i,
// Executable extensions in filename
/\.(exe|bat|cmd|scr|pif|com)$/i
];
//# sourceMappingURL=FileValidator.js.map