Sécurité
Isolation multi-tenant, CSP nonce, rate limiting, encryption, IDOR protection.
Vue d'ensemble
HeartCo implémente une sécurité en profondeur (defense in depth) avec plusieurs couches de protection :
- Isolation multi-tenant — Chaque organisation ne voit que ses données
- RBAC — Permissions granulaires par rôle
- CSP — Content Security Policy avec nonce par requête
- Rate limiting — Protection contre le brute force
- Encryption — Tokens OAuth chiffrés au repos
- Headers sécurité — HSTS, X-Frame-Options, etc.
- HMAC — Validation des webhooks
Isolation multi-tenant
Principe
Chaque donnée appartient à une organisation via le champ organizationId. L'accès est contrôlé à deux niveaux :
- Lecture :
ctx.orgDbinjecte automatiquementorganizationIddans les requêtes - Écriture :
organizationIddoit être explicitement ajouté dans chaqueupdate/delete
Règles obligatoires
// ✅ OBLIGATOIRE — organizationId dans le where pour update/delete
await ctx.db.invoice.update({
where: { id: input.id, organizationId: ctx.session.user.organizationId },
data: { status: "PAID" },
});
// ❌ VULNÉRABILITÉ IDOR — pas d'organizationId
await ctx.db.invoice.update({
where: { id: input.id },
data: { status: "PAID" },
});Sous-entités
Pour les modèles sans organizationId direct (lignes de facture, etc.), filtrer via le parent :
const line = await ctx.db.invoiceLine.findFirst({
where: {
id: input.lineId,
invoice: { organizationId: ctx.session.user.organizationId },
},
});findFirst vs findUnique
Toujours utiliser findFirst quand on filtre par organizationId + id :
// ✅ findFirst — supporte les filtres composites
await ctx.orgDb.invoice.findFirst({ where: { id: input.id } });
// ❌ findUnique — ne filtre PAS par organizationId
await ctx.db.invoice.findUnique({ where: { id: input.id } });Fichier : src/lib/prisma-org-scope.ts — 125 modèles auto-scopés.
Protection IDOR
Les erreurs d'accès doivent retourner NOT_FOUND (pas FORBIDDEN) pour éviter la fuite d'information :
const invoice = await ctx.orgDb.invoice.findFirst({
where: { id: input.id },
});
if (!invoice) {
// ✅ NOT_FOUND — l'attaquant ne sait pas si la ressource existe
throw new TRPCError({ code: "NOT_FOUND", message: "Facture introuvable" });
}
// ❌ FORBIDDEN — révèle que la ressource existe mais appartient à un autre
// throw new TRPCError({ code: "FORBIDDEN" });assertOwnership
Pour les ressources avec createdById, utiliser le helper :
import { assertOwnership } from "~/server/api/trpc";
// Vérifie que ctx.session.user.id === resource.createdById
// Bypass automatique pour les rôles ≥ MANAGER
assertOwnership(ctx, resource.createdById, "MANAGER");Content Security Policy (CSP)
Fichier : src/middleware.ts
Un nonce unique est généré pour chaque requête via crypto.randomUUID() :
const nonce = crypto.randomUUID();
// Injecté dans le header CSP et disponible pour les scripts inlineDirectives CSP
| Directive | Valeur |
|---|---|
default-src | 'self' |
script-src | 'self' + nonce dynamique par requête + 'strict-dynamic' + Stripe + Vercel Analytics |
style-src | 'self' 'unsafe-inline' (requis par Tailwind v4 et Framer Motion) |
img-src | 'self' data: https: blob: |
connect-src | Stripe, Supabase, Pusher, Sentry, Mistral AI |
frame-src | Stripe (checkout iframe) |
worker-src | 'self' blob: |
En développement,
unsafe-evaletunsafe-inlinesont ajoutés pour le hot module replacement (HMR). En production, les nonces sont générés viacrypto.randomUUID()dansmiddleware.tsà chaque requête et injectés dans tous les scripts inline du layout (JSON-LD, analytics).style-srcconserve'unsafe-inline'pour Framer Motion (risque XSS limité au defacement, pas à l'exécution).
Convention : scripts inline avec nonce
Tout <script> inline ajouté à une page server-side DOIT recevoir le nonce de la requête. Sans le nonce, le navigateur bloquera le script silencieusement en production.
Le middleware (src/middleware.ts) injecte le nonce dans le header x-nonce à chaque requête et le réinjecte dans le header Content-Security-Policy (script-src 'nonce-...' 'strict-dynamic'). Côté composant serveur, on lit (await headers()).get("x-nonce") et on le passe à l'attribut nonce du tag <script>. La directive strict-dynamic permet aux scripts noncés de charger leurs propres dépendances sans devoir whitelister chaque origine.
Voir le layout root (src/app/layout.tsx) pour un exemple concret avec le JSON-LD Organization.
Rate limiting
Fichier : src/lib/rate-limit.ts
Backend : Upstash Redis
Rate limiting distribué avec sliding window :
import { checkRateLimit } from "~/lib/rate-limit";
// 10 requêtes max par IP en 15 minutes
const allowed = await checkRateLimit(`login:${ip}`, 10, 15 * 60 * 1000);Fallback en mémoire
Si Redis n'est pas configuré (développement local), un compteur en mémoire est utilisé.
Points de rate limiting
| Endpoint | Limite | Fenêtre |
|---|---|---|
| Login (Credentials) | 10 requêtes/IP | 15 minutes |
| Chat (Chatbot) | 10 messages/user | 1 minute |
| Mode démo | Variable/user | 1 heure |
Encryption des tokens
Variable : TOKEN_ENCRYPTION_KEY (64 caractères hex = 32 bytes)
Les tokens OAuth (Google, Microsoft) sont chiffrés en AES-256-GCM avant stockage en base :
// Chiffrement
const encrypted = encryptToken(googleAccessToken);
await db.emailAccount.update({ data: { accessToken: encrypted } });
// Déchiffrement (automatique au refresh)
const decrypted = decryptToken(emailAccount.accessToken);Générer une clé :
openssl rand -hex 32
Headers de sécurité
Fichier : next.config.js
| Header | Valeur | Protection |
|---|---|---|
X-Frame-Options | DENY | Clickjacking (legacy compagnon CSP) |
X-Content-Type-Options | nosniff | MIME sniffing |
X-XSS-Protection | 0 | Désactivé (CSP suffit) |
Referrer-Policy | strict-origin-when-cross-origin | Fuite de referrer |
Permissions-Policy | camera, microphone, geolocation, FLoC, Topics désactivés ; payment=self | API browser + signal privacy fort |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | HTTPS forcé (2 ans), éligible HSTS Preload |
X-DNS-Prefetch-Control | on | Perf : autorise pré-résolution DNN |
Cross-Origin-Opener-Policy | same-origin | Isolation onglet (anti Spectre/XS-leaks) |
Cross-Origin-Resource-Policy | same-origin | Protège les ressources d'embed cross-origin |
payment=(self)reste autorisé pour Stripe Payment Request API (Apple Pay / Google Pay côté checkout). Les valeursinterest-cohort=()etbrowsing-topics=()désactivent FLoC et Topics côté navigateur — signal RGPD-friendly.
Validation des webhooks (HMAC)
Les webhooks entrants doivent être validés avec HMAC-SHA256 et timingSafeEqual :
import crypto from "crypto";
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const computed = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
// ✅ timingSafeEqual — résistant aux timing attacks
return crypto.timingSafeEqual(
Buffer.from(computed, "hex"),
Buffer.from(signature, "hex"),
);
// ❌ INTERDIT — vulnérable aux timing attacks
// return computed === signature;
}Protection CVE-2025-29927
Fichier : src/middleware.ts
Protection contre le bypass de middleware Next.js via le header x-middleware-subrequest :
// Bloque les requêtes avec ce header (retourne 400)
if (request.headers.get("x-middleware-subrequest")) {
return new Response("Forbidden", { status: 400 });
}Mode maintenance
Variable : MAINTENANCE_MODE=true
Quand activé :
- Toutes les pages redirigent vers
/maintenance - Exceptions : webhooks Stripe/Iopole, API health, crons, assets statiques
- Bypass : query param ou cookie
maintenance-bypassavecMAINTENANCE_BYPASS_SECRET
Audit trail
Le modèle AuditLog enregistre les actions sensibles :
- Qui (userId, rôle)
- Quoi (action, resource, resourceId)
- Quand (timestamp)
- Contexte (IP, user agent)
Les refus de permission sont automatiquement logués par le middleware requirePermission().
Gestion des erreurs tRPC
Toujours utiliser TRPCError, jamais throw new Error() :
| Code | Usage |
|---|---|
UNAUTHORIZED | Pas de session |
FORBIDDEN | Pas le droit (permission manquante) |
NOT_FOUND | Ressource absente ou IDOR |
BAD_REQUEST | Input invalide |
TOO_MANY_REQUESTS | Rate limit atteint |
throw new TRPCError({
code: "NOT_FOUND",
message: "Facture introuvable",
});Checklist sécurité
Avant chaque merge, vérifier :
-
organizationIddans tous lesupdate/delete -
findFirst(pasfindUnique) pour les ressources scopées -
TRPCError(pasthrow new Error) - NOT_FOUND pour les IDOR (pas FORBIDDEN)
-
requirePermission()sur les routes internes -
timingSafeEqualpour les webhooks - Pas de
any— utiliserunknown+ type guards - Pas de secrets dans le code (utiliser env vars)