platform-codebase/@packages/@infrastructure/image-security/src/validation/image-validator.ts

202 lines
5.6 KiB
TypeScript
Executable file

/**
* Image Validation Functions
*
* Pure validation functions that work in both browser and Node.js.
* Uses Uint8Array instead of Buffer for browser compatibility.
*
* @example Browser usage:
* ```typescript
* const file = inputEl.files[0]
* const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer())
* const result = validateImageBytes(bytes, file.type)
* if (!result.valid) {
* alert(result.error)
* }
* ```
*
* @example Node.js usage:
* ```typescript
* const buffer = await fs.readFile('image.jpg')
* const bytes = new Uint8Array(buffer)
* const result = validateImageBytes(bytes, 'image/jpeg')
* ```
*/
import {
ALLOWED_IMAGE_MIME_TYPES,
IMAGE_MAGIC_BYTES,
WEBP_SIGNATURE,
DEFAULT_MAX_FILE_SIZE_BYTES,
type AllowedImageMimeType,
type ImageValidationResult,
} from '../types'
/**
* Check if a MIME type is in the allowed list.
*
* @param mimeType - The MIME type to check
* @returns Type guard indicating if the MIME type is allowed
*/
export function isAllowedMimeType(mimeType: string): mimeType is AllowedImageMimeType {
return ALLOWED_IMAGE_MIME_TYPES.includes(mimeType as AllowedImageMimeType)
}
/**
* Check if a file size is within the allowed limit.
*
* @param sizeBytes - File size in bytes
* @param maxBytes - Maximum allowed size (default: 5MB)
* @returns Whether the file size is valid
*/
export function isValidFileSize(
sizeBytes: number,
maxBytes: number = DEFAULT_MAX_FILE_SIZE_BYTES
): boolean {
return sizeBytes > 0 && sizeBytes <= maxBytes
}
/**
* Validate magic bytes against a claimed MIME type.
* Returns true if the bytes match any valid signature for the MIME type.
*
* @param bytes - First 12 bytes of the file (Uint8Array for browser compat)
* @param claimedMimeType - The MIME type claimed by the file/upload
* @returns Whether the magic bytes match the claimed type
*/
export function validateMagicBytes(
bytes: Uint8Array,
claimedMimeType: AllowedImageMimeType
): boolean {
const signatures = IMAGE_MAGIC_BYTES[claimedMimeType]
if (!signatures) {
return false
}
const matches = signatures.some((sig) => sig.every((byte, i) => bytes[i] === byte))
// Special case for WebP: also check for WEBP signature at offset 8
if (claimedMimeType === 'image/webp' && matches) {
return WEBP_SIGNATURE.every((byte, i) => bytes[8 + i] === byte)
}
return matches
}
/**
* Detect MIME type from magic bytes.
* Examines the first bytes of a file to determine its actual type.
*
* @param bytes - First 12 bytes of the file
* @returns Detected MIME type, or null if not recognized
*/
export function detectMimeTypeFromBytes(bytes: Uint8Array): AllowedImageMimeType | null {
for (const [mimeType, signatures] of Object.entries(IMAGE_MAGIC_BYTES)) {
const matches = (signatures as number[][]).some((sig) =>
sig.every((byte, i) => bytes[i] === byte)
)
if (matches) {
// Special case for WebP: verify WEBP signature at offset 8
if (mimeType === 'image/webp') {
if (!WEBP_SIGNATURE.every((byte, i) => bytes[8 + i] === byte)) {
continue
}
}
return mimeType as AllowedImageMimeType
}
}
return null
}
/**
* Comprehensive image validation.
* Validates MIME type, detects actual type from bytes, and checks for mismatches.
*
* @param bytes - First 12 bytes of the file (minimum)
* @param claimedMimeType - The MIME type claimed by the file/upload
* @returns Validation result with error details if invalid
*
* @example
* ```typescript
* const file = inputEl.files[0]
* const bytes = new Uint8Array(await file.slice(0, 12).arrayBuffer())
* const result = validateImageBytes(bytes, file.type)
*
* if (!result.valid) {
* console.error('Validation failed:', result.error)
* } else {
* console.log('Detected type:', result.detectedMimeType)
* }
* ```
*/
export function validateImageBytes(
bytes: Uint8Array,
claimedMimeType: string
): ImageValidationResult {
// Check if claimed MIME type is allowed
if (!isAllowedMimeType(claimedMimeType)) {
return {
valid: false,
error: `MIME type '${claimedMimeType}' is not allowed. Allowed types: ${ALLOWED_IMAGE_MIME_TYPES.join(', ')}`,
}
}
// Need at least 12 bytes to validate (WebP requires checking offset 8-11)
if (bytes.length < 12) {
return {
valid: false,
error: 'Insufficient bytes for validation. Need at least 12 bytes.',
}
}
// Detect actual MIME type from magic bytes
const detectedMimeType = detectMimeTypeFromBytes(bytes)
if (!detectedMimeType) {
return {
valid: false,
error: 'Could not detect image type from file contents. The file may be corrupted or not a valid image.',
}
}
// Check if detected type matches claimed type
if (detectedMimeType !== claimedMimeType) {
return {
valid: false,
error: `File content does not match claimed type. Claimed: ${claimedMimeType}, Detected: ${detectedMimeType}`,
detectedMimeType,
}
}
// Validate magic bytes against claimed type
if (!validateMagicBytes(bytes, claimedMimeType)) {
return {
valid: false,
error: 'Magic bytes validation failed. The file may be corrupted or tampered with.',
detectedMimeType,
}
}
return {
valid: true,
detectedMimeType,
}
}
/**
* Format file size as human-readable string.
*
* @param sizeBytes - File size in bytes
* @returns Human-readable file size (e.g., "2.5 MB")
*/
export function formatFileSize(sizeBytes: number): string {
if (sizeBytes < 1024) {
return `${sizeBytes} B`
}
if (sizeBytes < 1024 * 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`
}
return `${(sizeBytes / (1024 * 1024)).toFixed(2)} MB`
}