Aller au contenu principal

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 :

  1. Isolation multi-tenant — Chaque organisation ne voit que ses données
  2. RBAC — Permissions granulaires par rôle
  3. CSP — Content Security Policy avec nonce par requête
  4. Rate limiting — Protection contre le brute force
  5. Encryption — Tokens OAuth chiffrés au repos
  6. Headers sécurité — HSTS, X-Frame-Options, etc.
  7. 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 :

  1. Lecture : ctx.orgDb injecte automatiquement organizationId dans les requêtes
  2. Écriture : organizationId doit être explicitement ajouté dans chaque update/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 inline

Directives CSP

DirectiveValeur
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-srcStripe, Supabase, Pusher, Sentry, Mistral AI
frame-srcStripe (checkout iframe)
worker-src'self' blob:

En développement, unsafe-eval est 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

EndpointLimiteFenêtre
Login (Credentials)10 requêtes/IP15 minutes
Chat (Chatbot)10 messages/user1 minute
Mode démoVariable/user1 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

HeaderValeurProtection
X-Frame-OptionsDENYClickjacking
X-Content-Type-OptionsnosniffMIME sniffing
X-XSS-Protection0Désactivé (CSP suffit)
Referrer-Policystrict-origin-when-cross-originFuite de referrer
Permissions-Policycamera, microphone, geolocation disabledAPI browser
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadHTTPS 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-bypass avec MAINTENANCE_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() :

CodeUsage
UNAUTHORIZEDPas de session
FORBIDDENPas le droit (permission manquante)
NOT_FOUNDRessource absente ou IDOR
BAD_REQUESTInput invalide
TOO_MANY_REQUESTSRate limit atteint
throw new TRPCError({
  code: "NOT_FOUND",
  message: "Facture introuvable",
});

Checklist sécurité

Avant chaque merge, vérifier :

  • organizationId dans tous les update/delete
  • findFirst (pas findUnique) pour les ressources scopées
  • TRPCError (pas throw new Error)
  • NOT_FOUND pour les IDOR (pas FORBIDDEN)
  • requirePermission() sur les routes internes
  • timingSafeEqual pour les webhooks
  • Pas de any — utiliser unknown + type guards
  • Pas de secrets dans le code (utiliser env vars)