Files
obc-terrassement/app/api/stripe/webhook/route.ts
Claude 3843595e18 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
2026-02-21 09:01:21 +00:00

231 lines
7.6 KiB
TypeScript

import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/client";
import { createAdminClient } from "@/lib/supabase/server";
import Stripe from "stripe";
// Désactiver le body parser pour les webhooks Stripe
export const runtime = "nodejs";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Signature manquante." },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Erreur verification webhook:", err);
return NextResponse.json(
{ error: "Signature invalide." },
{ status: 400 }
);
}
const supabase = createAdminClient();
try {
switch (event.type) {
// Paiement initial réussi
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.metadata?.email || session.customer_email;
const customerId = session.customer as string;
if (!email) {
console.error("Email manquant dans la session Stripe");
break;
}
// Générer un mot de passe temporaire
const tempPassword = generatePassword();
// Créer le compte utilisateur Supabase
const { data: authUser, error: authError } =
await supabase.auth.admin.createUser({
email,
password: tempPassword,
email_confirm: true,
user_metadata: {
full_name: email.split("@")[0],
},
});
if (authError) {
// L'utilisateur existe peut-être déjà
console.error("Erreur creation user:", authError);
// Mettre à jour le profil existant si l'utilisateur existe
const { data: existingProfile } = await supabase
.from("profiles")
.select("id")
.eq("email", email)
.single() as { data: { id: string } | null };
if (existingProfile) {
await supabase
.from("profiles")
.update({
subscription_status: "active",
stripe_customer_id: customerId,
subscription_end_date: new Date(
Date.now() + 60 * 24 * 60 * 60 * 1000 // 60 jours
).toISOString(),
} as never)
.eq("id", existingProfile.id);
}
break;
}
// Mettre à jour le profil avec les infos Stripe
if (authUser.user) {
await supabase
.from("profiles")
.update({
subscription_status: "active",
stripe_customer_id: customerId,
subscription_end_date: new Date(
Date.now() + 60 * 24 * 60 * 60 * 1000
).toISOString(),
} as never)
.eq("id", authUser.user.id);
// Log du paiement
await supabase.from("payments").insert({
user_id: authUser.user.id,
stripe_payment_intent_id:
(session.payment_intent as string) || session.id,
amount: session.amount_total || 49000,
currency: session.currency || "eur",
status: "succeeded",
metadata: {
checkout_session_id: session.id,
candidature_id: session.metadata?.candidature_id,
},
} as never);
}
// Envoyer email de bienvenue avec credentials
if (
process.env.RESEND_API_KEY &&
process.env.RESEND_API_KEY !== "re_your-api-key"
) {
try {
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || "HookLab <onboarding@resend.dev>",
to: email,
subject: "Bienvenue dans HookLab ! Tes accès sont prêts",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #6D5EF6;">Bienvenue dans HookLab !</h1>
<p>Ton paiement a été confirmé. Voici tes accès :</p>
<div style="background: #1A1F2E; padding: 20px; border-radius: 12px; margin: 20px 0;">
<p style="color: #fff; margin: 5px 0;"><strong>Email :</strong> ${email}</p>
<p style="color: #fff; margin: 5px 0;"><strong>Mot de passe :</strong> ${tempPassword}</p>
</div>
<p>Connecte-toi sur <a href="${process.env.NEXT_PUBLIC_APP_URL}/login" style="color: #6D5EF6;">hooklab.fr/login</a> pour commencer.</p>
<p><strong>Pense à changer ton mot de passe après ta première connexion !</strong></p>
<p>À très vite,<br/>L'équipe HookLab</p>
</div>
`,
});
} catch (emailError) {
console.error("Erreur envoi email welcome:", emailError);
}
}
break;
}
// Renouvellement mensuel réussi
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
// Mettre à jour la date de fin d'abonnement
const { data: profile } = await supabase
.from("profiles")
.select("id")
.eq("stripe_customer_id", customerId)
.single() as { data: { id: string } | null };
if (profile) {
await supabase
.from("profiles")
.update({
subscription_status: "active",
subscription_end_date: new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000
).toISOString(),
} as never)
.eq("id", profile.id);
// Log du paiement
const invoicePI = (invoice as unknown as Record<string, unknown>).payment_intent;
await supabase.from("payments").insert({
user_id: profile.id,
stripe_payment_intent_id:
(invoicePI as string) || invoice.id,
amount: invoice.amount_paid,
currency: invoice.currency,
status: "succeeded",
metadata: { invoice_id: invoice.id },
} as never);
}
break;
}
// Abonnement annulé
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await supabase
.from("profiles")
.update({ subscription_status: "cancelled" } as never)
.eq("stripe_customer_id", customerId);
break;
}
default:
console.log(`Webhook non géré: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (err) {
console.error("Erreur traitement webhook:", err);
return NextResponse.json(
{ error: "Erreur traitement webhook." },
{ status: 500 }
);
}
}
// Générateur de mot de passe temporaire — crypto.getRandomValues() uniquement
// (cryptographiquement sûr, contrairement à Math.random())
function generatePassword(): string {
const chars =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
const randomBytes = new Uint8Array(16);
crypto.getRandomValues(randomBytes);
return Array.from(randomBytes.slice(0, 12))
.map((b) => chars[b % chars.length])
.join("");
}