diff --git a/src/lib/image.ts b/src/lib/image.ts index 6603c37..7fe8b59 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -20,6 +20,18 @@ function isAllowedMimeType(mimeType: string): mimeType is AllowedMimeType { return (ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType); } +const SHARP_FORMAT_TO_MIME: Record = { + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", +}; + +/** GIF magic bytes: "GIF87a" or "GIF89a" */ +function isGifData(data: Buffer): boolean { + return data.length >= 6 && data.toString("ascii", 0, 3) === "GIF"; +} + export async function processImage( data: Buffer, mimeType: string, @@ -37,13 +49,28 @@ export async function processImage( ); } - // GIFs: pass through (sharp's animated GIF support is limited, GIFs rarely have EXIF) + // GIFs: validate magic bytes, then pass through + // (sharp's animated GIF support is limited, GIFs rarely have EXIF) if (mimeType === "image/gif") { + if (!isGifData(data)) { + throw new ImageValidationError("File content is not a valid GIF"); + } return { data, mimeType }; } - // Auto-orient (applies EXIF rotation) and strip metadata + // Auto-orient (applies EXIF rotation) and strip metadata. + // sharp validates magic bytes internally -- throws on non-image data. const { default: sharp } = await import("sharp"); - const processed = await sharp(data).rotate().toBuffer(); + const instance = sharp(data); + const meta = await instance.metadata(); + const detectedMime = meta.format + ? SHARP_FORMAT_TO_MIME[meta.format] + : undefined; + if (detectedMime && detectedMime !== mimeType) { + throw new ImageValidationError( + `MIME type mismatch: claimed ${mimeType} but content is ${detectedMime}`, + ); + } + const processed = await instance.rotate().toBuffer(); return { data: processed, mimeType }; }