security: corriger les vraies vulnérabilités détectées par l'audit
1. MIME spoofing (upload) — app/api/admin/upload/route.ts
- Ajout de la validation par magic bytes : lit les premiers octets du
fichier et vérifie la signature binaire réelle (JPEG FF D8 FF,
PNG 89 50 4E 47, GIF 47 49 46 38, WebP RIFF+WEBP, AVIF ftyp box)
- Extension dérivée exclusivement du MIME validé côté serveur
(MIME_TO_EXT), jamais du nom de fichier fourni par le client
- Un fichier .exe renommé en .jpg est désormais rejeté
2. Générateur de mot de passe non-cryptographique — stripe/webhook/route.ts
- Remplace Math.random() (non-déterministe mais prévisible) par
crypto.getRandomValues() (CSPRNG, conforme Web Crypto API)
3. Headers HTTP de sécurité manquants — middleware.ts (nouveau)
- X-Content-Type-Options: nosniff (anti MIME-sniffing navigateur)
- X-Frame-Options: SAMEORIGIN (anti clickjacking)
- Referrer-Policy: strict-origin-when-cross-origin
- Permissions-Policy: désactive camera, micro, geolocation
- Content-Security-Policy: whitelist stricte par type de ressource
(scripts, styles, images Unsplash/Supabase/Sanity, connect Supabase/Stripe,
frames Stripe uniquement, object-src none, form-action self)
https://claude.ai/code/session_01PzA98VhLMmsHpzs7gnLHGs
This commit is contained in:
@@ -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<string, string> = {
|
||||
"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 });
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
79
middleware.ts
Normal file
79
middleware.ts
Normal file
@@ -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 <base> 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).*)",
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user