diff --git a/app/api/admin/upload/route.ts b/app/api/admin/upload/route.ts index 1bbe78d..61fa53e 100644 --- a/app/api/admin/upload/route.ts +++ b/app/api/admin/upload/route.ts @@ -4,6 +4,63 @@ import type { Profile } from "@/types/database.types"; const BUCKET = "private-gallery"; +// ── Types MIME autorisés → extension de stockage ─────────────────────────── +// L'extension est dérivée du MIME validé côté serveur, jamais du nom de fichier. +const MIME_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "image/gif": "gif", + "image/avif": "avif", +}; + +// ── Signatures magic bytes ────────────────────────────────────────────────── +// Permet de détecter le vrai format binaire indépendamment du Content-Type +// déclaré par le client (qui peut être forgé). +const MAGIC_SIGNATURES: Array<{ + mime: string; + check: (b: Uint8Array) => boolean; +}> = [ + { + mime: "image/jpeg", + check: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, + }, + { + mime: "image/png", + check: (b) => + b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && + b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a, + }, + { + mime: "image/gif", + // GIF87a ou GIF89a + check: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38, + }, + { + mime: "image/webp", + // RIFF....WEBP + check: (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, + }, + { + mime: "image/avif", + // ftyp box à l'offset 4 (structure ISOBMFF) + check: (b) => b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70, + }, +]; + +/** + * Détecte le MIME réel du fichier via ses magic bytes. + * Retourne null si aucune signature connue ne correspond. + */ +function detectMimeFromBytes(buffer: Uint8Array): string | null { + for (const sig of MAGIC_SIGNATURES) { + if (buffer.length >= 12 && sig.check(buffer)) return sig.mime; + } + return null; +} + async function checkAdmin() { const supabase = await createClient(); const { @@ -42,33 +99,53 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Champs 'file' et 'key' requis" }, { status: 400 }); } - // Valider le type MIME - const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]; - if (!allowedTypes.includes(file.type)) { + // ── 1. Vérifier le type MIME déclaré ────────────────────────────────────── + if (!Object.hasOwn(MIME_TO_EXT, file.type)) { return NextResponse.json( { error: "Type de fichier non supporté. Utilisez JPEG, PNG, WebP, GIF ou AVIF." }, { status: 400 } ); } - // Limiter à 5 Mo + // ── 2. Limite de taille ─────────────────────────────────────────────────── if (file.size > 5 * 1024 * 1024) { return NextResponse.json({ error: "Fichier trop volumineux (max 5 Mo)" }, { status: 400 }); } - // Construire le chemin : ex. "hero/image.jpg" - const ext = file.name.split(".").pop() ?? "jpg"; - const sanitizedKey = imageKey.replace(/[^a-zA-Z0-9_-]/g, "_"); - const filePath = `${sanitizedKey}/image.${ext}`; - + // ── 3. Lire le contenu binaire ──────────────────────────────────────────── const arrayBuffer = await file.arrayBuffer(); const buffer = new Uint8Array(arrayBuffer); + // ── 4. Valider les magic bytes (anti-MIME spoofing) ─────────────────────── + // On détecte le vrai format à partir des octets du fichier, pas du Content-Type + // envoyé par le client. + const detectedMime = detectMimeFromBytes(buffer); + if (!detectedMime) { + return NextResponse.json( + { error: "Le fichier ne correspond pas à un format image valide." }, + { status: 400 } + ); + } + // Le MIME réel doit correspondre au MIME déclaré + if (detectedMime !== file.type) { + return NextResponse.json( + { error: "Le type du fichier ne correspond pas à son contenu." }, + { status: 400 } + ); + } + + // ── 5. Construire le chemin de stockage ─────────────────────────────────── + // Extension toujours dérivée du MIME validé, jamais du nom de fichier fourni. + const ext = MIME_TO_EXT[detectedMime]; + const sanitizedKey = imageKey.replace(/[^a-z0-9_-]/gi, "_"); + const filePath = `${sanitizedKey}/image.${ext}`; + + // ── 6. Upload vers Supabase Storage ────────────────────────────────────── const adminClient = createAdminClient(); const { error } = await adminClient.storage .from(BUCKET) .upload(filePath, buffer, { - contentType: file.type, + contentType: detectedMime, upsert: true, }); @@ -79,7 +156,6 @@ export async function POST(request: NextRequest) { ); } - // Retourner le chemin avec préfixe "storage:" const storagePath = `storage:${filePath}`; return NextResponse.json({ storagePath, filePath }); } diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index d1d5be0..0541bdf 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -217,13 +217,14 @@ export async function POST(request: Request) { } } -// Générateur de mot de passe temporaire +// Générateur de mot de passe temporaire — crypto.getRandomValues() uniquement +// (cryptographiquement sûr, contrairement à Math.random()) function generatePassword(): string { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%"; - let password = ""; - for (let i = 0; i < 12; i++) { - password += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return password; + const randomBytes = new Uint8Array(16); + crypto.getRandomValues(randomBytes); + return Array.from(randomBytes.slice(0, 12)) + .map((b) => chars[b % chars.length]) + .join(""); } diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..92b9a7a --- /dev/null +++ b/middleware.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(request: NextRequest) { + const response = NextResponse.next(); + + // ── X-Content-Type-Options ───────────────────────────────────────────────── + // Empêche le navigateur de "deviner" le Content-Type (MIME-sniffing). + // Sans ce header, un fichier .jpg contenant du HTML pourrait être exécuté. + response.headers.set("X-Content-Type-Options", "nosniff"); + + // ── X-Frame-Options ──────────────────────────────────────────────────────── + // Bloque l'intégration de la page dans une iframe externe (clickjacking). + response.headers.set("X-Frame-Options", "SAMEORIGIN"); + + // ── Referrer-Policy ──────────────────────────────────────────────────────── + // Limite les infos envoyées dans le header Referer aux requêtes cross-origin. + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + + // ── Permissions-Policy ───────────────────────────────────────────────────── + // Désactive les APIs navigateur sensibles non utilisées par le site. + response.headers.set( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=(), payment=(self)" + ); + + // ── Content-Security-Policy ──────────────────────────────────────────────── + // Whitelist explicite des origines autorisées pour chaque type de ressource. + const supabaseHost = process.env.NEXT_PUBLIC_SUPABASE_URL + ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).host + : "*.supabase.co"; + + const csp = [ + "default-src 'self'", + + // Next.js 15 (App Router + RSC) nécessite 'unsafe-inline' et 'unsafe-eval' + // pour le bundle client et l'hydration côté navigateur. + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://m.stripe.com", + + // Tailwind CSS + styles inline de composants React + "style-src 'self' 'unsafe-inline'", + + // Images : self, data URIs (placeholders SVG), blob (prévisualisations upload), + // Unsplash (images par défaut), Supabase Storage (signed URLs), Sanity CDN + `img-src 'self' data: blob: https://images.unsplash.com https://${supabaseHost} https://cdn.sanity.io`, + + // API calls : Supabase (REST + WebSocket realtime), Stripe + `connect-src 'self' https://${supabaseHost} wss://${supabaseHost} https://api.stripe.com https://r.stripe.com`, + + // Polices web : uniquement self (pas de Google Fonts) + "font-src 'self'", + + // Iframes : uniquement Stripe (paiement sécurisé) + "frame-src https://js.stripe.com", + + // Aucun plugin (Flash, Java, etc.) + "object-src 'none'", + + // Empêche l'injection de qui pourrait détourner les URLs relatives + "base-uri 'self'", + + // Les formulaires ne peuvent soumettre qu'à l'origine actuelle + "form-action 'self'", + + // Force HTTPS pour toutes les sous-ressources (utile si déployé en HTTP accidentellement) + "upgrade-insecure-requests", + ].join("; "); + + response.headers.set("Content-Security-Policy", csp); + + return response; +} + +export const config = { + // Appliquer sur toutes les routes sauf les assets statiques Next.js + matcher: [ + "/((?!_next/static|_next/image|favicon\\.ico|.*\\.svg|.*\\.ico|.*\\.png|.*\\.jpg|.*\\.jpeg|.*\\.webp|.*\\.gif).*)", + ], +};