From 2a6c3a12222426dc82f12e49724ef5693eda05df Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 19 May 2026 10:09:50 +0200 Subject: [PATCH] fix(security): validate file uploads with magic bytes, remove SVG from favicon whitelist - Add validateImageMagicBytes() to storage.ts reading first 12 bytes to verify JPEG/PNG/WebP/ICO signatures regardless of declared MIME type - Remove image/svg+xml from favicon upload whitelist (SVG can embed scripts) - Apply magic bytes check in product image and favicon upload endpoints --- .../app/api/admin/products/[id]/images/route.ts | 6 +++++- app/src/app/api/admin/upload/favicon/route.ts | 9 +++++++-- app/src/lib/storage.ts | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/src/app/api/admin/products/[id]/images/route.ts b/app/src/app/api/admin/products/[id]/images/route.ts index 88b191b..2f53029 100644 --- a/app/src/app/api/admin/products/[id]/images/route.ts +++ b/app/src/app/api/admin/products/[id]/images/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { getCurrentUser } from '@/lib/auth' -import { saveImage, deleteImageFile } from '@/lib/storage' +import { saveImage, deleteImageFile, validateImageMagicBytes } from '@/lib/storage' const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'] const MAX_SIZE = 5 * 1024 * 1024 // 5MB @@ -26,6 +26,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string if (!ALLOWED_TYPES.includes(file.type)) { return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 }) } + const isValidImage = await validateImageMagicBytes(file, file.type) + if (!isValidImage) { + return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 }) + } if (file.size > MAX_SIZE) { return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 }) } diff --git a/app/src/app/api/admin/upload/favicon/route.ts b/app/src/app/api/admin/upload/favicon/route.ts index bceefc5..971d5fa 100644 --- a/app/src/app/api/admin/upload/favicon/route.ts +++ b/app/src/app/api/admin/upload/favicon/route.ts @@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises' import path from 'path' import { prisma } from '@/lib/prisma' import { getCurrentUser } from '@/lib/auth' +import { validateImageMagicBytes } from '@/lib/storage' -const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/svg+xml', 'image/jpeg', 'image/webp'] +const ALLOWED_TYPES = ['image/x-icon', 'image/png', 'image/jpeg', 'image/webp'] const MAX_SIZE = 1 * 1024 * 1024 // 1MB const FAVICON_URL = '/uploads/branding/favicon.png' @@ -23,7 +24,11 @@ export async function POST(req: NextRequest) { if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 }) if (!ALLOWED_TYPES.includes(file.type)) { - return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, SVG o WebP)' }, { status: 400 }) + return NextResponse.json({ error: 'Formato non supportato (usa PNG, ICO, JPEG o WebP)' }, { status: 400 }) + } + const isValidImage = await validateImageMagicBytes(file, file.type) + if (!isValidImage) { + return NextResponse.json({ error: 'File content does not match declared image type' }, { status: 400 }) } if (file.size > MAX_SIZE) { return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 }) diff --git a/app/src/lib/storage.ts b/app/src/lib/storage.ts index bc1ddb3..503deb4 100644 --- a/app/src/lib/storage.ts +++ b/app/src/lib/storage.ts @@ -16,6 +16,23 @@ export async function saveImage( return `/uploads/${productId}/${filename}` } +const IMAGE_MAGIC_BYTES: Record boolean> = { + 'image/jpeg': (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, + 'image/png': (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47, + 'image/webp': (b) => + b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && + b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50, + 'image/x-icon': (b) => b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && b[3] === 0x00, +} + +export async function validateImageMagicBytes(file: File, declaredType: string): Promise { + const checker = IMAGE_MAGIC_BYTES[declaredType] + if (!checker) return false + const arrayBuffer = await file.slice(0, 12).arrayBuffer() + const buf = Buffer.from(arrayBuffer) + return checker(buf) +} + export async function deleteImageFile(url: string): Promise { const filePath = path.join(process.cwd(), 'public', url) await unlink(filePath)