From 30bf090f9340931a6c058b7b9a90a8aa231ca99e Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Thu, 2 Jul 2026 16:00:58 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(security):=20durcissement=20pr=C3=A9-pr?= =?UTF-8?q?od=20(auth,=20secrets,=20DoS=20rendu,=20cr=C3=A9dits)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige les findings à faible risque de l'audit sécurité (SECURITY-AUDIT.md) : - Fail-fast au boot (plugin Nitro) : refuse de démarrer en prod si BETTER_AUTH_SECRET absent/faible ou APP_URL non-https (anti forge de session). - cv/export-pdf + cv/preview : exigent désormais l'auth (requireUserId). export-pdf gate le quota export_pdf et enregistre l'usage — ferme un DoS Chromium non authentifié + le bypass de quota. - mailer : ne logge plus jamais le magic-link en prod ; échec explicite si SMTP absent (le jeton d'auth ne fuite plus dans les logs). - auth : cookies de session forcés Secure en prod + trustedOrigins explicite. - generate : capture InsufficientCreditsError (course sur le dernier crédit) → 403 propre au lieu d'un 500. - cv-render-input : bornes de taille Zod (sections/bullets/textes) contre les payloads géants (amplification DoS + JSON abusif en base). Ajoute SECURITY-AUDIT.md (rapport complet, 12 findings + correctifs). Typecheck OK, 124/124 tests verts. Co-Authored-By: Claude Opus 4.8 (1M context) --- SECURITY-AUDIT.md | 224 ++++++++++++++++++ .../server/api/candidature/generate.post.ts | 12 +- apps/app/server/api/cv/export-pdf.post.ts | 17 ++ apps/app/server/api/cv/preview.post.ts | 3 + apps/app/server/plugins/00.validate-env.ts | 29 +++ apps/app/server/utils/auth.ts | 10 + apps/app/server/utils/cv-render-input.ts | 29 ++- apps/app/server/utils/mailer.ts | 8 +- 8 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 SECURITY-AUDIT.md create mode 100644 apps/app/server/plugins/00.validate-env.ts diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md new file mode 100644 index 0000000..a064aa7 --- /dev/null +++ b/SECURITY-AUDIT.md @@ -0,0 +1,224 @@ +# Audit de sécurité — Teven (cv-optimizer) + +**Date :** 2026-07-02 +**Périmètre :** `apps/app` (Nuxt 3 / Nitro), `packages/shared` — auth, ~30 endpoints API, intégrations LLM (Anthropic), rendu PDF (Chromium/playwright-core), e-mail (nodemailer), paiement (Stripe), dépendances et configuration. +**Méthode :** lecture ligne à ligne des fichiers sensibles + 3 audits ciblés en parallèle + `pnpm audit` + vérification manuelle des findings critiques. + +> ⚠️ **Mise au point honnête :** « 100 % sécurisé » n'existe pour aucun site. Cet audit couvre les failles réalistes de la surface applicative. Il ne remplace pas : un pentest externe, la sécurité de l'infra (réseau, secrets manager, WAF, sauvegardes DB), ni la conformité RGPD juridique. L'objectif ici = **fermer les failles exploitables et durcir au maximum avant prod**. + +--- + +## Synthèse — à traiter avant la mise en prod + +| Priorité | Finding | Sévérité | Effort | +|---|---|---|---| +| **1** | Secret d'auth avec fallback en dur, sans fail-fast | 🔴 Critique | Faible | +| **2** | Export PDF **non authentifié** → DoS Chromium + bypass quota | 🔴 Élevé | Faible | +| **3** | Magic-link (jeton) loggé en clair si pas de SMTP | 🟠 Élevé* | Faible | +| **4** | Aucun header de sécurité (CSP, HSTS, X-Frame-Options…) | 🟠 Élevé | Moyen | +| **5** | Aucun rate-limit (magic-link, endpoints LLM) | 🟠 Élevé | Moyen | +| **6** | Cookies session non forcés `Secure` (dépend de `APP_URL`) | 🟡 Moyen | Faible | +| **7** | `nodemailer` 8.x vulnérable (advisory) | 🟡 Moyen | Faible | +| **8** | Chromium lancé avec `--no-sandbox` | 🟡 Moyen | Moyen | +| **9** | Gate crédits TOCTOU → surconsommation LLM + 500 | 🟡 Moyen | Faible | +| **10** | Schéma de rendu CV sans bornes de taille + pas de `bodyLimit` | 🟡 Moyen | Faible | +| **11** | Pas de CSRF applicatif (repose uniquement sur SameSite=Lax) | 🟡 Moyen | Faible | +| **12** | Dépendances en retard (`better-auth`, Prisma) | 🟡 Moyen | Moyen | +| — | Divers (preview non auth, fonts distantes, prompt-injection borné, /health, robots) | ⚪ Faible/Info | — | + +\* Élevé **conditionnel** au déploiement actuel (pas de SMTP câblé → le jeton part dans les logs). + +> **Statut correctifs (branche `fix/security-hardening`) :** ✅ **corrigés** — #1 (fail-fast secret via plugin Nitro), #2 (auth + quota + metering sur `export-pdf`, auth sur `preview`), #3 (magic-link : échec explicite en prod, plus de log), #6 (cookies `Secure` + `trustedOrigins`), #9 (catch `InsufficientCreditsError` → 403), #10 (bornes Zod sur le rendu CV). ⏳ **restants** (nécessitent tests/décision) — #4 headers/CSP, #5 rate-limit, #7 bump `nodemailer`, #8 sandbox Chromium (infra), #11 CSRF (mitigé par #6), #12 updates deps. Note : l'export PDF depuis la page publique `/cv/demo` exige désormais d'être connecté (conséquence assumée de #2). + +--- + +## Ce qui est déjà solide (vérifié) + +- ✅ **Aucune injection SQL** — Prisma paramétré partout ; pas de `$queryRaw`/`$executeRaw` (sauf `SELECT 1` du healthcheck). +- ✅ **IDOR entièrement couvert** — chaque accès par id filtre par propriétaire : candidatures (`findFirst { id, userId }`), sous-ressources profil (`where: { id, profile: { userId } }`). Un id d'autrui renvoie 404 (ne divulgue pas l'existence). +- ✅ **Pas de mass-assignment** — les handlers n'écrivent que des champs explicites après validation Zod ; `userId`/`id`/`status`/`deletedAt` non pilotables par le body. +- ✅ **Pas de XSS stocké dans le CV** — `cv-html.ts` échappe **toutes** les données user via `esc()` ; tokens de design assainis (couleur hex, police `[A-Za-z0-9 \-]`, nombres clampés). +- ✅ **Pas de SSRF via la photo/PDF** — `sanitizeDataImage` n'accepte QUE des data-URL raster (png/jpg/webp), **rejette le SVG**, refuse les URL distantes et `file://`, borne à 700 Ko. +- ✅ **Stripe durci** — webhook signé sur le corps brut, montants/crédits déterminés server-side, idempotence réelle (ledger `idempotencyKey`), crédits seulement sur `payment_status='paid'`. +- ✅ **Garde-fou anti-invention LLM** — `assertValidCv` rejette tout contenu de CV dont la provenance ne pointe pas un id réel du profil : une injection de prompt ne peut pas fabriquer de fausse expérience. +- ✅ **Logs RGPD-safe** — ni offre, ni profil, ni CV, ni clé API loggés (seulement tokens/coût). +- ✅ **Aucun secret réel committé** — `.env`/`.output`/`.nuxt` gitignorés ; historique et working tree propres ; `.env.example` sans vraie clé. + +--- + +## Findings détaillés + +### 🔴 1 — Secret d'authentification : fallback en dur, pas de fail-fast (Critique) +**`apps/app/nuxt.config.ts`** +```ts +authSecret: process.env.BETTER_AUTH_SECRET ?? 'dev-secret-change-in-prod-min32chars!!', +``` +**Scénario :** si `BETTER_AUTH_SECRET` est absent en prod, l'app démarre **silencieusement** avec un secret **présent dans le repo public**. Ce secret signe les cookies de session et les tokens magic-link → un attaquant qui connaît cette valeur peut **forger des sessions valides** (usurpation totale de n'importe quel compte). Le fallback rend la faute invisible. + +**Correctif — refuser de démarrer en prod sans secret fort :** +```ts +const authSecret = process.env.BETTER_AUTH_SECRET +if (process.env.NODE_ENV === 'production' && (!authSecret || authSecret.length < 32)) { + throw new Error('BETTER_AUTH_SECRET manquant ou < 32 chars — refus de démarrer en production.') +} +// puis : authSecret: authSecret ?? 'dev-secret-change-in-prod-min32chars!!' +``` +Appliquer le même fail-fast à `DATABASE_URL`, `ANTHROPIC_API_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, et exiger `APP_URL=https://…`. + +--- + +### 🔴 2 — Export PDF non authentifié → DoS Chromium + bypass de quota (Élevé) +**`apps/app/server/api/cv/export-pdf.post.ts:17`** (et `preview.post.ts:13`) — **aucun `requireUserId`**. + +**Scénario :** un visiteur **non authentifié** peut POSTer un CV et déclencher `renderHtmlToPdf`, qui **lance une instance Chromium headless par requête** (~100-300 Mo RAM chacune). Une boucle de requêtes → épuisement mémoire/CPU du serveur, coût nul côté attaquant. De plus, le quota `export_pdf` (censé être borné par période) **n'est jamais appliqué** ici : aucun `recordUsageEvent`, aucune vérif de quota. + +**Correctif :** +```ts +const userId = requireUserId(event) +// vérifier le quota export_pdf (isUsageAllowed) → 429 si dépassé +const { cv, design: bodyDesign } = parseRenderInput(await readBody(event)) +// … rendu … +await recordUsageEvent(prisma, { userId, type: 'export_pdf' }, new Date()) +``` +Ajouter aussi `requireUserId` sur `preview.post.ts`. + +--- + +### 🟠 3 — Magic-link loggé en clair sans SMTP (Élevé, conditionnel) +**`apps/app/server/utils/mailer.ts:34`** +```ts +} else { + console.log(`\n[DEV] Magic-link pour ${email}:\n ${magicLinkUrl}\n`) +} +``` +**Scénario :** sans `SMTP_HOST` (état actuel du projet), **chaque demande de connexion imprime un jeton d'auth valide 10 min dans les logs**. Quiconque accède aux logs (opérateur, agrégateur type Datadog, fuite) peut se connecter en tant que n'importe quel utilisateur. + +**Correctif :** ne jamais logger le lien hors dev, et **échouer explicitement** en prod si SMTP absent : +```ts +} else if (process.env.NODE_ENV !== 'production') { + console.log(`\n[DEV] Magic-link pour ${email}:\n ${magicLinkUrl}\n`) +} else { + throw new Error('SMTP non configuré en production — envoi du magic-link impossible.') +} +``` +→ **Câbler un vrai SMTP est un prérequis de prod** (sans lui, la connexion ne fonctionne pas de toute façon). + +--- + +### 🟠 4 — Aucun header de sécurité HTTP (Élevé) +`nuxt.config.ts` ne définit aucun header ; aucun module `nuxt-security` installé. Absents : **CSP, HSTS, X-Content-Type-Options, X-Frame-Options/frame-ancestors, Referrer-Policy, Permissions-Policy**. → clickjacking, MIME-sniffing, pas de HTTPS forcé, XSS non durci. + +**Correctif sans dépendance (à tester d'abord en `Content-Security-Policy-Report-Only`) :** +```ts +// nuxt.config.ts +routeRules: { + '/**': { + headers: { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()', + 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', + 'Content-Security-Policy': [ + "default-src 'self'", "base-uri 'self'", "frame-ancestors 'none'", + "object-src 'none'", "img-src 'self' data:", "font-src 'self'", + "style-src 'self' 'unsafe-inline'", "script-src 'self'", + "connect-src 'self'", "form-action 'self'", + ].join('; '), + }, + }, +}, +``` +Alternative recommandée à terme : **`nuxt-security`** (CSP à nonce, HSTS, CORS, rate-limit intégré — cf. #5). + +--- + +### 🟠 5 — Aucun rate limiting (Élevé) +Aucun `rateLimit` dans le code. Surfaces exposées : +- **`POST /api/auth/magic-link/sign-in`** : email bombing (envoi massif vers des tiers → coût, blacklist domaine) + énumération de comptes. +- **`POST /api/candidature/analyze`** : **2 appels LLM Anthropic** et **hors gate de crédits** (seul `generate` consomme un crédit) → un compte peut marteler `/analyze` = coût token illimité (abus économique). + +**Correctifs :** +```ts +// server/utils/auth.ts — Better Auth +rateLimit: { + enabled: true, window: 60, max: 20, + customRules: { '/magic-link/sign-in': { window: 60, max: 3 } }, +}, +``` ++ rate-limit applicatif par `userId` sur `/analyze` (compteur fenêtre glissante, en base ou Redis). + +--- + +### 🟡 6 — Cookies de session non forcés `Secure` (Moyen) +`server/utils/auth.ts` ne configure ni `advanced.useSecureCookies` ni `trustedOrigins`. Better Auth active `Secure` **seulement si `baseURL` est en `https://`**. Or `baseURL = APP_URL ?? 'http://localhost:3000'` → si `APP_URL` n'est pas `https://…` en prod, **le cookie de session part en clair**. +```ts +// server/utils/auth.ts +trustedOrigins: [config.public.appUrl], +advanced: { useSecureCookies: process.env.NODE_ENV === 'production' }, +``` ++ garantir `APP_URL=https://…` en prod. + +--- + +### 🟡 7 — `nodemailer` 8.0.10 vulnérable (Moyen) +`pnpm audit` : **1 High** — `GHSA-p6gq-j5cr-w38f` (l'option `raw` contourne `disableFileAccess`/`disableUrlAccess` → lecture de fichier / SSRF). **Le code n'utilise pas `raw`** → exploitabilité réelle faible, mais le correctif est un bump majeur. +``` +nodemailer ^8.0.10 → ^9.0.1 (éditer la contrainte dans package.json, puis pnpm update) +``` +Autres : `esbuild` transitif (Low, dev/Windows uniquement — non-prod). + +--- + +### 🟡 8 — Chromium lancé sans sandbox (Moyen) +**`apps/app/server/utils/pdf.ts:47`** — `args: ['--no-sandbox', '--disable-setuid-sandbox']`. Retire une défense en profondeur : en cas de 0-day renderer sur du contenu dérivé de l'utilisateur, l'absence de sandbox facilite une RCE serveur. **Correctif :** faire tourner Chromium en utilisateur non-root dans un conteneur dédié plutôt que `--no-sandbox`, avec egress réseau filtré ; ou déléguer à Gotenberg. + +--- + +### 🟡 9 — Gate crédits TOCTOU → surconsommation LLM (Moyen) +**`apps/app/server/api/candidature/generate.post.ts`** — le solde est lu **au début** (l.66), les appels LLM ont lieu, puis `consumeOneCredit` débite **après succès** (l.165). Avec un solde de 1, N requêtes concurrentes passent toutes le contrôle → **N appels LLM payés**, un seul débit ; les autres lèvent `InsufficientCreditsError` **non capturée** → **500 non maîtrisée**. Le solde ne devient jamais négatif (transaction OK), mais le **coût LLM est amplifié** et l'erreur est sale. + +**Correctif :** réserver le crédit **avant** l'appel LLM (débit puis remboursement si échec), ou a minima `catch (InsufficientCreditsError)` → 403 propre. + +--- + +### 🟡 10 — Rendu CV sans bornes de taille + pas de `bodyLimit` (Moyen) +**`apps/app/server/utils/cv-render-input.ts`** : `RenderableCvSchema` valide la structure mais **aucune borne** (`sections`/`bullets` sans `.max()`, strings sans `.max()`). Couplé au #2 (endpoint ouvert), un CV valide mais gigantesque → HTML massif → rendu Chromium très lourd + JSON géant persisté via `[id].patch`. De plus `readBody` bufferise tout avant validation, sans `bodyLimit` Nitro. +**Correctif :** bornes Zod (`.max(20)` sections, `.max(30)` bullets, `.max(2000)` textes) + `bodyLimit` global sur les routes d'écriture. + +--- + +### 🟡 11 — Pas de CSRF applicatif sur les endpoints custom (Moyen) +`/api/candidature/*`, `/api/profile/*`, `/api/billing/*` s'appuient sur le cookie de session sans vérif d'`Origin` ni token CSRF. La **seule** protection est `SameSite=Lax` (défaut Better Auth). Correct aujourd'hui, mais défense unique : à durcir avec `trustedOrigins` explicite (cf. #6) et, idéalement, une vérif `Origin` sur les mutations. + +--- + +### 🟡 12 — Dépendances en retard (Moyen) +| Paquet | Actuel | Latest | Note | +|---|---|---|---| +| `better-auth` | 1.6.15 | 1.6.23 | **8 patchs de retard sur la lib d'auth** — à mettre à jour en priorité | +| `@prisma/client` / `prisma` | 6.19.3 | 7.8.0 | 1 majeure de retard (à planifier) | +| `nuxt` | 3.21.8 | 4.4.8 | 1 majeure (hors périmètre prod immédiat) | +| `stripe` | 22.2.2 | 22.3.0 | mineur, OK | + +--- + +### ⚪ Faible / Info +- **Preview HTML non authentifié** (`cv/preview.post.ts`) — pas de fuite (HTML issu du body), mais surface exposée : ajouter `requireUserId`. +- **Fonts distantes au rendu PDF** (`cv-html.ts` → `fonts.googleapis.com`, `waitUntil:'networkidle'`) — hôte non contrôlable par l'attaquant ; auto-héberger les polices évite la requête sortante et la latence. +- **Prompt injection via texte d'offre** — borné : le contenu du CV vient du profil + garde-fou provenance, score clampé, offre plafonnée 20 000 car. Résiduel acceptable ; optionnel : délimiteurs explicites autour de l'entrée non fiable. +- **`/api/health` public** — expose `db: up/down` + nom de service (pas de secret). Optionnel : réduire à 200/503. +- **`robots.txt` absent** — SEO/contrôle du crawl (empêcher l'indexation des pages app). +- **Détails de schéma dans les erreurs 400** (`profile/index.put.ts`) — `error.flatten()` divulgue la forme du schéma (pas de secret). Cosmétique. + +--- + +## Plan de remédiation recommandé (ordre) + +1. **#1 fail-fast secret** + **#2 auth sur export-pdf/preview** + **#9 catch InsufficientCreditsError** + **#3 magic-link** — corrections ciblées, faible risque, fort impact. +2. **#6 cookies Secure + trustedOrigins** + **#10 bornes Zod / bodyLimit**. +3. **#4 headers de sécurité** (tester en Report-Only) + **#5 rate-limit**. +4. **#7 bump nodemailer** + **#12 update better-auth**. +5. **#8 sandbox Chromium** (infra/conteneur) — au déploiement. + +**Prérequis d'infra prod (hors code) :** SMTP réel, `APP_URL=https`, tous les secrets fournis via un gestionnaire de secrets (pas de `.env` en clair sur la machine), HTTPS/HSTS au niveau du reverse-proxy, sauvegardes DB chiffrées, et idéalement un scanner de secrets (`gitleaks`) en CI. diff --git a/apps/app/server/api/candidature/generate.post.ts b/apps/app/server/api/candidature/generate.post.ts index f21961d..05ce89e 100644 --- a/apps/app/server/api/candidature/generate.post.ts +++ b/apps/app/server/api/candidature/generate.post.ts @@ -24,7 +24,7 @@ import { requireUserId } from '../../utils/session' import { prisma } from '../../utils/prisma' import { NOT_DELETED, toProfileDTO } from '../../utils/profile-serialize' import { recordUsageEvent } from '../../utils/metering' -import { getOrInitBalance, consumeOneCredit } from '../../utils/credits' +import { getOrInitBalance, consumeOneCredit, InsufficientCreditsError } from '../../utils/credits' import { anthropicComplete, LlmError, type LlmComplete } from '../../utils/anthropic' import { matchProfileToOffer } from '../../services/matching' import { adaptKeySkills } from '../../services/keyskills-adapt' @@ -183,6 +183,16 @@ export default defineEventHandler(async (event): Promise { + // Auth OBLIGATOIRE : le rendu lance un Chromium par requête. Sans ce garde, un + // visiteur anonyme peut épuiser CPU/RAM du serveur (DoS) et contourner le quota. + const userId = requireUserId(event) + + // Gate de quota d'export (borné par période) AVANT de lancer Chromium. + const now = new Date() + if (!(await isUsageAllowed(prisma, userId, 'export_pdf', FREE_TIER_QUOTAS, now))) { + throw createError({ statusCode: 429, message: 'Quota d’export PDF atteint pour cette période.' }) + } + const { cv, design: bodyDesign } = parseRenderInput(await readBody(event)) // Design par-candidature (body) > thème de profil > défaut. Avec un design, on @@ -23,6 +37,9 @@ export default defineEventHandler(async (event) => { const pdfBuffer = await renderHtmlToPdf(buildCvHtml(cv, design), { fullBleed: !!design }) + // Comptabilise l'export réussi (metering + quota de la période suivante). + await recordUsageEvent(prisma, { userId, type: 'export_pdf' }, now) + setHeader(event, 'Content-Type', 'application/pdf') setHeader(event, 'Content-Disposition', 'attachment; filename="cv.pdf"') return new Uint8Array(pdfBuffer) diff --git a/apps/app/server/api/cv/preview.post.ts b/apps/app/server/api/cv/preview.post.ts index 5da6cd8..db9fccb 100644 --- a/apps/app/server/api/cv/preview.post.ts +++ b/apps/app/server/api/cv/preview.post.ts @@ -6,11 +6,14 @@ * provient du body (éditeur, design par-candidature) sinon du profil sinon défaut. */ import type { CvDesign } from '@cvo/shared' +import { requireUserId } from '../../utils/session' import { buildCvHtml } from '../../utils/cv-html' import { loadBaseCvDesign } from '../../utils/cv-design' import { parseRenderInput } from '../../utils/cv-render-input' export default defineEventHandler(async (event) => { + // Auth requise : endpoint de rendu réservé aux utilisateurs connectés. + requireUserId(event) const { cv, design } = parseRenderInput(await readBody(event)) const effective: CvDesign | null = design ?? (await loadBaseCvDesign(event)) diff --git a/apps/app/server/plugins/00.validate-env.ts b/apps/app/server/plugins/00.validate-env.ts new file mode 100644 index 0000000..87c3f64 --- /dev/null +++ b/apps/app/server/plugins/00.validate-env.ts @@ -0,0 +1,29 @@ +/** + * Fail-fast de configuration au démarrage du serveur (Nitro). + * + * En production, on REFUSE de démarrer si un secret critique est absent/faible : + * un fallback silencieux (secret d'auth codé en dur, cookies non-Secure) ouvre + * une usurpation de session totale. Mieux vaut un crash au boot qu'une prod + * vulnérable en silence. En dev, on ne bloque pas (valeurs de repli tolérées). + * + * S'exécute une fois à l'initialisation ; lit la runtimeConfig résolue (donc les + * overrides d'env runtime `NUXT_*` sont bien pris en compte). + */ +export default defineNitroPlugin(() => { + if (process.env.NODE_ENV !== 'production') return + + const config = useRuntimeConfig() + const fail = (msg: string): never => { + throw new Error(`[config] ${msg} — refus de démarrer en production.`) + } + + const secret = config.authSecret + if (!secret || secret.length < 32 || secret.includes('change-in-prod')) { + fail('BETTER_AUTH_SECRET manquant, trop court (<32) ou laissé à la valeur de dev') + } + + // Better Auth n'active les cookies Secure que si baseURL est en https. + if (!config.public.appUrl.startsWith('https://')) { + fail('APP_URL doit être en https:// (cookies de session Secure)') + } +}) diff --git a/apps/app/server/utils/auth.ts b/apps/app/server/utils/auth.ts index cf19a19..0ff0253 100644 --- a/apps/app/server/utils/auth.ts +++ b/apps/app/server/utils/auth.ts @@ -15,6 +15,16 @@ export const auth = betterAuth({ secret: config.authSecret, baseURL: config.public.appUrl, + // Origines de confiance (défense CSRF de Better Auth sur /api/auth/**). Explicite + // pour éviter toute dérive si `baseURL` change. + trustedOrigins: [config.public.appUrl], + + advanced: { + // Force les cookies de session en Secure en prod (transmis uniquement en HTTPS), + // sans dépendre du protocole de `baseURL`. HttpOnly + SameSite=Lax restent les défauts. + useSecureCookies: process.env.NODE_ENV === 'production', + }, + database: prismaAdapter(prisma, { provider: 'postgresql' }), // FR-first : labels et messages côté e-mail en français. diff --git a/apps/app/server/utils/cv-render-input.ts b/apps/app/server/utils/cv-render-input.ts index 0ef4456..81e4791 100644 --- a/apps/app/server/utils/cv-render-input.ts +++ b/apps/app/server/utils/cv-render-input.ts @@ -7,23 +7,28 @@ import { assertValidCv } from '@cvo/shared' import type { CvDesign, RenderableCv } from '@cvo/shared' import { normalizeDesign } from './cv-design-tokens' -const Provenance = z.object({ profileItemId: z.string(), reformulated: z.boolean() }) -const Contact = z.object({ kind: z.enum(['email', 'phone', 'location', 'link']), label: z.string(), value: z.string() }) -const Bullet = z.object({ id: z.string(), text: z.string(), provenance: Provenance }) -const BaseEntry = z.object({ id: z.string(), provenance: Provenance }) +// Bornes de taille : un CV valide reste modeste. Elles plafonnent le HTML/JSON +// pour éviter un rendu Chromium géant / une persistance JSON abusive (anti-DoS). +const S = (max: number) => z.string().max(max) +const ID = z.string().max(200) + +const Provenance = z.object({ profileItemId: ID, reformulated: z.boolean() }) +const Contact = z.object({ kind: z.enum(['email', 'phone', 'location', 'link']), label: S(100), value: S(500) }) +const Bullet = z.object({ id: ID, text: S(2000), provenance: Provenance }) +const BaseEntry = z.object({ id: ID, provenance: Provenance }) const Section = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('summary'), title: z.string(), text: z.string(), provenance: Provenance }), - z.object({ kind: z.literal('keyskills'), title: z.string(), entries: z.array(BaseEntry.extend({ text: z.string() })) }), - z.object({ kind: z.literal('experience'), title: z.string(), entries: z.array(BaseEntry.extend({ role: z.string(), organization: z.string(), period: z.string(), location: z.string().optional(), bullets: z.array(Bullet) })) }), - z.object({ kind: z.literal('skills'), title: z.string(), entries: z.array(BaseEntry.extend({ label: z.string() })) }), - z.object({ kind: z.literal('education'), title: z.string(), entries: z.array(BaseEntry.extend({ degree: z.string(), institution: z.string(), period: z.string() })) }), - z.object({ kind: z.literal('languages'), title: z.string(), entries: z.array(BaseEntry.extend({ label: z.string(), level: z.string() })) }), + z.object({ kind: z.literal('summary'), title: S(200), text: S(5000), provenance: Provenance }), + z.object({ kind: z.literal('keyskills'), title: S(200), entries: z.array(BaseEntry.extend({ text: S(1000) })).max(30) }), + z.object({ kind: z.literal('experience'), title: S(200), entries: z.array(BaseEntry.extend({ role: S(300), organization: S(300), period: S(120), location: S(300).optional(), bullets: z.array(Bullet).max(40) })).max(40) }), + z.object({ kind: z.literal('skills'), title: S(200), entries: z.array(BaseEntry.extend({ label: S(200) })).max(100) }), + z.object({ kind: z.literal('education'), title: S(200), entries: z.array(BaseEntry.extend({ degree: S(300), institution: S(300), period: S(120) })).max(30) }), + z.object({ kind: z.literal('languages'), title: S(200), entries: z.array(BaseEntry.extend({ label: S(200), level: S(100) })).max(50) }), ]) export const RenderableCvSchema = z.object({ - header: z.object({ fullName: z.string().min(1), headline: z.string(), contacts: z.array(Contact), provenance: Provenance }), - sections: z.array(Section), + header: z.object({ fullName: z.string().min(1).max(200), headline: S(300), contacts: z.array(Contact).max(20), provenance: Provenance }), + sections: z.array(Section).max(20), locale: z.literal('fr'), }) diff --git a/apps/app/server/utils/mailer.ts b/apps/app/server/utils/mailer.ts index 38a8039..1ff6373 100644 --- a/apps/app/server/utils/mailer.ts +++ b/apps/app/server/utils/mailer.ts @@ -29,8 +29,12 @@ export async function sendMagicLinkEmail(email: string, magicLinkUrl: string): P text: `Bonjour,\n\nCliquez sur ce lien pour vous connecter (valable 10 minutes) :\n${magicLinkUrl}\n\nSi vous n'avez pas demandé ce lien, ignorez cet e-mail.\n\n— L'équipe CV Optimizer`, html: `

