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
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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 ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||||
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
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)) {
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
return NextResponse.json({ error: 'Only JPEG, PNG and WebP images are allowed' }, { status: 400 })
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
return NextResponse.json({ error: 'File too large (max 5MB)' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { mkdir, writeFile } from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getCurrentUser } from '@/lib/auth'
|
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 MAX_SIZE = 1 * 1024 * 1024 // 1MB
|
||||||
const FAVICON_URL = '/uploads/branding/favicon.png'
|
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 (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
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) {
|
if (file.size > MAX_SIZE) {
|
||||||
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
|
return NextResponse.json({ error: 'File troppo grande (max 1MB)' }, { status: 400 })
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ export async function saveImage(
|
|||||||
return `/uploads/${productId}/${filename}`
|
return `/uploads/${productId}/${filename}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_MAGIC_BYTES: Record<string, (buf: Buffer) => 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<boolean> {
|
||||||
|
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<void> {
|
export async function deleteImageFile(url: string): Promise<void> {
|
||||||
const filePath = path.join(process.cwd(), 'public', url)
|
const filePath = path.join(process.cwd(), 'public', url)
|
||||||
await unlink(filePath)
|
await unlink(filePath)
|
||||||
|
|||||||
Reference in New Issue
Block a user