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 (pas de unsafe-inline) |
style-src | 'self' 'unsafe-inline' (requis par Tailwind v4) |
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-evalest ajouté pour le hot module replacement (HMR) de webpack.
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 |
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 disabled | API browser |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | HTTPS forcé (2 ans) |
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)