Bonjour,

Cliquez sur ce lien pour vous connecter (valable 10 minutes) :

${magicLinkUrl}

Si vous n'avez pas demandé ce lien, ignorez cet e-mail.

— L'équipe CV Optimizer

`, }) - } else { - // Dev : affiche le lien dans la console pour test sans SMTP. + } else if (process.env.NODE_ENV !== 'production') { + // Dev uniquement : affiche le lien dans la console pour test sans SMTP. console.log(`\n[DEV] Magic-link pour ${email}:\n ${magicLinkUrl}\n`) + } else { + // Prod sans SMTP : ne JAMAIS logger un jeton d'auth (vol de session via logs). + // On échoue explicitement — la connexion est cassée tant que SMTP n'est pas câblé. + throw new Error('SMTP non configuré en production — envoi du magic-link impossible.') } } From 37b230f5253d30ff93fe8ea0fdee6347e379aa6c Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Thu, 2 Jul 2026 16:21:51 +0200 Subject: [PATCH 2/4] feat(security): headers/CSP via nuxt-security + rate-limit magic-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nuxt-security : HSTS, X-Frame-Options DENY, nosniff, Referrer-Policy, Permissions-Policy, et CSP à nonce en **Report-Only** (n'applique rien tant que non validée au navigateur — retirer contentSecurityPolicyReportOnly ensuite). - requestSizeLimiter réglé au-dessus de 8 Mo (préserve l'upload PDF de cv-design/extract) ; rateLimiter global désactivé (géré finement ailleurs). - Better Auth rateLimit : magic-link bridé à 3 envois/min (anti email-bombing et énumération). Stockage mémoire — à passer sur store partagé en multi-instance. Vérifié : app boote (200), en-tête Content-Security-Policy-Report-Only présent. Typecheck OK, 124/124 tests verts. Co-Authored-By: Claude Opus 4.8 (1M context) --- SECURITY-AUDIT.md | 7 ++- apps/app/nuxt.config.ts | 41 ++++++++++++++++- apps/app/package.json | 1 + apps/app/server/utils/auth.ts | 13 ++++++ pnpm-lock.yaml | 85 +++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md index a064aa7..548c1cc 100644 --- a/SECURITY-AUDIT.md +++ b/SECURITY-AUDIT.md @@ -28,7 +28,12 @@ \* Élevé **conditionnel** au déploiement actuel (pas de SMTP câblé → le jeton part dans les logs). -> **Statut correctifs (branche `fix/security-hardening`) :** ✅ **corrigés** — #1 (fail-fast secret via plugin Nitro), #2 (auth + quota + metering sur `export-pdf`, auth sur `preview`), #3 (magic-link : échec explicite en prod, plus de log), #6 (cookies `Secure` + `trustedOrigins`), #9 (catch `InsufficientCreditsError` → 403), #10 (bornes Zod sur le rendu CV). ⏳ **restants** (nécessitent tests/décision) — #4 headers/CSP, #5 rate-limit, #7 bump `nodemailer`, #8 sandbox Chromium (infra), #11 CSRF (mitigé par #6), #12 updates deps. Note : l'export PDF depuis la page publique `/cv/demo` exige désormais d'être connecté (conséquence assumée de #2). +> **Statut correctifs (branche `fix/security-hardening`) :** +> ✅ **corrigés** — #1 (fail-fast secret via plugin Nitro), #2 (auth + quota + metering sur `export-pdf`, auth sur `preview`), #3 (magic-link : échec explicite en prod, plus de log), #4 (**nuxt-security** : HSTS/XFO/nosniff/Referrer/Permissions + **CSP en Report-Only**), #6 (cookies `Secure` + `trustedOrigins`), #9 (catch `InsufficientCreditsError` → 403), #10 (bornes Zod + `requestSizeLimiter`). +> �️ **partiel** — #5 : rate-limit **magic-link** actif (Better Auth, 3/min) ; **reste** la limite par-utilisateur sur `/analyze` (à décider : seuil + stockage). #11 : mitigé par #6 (SameSite=Lax + `trustedOrigins`) ; CSRF middleware nuxt-security laissé off pour ne pas casser le webhook Stripe. +> ⏳ **restants** — **passer la CSP en mode bloquant** après validation navigateur (retirer `contentSecurityPolicyReportOnly`), limite `/analyze`, #7 bump `nodemailer`, #8 sandbox Chromium (infra), #12 updates deps (`better-auth`). +> +> ⚠️ Notes : l'export PDF depuis la page publique `/cv/demo` exige désormais d'être connecté (conséquence de #2). Le rate-limit magic-link et le limiteur nuxt-security sont **en mémoire par instance** → prévoir un stockage partagé (Redis/DB) en multi-instance. --- diff --git a/apps/app/nuxt.config.ts b/apps/app/nuxt.config.ts index ff3bcbb..c06d3f9 100644 --- a/apps/app/nuxt.config.ts +++ b/apps/app/nuxt.config.ts @@ -8,11 +8,50 @@ export default defineNuxtConfig({ // Chargement réel des fontes (self-hosted au build — pas de requête Google au runtime). // La famille est consommée via le token --font-sans (assets/css/main.css). - modules: ['@nuxt/fonts'], + modules: ['@nuxt/fonts', 'nuxt-security'], fonts: { families: [{ name: 'Plus Jakarta Sans', provider: 'google', weights: [400, 500, 600, 700, 800] }], }, + // Durcissement HTTP (nuxt-security). Headers appliqués globalement ; la CSP est + // en Report-Only tant qu'elle n'a pas été validée au navigateur (elle n' observe + // et ne bloque RIEN dans cet état — voir /api/_security si besoin). + security: { + // CSP en Report-Only : le header envoyé est `Content-Security-Policy-Report-Only`, + // qui n'BLOQUE rien (observe seulement). Passer à `false` après validation navigateur. + contentSecurityPolicyReportOnly: true, + // Rate-limit global désactivé : on gère finement ailleurs (Better Auth pour le + // magic-link, compteur par utilisateur pour /analyze). Le limiteur intégré est + // par-IP en mémoire (par instance) → insuffisant pour l'abus économique. + rateLimiter: false, + // Le magic-link + les uploads PDF (cv-design/extract, 8 Mo) doivent passer. + requestSizeLimiter: { + maxRequestSizeInBytes: 2_000_000, // corps JSON standard (2 Mo) + maxUploadFileRequestInBytes: 12_000_000, // > 8 Mo pour l'upload PDF multipart + }, + // CSP en observation d'abord : header Report-Only (ne bloque pas). + // Passer à `false` une fois la CSP validée pour l'appliquer réellement. + headers: { + contentSecurityPolicy: { + 'base-uri': ["'self'"], + 'default-src': ["'self'"], + 'object-src': ["'none'"], + 'frame-ancestors': ["'none'"], + 'img-src': ["'self'", 'data:'], + 'font-src': ["'self'"], + 'style-src': ["'self'", "'unsafe-inline'"], + 'script-src': ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"], + 'connect-src': ["'self'"], + 'form-action': ["'self'"], + 'upgrade-insecure-requests': true, + }, + strictTransportSecurity: { maxAge: 63072000, includeSubdomains: true, preload: true }, + xFrameOptions: 'DENY', + referrerPolicy: 'strict-origin-when-cross-origin', + permissionsPolicy: { camera: [], microphone: [], geolocation: [], payment: [] }, + }, + }, + // TS strict des deux côtés (front Vue + serveur Nitro). typescript: { strict: true, diff --git a/apps/app/package.json b/apps/app/package.json index dbbc264..aaf2377 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -22,6 +22,7 @@ "@prisma/client": "^6.2.1", "better-auth": "^1.6.15", "nodemailer": "^8.0.10", + "nuxt-security": "2.6.0", "playwright-core": "^1.60.0", "stripe": "^22.2.2", "zod": "^4.4.3" diff --git a/apps/app/server/utils/auth.ts b/apps/app/server/utils/auth.ts index 0ff0253..f7412e5 100644 --- a/apps/app/server/utils/auth.ts +++ b/apps/app/server/utils/auth.ts @@ -25,6 +25,19 @@ export const auth = betterAuth({ useSecureCookies: process.env.NODE_ENV === 'production', }, + // Rate-limit des routes d'auth : bride surtout l'envoi de magic-link (anti + // email-bombing / énumération). Stockage en mémoire par défaut (par instance) ; + // en multi-instance prod, basculer sur un stockage partagé (DB/secondaryStorage). + rateLimit: { + enabled: true, + window: 60, + max: 30, + customRules: { + // Chemin réel du plugin magic-link : POST /api/auth/sign-in/magic-link. + '/sign-in/magic-link': { window: 60, max: 3 }, // 3 envois / min max + }, + }, + database: prismaAdapter(prisma, { provider: 'postgresql' }), // FR-first : labels et messages côté e-mail en français. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94b4861..8028319 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: nodemailer: specifier: ^8.0.10 version: 8.0.10 + nuxt-security: + specifier: 2.6.0 + version: 2.6.0(magicast@0.5.3)(rollup@4.61.1) playwright-core: specifier: ^1.60.0 version: 1.60.0 @@ -2069,6 +2072,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + better-auth@1.6.15: resolution: {integrity: sha512-0nuQuEru3ZrLF+9xFUuN3llAmR+6gHLtLunoXaZxB9lXGjSmfBcc6SZUgYq4DfzugPnLvdnzYazsyprZFSFC4Q==} peerDependencies: @@ -2358,6 +2365,9 @@ packages: engines: {node: '>=4'} hasBin: true + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + cssnano-preset-default@7.0.17: resolution: {integrity: sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -3348,6 +3358,13 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuxt-csurf@1.6.5: + resolution: {integrity: sha512-/DMNTON8LIVhntamKbBmAuM879B0QnuSJa7ZAkmkZe+21m+1QGcjVUxtSkizaM48NUvkuAGYOG0ncn+kqEgrzw==} + + nuxt-security@2.6.0: + resolution: {integrity: sha512-5rEfyxw1gS+vl1s5q7GcGuFOL5g0I03Q/mz7Zfr+IRJFsaZKwRxNVMFNRbatfK3Dz65LuRPnLewLmJ+Yh9ujOA==} + engines: {node: '>=24.0.0'} + nuxt@3.21.8: resolution: {integrity: sha512-RRB/MpZhdEhb/A21qaUaSI1UYDOy9bgK0vZvRRjDsA8HGY+eFBr2EvUkdeYQHOT8WKuByxWlg5ckz3H4A+QQ1w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4155,6 +4172,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + uncsrf@1.2.0: + resolution: {integrity: sha512-EyeG1tIx1zisLuqokSXZ5LhndzaUd2WBMS+18IlBUYobJsKSUQMpLIEm6QUfY/Azmhnnz0v2QbkrT6/u2K/Y1g==} + unctx@2.5.0: resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} @@ -4190,6 +4210,9 @@ packages: rolldown: optional: true + unplugin-remove@1.0.3: + resolution: {integrity: sha512-BZMt9v8Y/Z27cY7YQv+DpcW928znjP1cqplBXOirbANiFQtM2YCdiyNAJhHCvjppT0lScNn1aDrQnXqnRp32pQ==} + unplugin-utils@0.3.1: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} @@ -4204,6 +4227,10 @@ packages: vue-router: optional: true + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -4535,6 +4562,11 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xss@1.0.15: + resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} + engines: {node: '>= 0.10.0'} + hasBin: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6529,6 +6561,10 @@ snapshots: baseline-browser-mapping@2.10.34: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + better-auth@1.6.15(@prisma/client@6.19.3(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(typescript@5.9.3))(prisma@6.19.3(magicast@0.5.3)(typescript@5.9.3))(vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@5.9.3)): dependencies: '@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) @@ -6789,6 +6825,8 @@ snapshots: cssesc@3.0.0: {} + cssfilter@0.0.10: {} + cssnano-preset-default@7.0.17(postcss@8.5.15): dependencies: browserslist: 4.28.2 @@ -7881,6 +7919,28 @@ snapshots: dependencies: boolbase: 1.0.0 + nuxt-csurf@1.6.5(magicast@0.5.3): + dependencies: + '@nuxt/kit': 3.21.8(magicast@0.5.3) + defu: 6.1.7 + uncsrf: 1.2.0 + transitivePeerDependencies: + - magicast + + nuxt-security@2.6.0(magicast@0.5.3)(rollup@4.61.1): + dependencies: + '@nuxt/kit': 4.4.8(magicast@0.5.3) + basic-auth: 2.0.1 + defu: 6.1.7 + nuxt-csurf: 1.6.5(magicast@0.5.3) + pathe: 2.0.3 + unplugin-remove: 1.0.3(rollup@4.61.1) + xss: 1.0.15 + transitivePeerDependencies: + - magicast + - rollup + - supports-color + nuxt@3.21.8(@parcel/watcher@2.5.6)(@types/node@22.19.20)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.61.1))(rollup@4.61.1)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.4)(typescript@5.9.3)(vite@7.3.5(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))(vue-tsc@2.2.12(typescript@5.9.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@5.9.3) @@ -8824,6 +8884,8 @@ snapshots: uncrypto@0.1.3: {} + uncsrf@1.2.0: {} + unctx@2.5.0: dependencies: acorn: 8.16.0 @@ -8870,6 +8932,19 @@ snapshots: optionalDependencies: oxc-parser: 0.132.0 + unplugin-remove@1.0.3(rollup@4.61.1): + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7 + '@rollup/pluginutils': 5.4.0(rollup@4.61.1) + magic-string: 0.30.21 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + - supports-color + unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 @@ -8900,6 +8975,11 @@ snapshots: transitivePeerDependencies: - vue + unplugin@1.16.1: + dependencies: + acorn: 8.16.0 + webpack-virtual-modules: 0.6.2 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -9209,6 +9289,11 @@ snapshots: xml-name-validator@4.0.0: {} + xss@1.0.15: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + y18n@5.0.8: {} yallist@3.1.1: {} From 7c251409539719accf36ffc09b716a513cc97aac Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Thu, 2 Jul 2026 16:49:30 +0200 Subject: [PATCH 3/4] feat(security): active la CSP (bloquante) + collecteur de violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSP validée au navigateur via un collecteur /api/csp-report (directive report-uri) en mode Report-Only : seule violation = les Google Fonts du rendu CV (cv-html.ts), autorisées explicitement (fonts.googleapis.com / fonts.gstatic.com). 0 violation résiduelle → bascule en Content-Security-Policy bloquante. - server/api/csp-report.post.ts : logge les violations (monitoring, conservé en prod). - font-src/style-src : allowlist Google Fonts (suivi #3 : auto-héberger pour retirer ces hôtes distants). Smoke-test navigateur OK (pages, aperçu CV iframe, export PDF). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/nuxt.config.ts | 16 +++++++++++----- apps/app/server/api/csp-report.post.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 apps/app/server/api/csp-report.post.ts diff --git a/apps/app/nuxt.config.ts b/apps/app/nuxt.config.ts index c06d3f9..b527c3c 100644 --- a/apps/app/nuxt.config.ts +++ b/apps/app/nuxt.config.ts @@ -17,9 +17,10 @@ export default defineNuxtConfig({ // en Report-Only tant qu'elle n'a pas été validée au navigateur (elle n' observe // et ne bloque RIEN dans cet état — voir /api/_security si besoin). security: { - // CSP en Report-Only : le header envoyé est `Content-Security-Policy-Report-Only`, - // qui n'BLOQUE rien (observe seulement). Passer à `false` après validation navigateur. - contentSecurityPolicyReportOnly: true, + // CSP ACTIVE (bloquante) : validée au navigateur via le collecteur /api/csp-report + // (0 violation après allowlist Google Fonts). Le header report-uri reste actif pour + // continuer à monitorer d'éventuelles violations en prod. + contentSecurityPolicyReportOnly: false, // Rate-limit global désactivé : on gère finement ailleurs (Better Auth pour le // magic-link, compteur par utilisateur pour /analyze). Le limiteur intégré est // par-IP en mémoire (par instance) → insuffisant pour l'abus économique. @@ -38,12 +39,17 @@ export default defineNuxtConfig({ 'object-src': ["'none'"], 'frame-ancestors': ["'none'"], 'img-src': ["'self'", 'data:'], - 'font-src': ["'self'"], - 'style-src': ["'self'", "'unsafe-inline'"], + // Google Fonts pour le rendu du CV (cv-html.ts injecte un selon la + // police du design). Suivi #3 : auto-héberger ces polices pour retirer ces + // deux hôtes distants et la requête sortante au rendu. + 'font-src': ["'self'", 'https://fonts.gstatic.com'], + 'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], 'script-src': ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"], 'connect-src': ["'self'"], 'form-action': ["'self'"], 'upgrade-insecure-requests': true, + // Collecteur de violations (phase Report-Only) — voir server/api/csp-report. + 'report-uri': ['/api/csp-report'], }, strictTransportSecurity: { maxAge: 63072000, includeSubdomains: true, preload: true }, xFrameOptions: 'DENY', diff --git a/apps/app/server/api/csp-report.post.ts b/apps/app/server/api/csp-report.post.ts new file mode 100644 index 0000000..cbd013e --- /dev/null +++ b/apps/app/server/api/csp-report.post.ts @@ -0,0 +1,26 @@ +/** + * POST /api/csp-report — collecteur de violations CSP (phase de validation). + * + * Tant que la CSP est en Report-Only (nuxt.config `contentSecurityPolicyReportOnly`), + * le navigateur POSTe ici chaque violation (directive `report-uri`). On logge une + * ligne concise par violation pour ajuster la policy avant de l'appliquer réellement. + * + * Non authentifié (appelé par le navigateur, sans session garantie) ; ne stocke rien. + * Le corps arrive en `application/csp-report` → on lit le brut et on parse à la main. + */ +export default defineEventHandler(async (event) => { + const raw = await readRawBody(event) + if (!raw) return { ok: true } + try { + const parsed = JSON.parse(raw.toString()) + const r = parsed['csp-report'] ?? parsed.body ?? parsed + const directive = r['effective-directive'] || r['violated-directive'] || '?' + const blocked = r['blocked-uri'] || r.blockedURL || '?' + const page = r['document-uri'] || r.documentURL || '?' + const src = r['source-file'] ? ` | source: ${r['source-file']}:${r['line-number'] ?? '?'}` : '' + console.warn(`[CSP] directive=${directive} | bloqué=${blocked} | page=${page}${src}`) + } catch { + console.warn('[CSP] rapport non parsable:', raw.toString().slice(0, 300)) + } + return { ok: true } +}) From 7d0f4fdfa3858e27e06909078e8d61ba5dcaee66 Mon Sep 17 00:00:00 2001 From: tbeaumont79 Date: Thu, 2 Jul 2026 16:50:04 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(security):=20rapport=20=C3=A0=20jour?= =?UTF-8?q?=20=E2=80=94=20CSP=20active,=20statut=20des=20correctifs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- SECURITY-AUDIT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY-AUDIT.md b/SECURITY-AUDIT.md index 548c1cc..2d0c8bd 100644 --- a/SECURITY-AUDIT.md +++ b/SECURITY-AUDIT.md @@ -29,9 +29,9 @@ \* Élevé **conditionnel** au déploiement actuel (pas de SMTP câblé → le jeton part dans les logs). > **Statut correctifs (branche `fix/security-hardening`) :** -> ✅ **corrigés** — #1 (fail-fast secret via plugin Nitro), #2 (auth + quota + metering sur `export-pdf`, auth sur `preview`), #3 (magic-link : échec explicite en prod, plus de log), #4 (**nuxt-security** : HSTS/XFO/nosniff/Referrer/Permissions + **CSP en Report-Only**), #6 (cookies `Secure` + `trustedOrigins`), #9 (catch `InsufficientCreditsError` → 403), #10 (bornes Zod + `requestSizeLimiter`). -> �️ **partiel** — #5 : rate-limit **magic-link** actif (Better Auth, 3/min) ; **reste** la limite par-utilisateur sur `/analyze` (à décider : seuil + stockage). #11 : mitigé par #6 (SameSite=Lax + `trustedOrigins`) ; CSRF middleware nuxt-security laissé off pour ne pas casser le webhook Stripe. -> ⏳ **restants** — **passer la CSP en mode bloquant** après validation navigateur (retirer `contentSecurityPolicyReportOnly`), limite `/analyze`, #7 bump `nodemailer`, #8 sandbox Chromium (infra), #12 updates deps (`better-auth`). +> ✅ **corrigés** — #1 (fail-fast secret via plugin Nitro), #2 (auth + quota + metering sur `export-pdf`, auth sur `preview`), #3 (magic-link : échec explicite en prod, plus de log), #4 (**nuxt-security** : HSTS/XFO/nosniff/Referrer/Permissions + **CSP active/bloquante**, validée navigateur via collecteur `/api/csp-report`, Google Fonts du rendu CV en allowlist), #6 (cookies `Secure` + `trustedOrigins`), #9 (catch `InsufficientCreditsError` → 403), #10 (bornes Zod + `requestSizeLimiter`). +> 🟡 **partiel** — #5 : rate-limit **magic-link** actif (Better Auth, 3/min) ; **reste** la limite par-utilisateur sur `/analyze` (à décider : seuil + stockage). #11 : mitigé par #6 (SameSite=Lax + `trustedOrigins`) ; CSRF middleware nuxt-security laissé off pour ne pas casser le webhook Stripe. +> ⏳ **restants** — limite `/analyze`, #7 bump `nodemailer`, #8 sandbox Chromium (infra), #12 updates deps (`better-auth`). Suivi #3 : auto-héberger les polices du CV pour retirer les hôtes Google Fonts de la CSP. > > ⚠️ Notes : l'export PDF depuis la page publique `/cv/demo` exige désormais d'être connecté (conséquence de #2). Le rate-limit magic-link et le limiteur nuxt-security sont **en mémoire par instance** → prévoir un stockage partagé (Redis/DB) en multi-instance.