Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions SECURITY-AUDIT.md

Large diffs are not rendered by default.

47 changes: 46 additions & 1 deletion apps/app/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,56 @@ 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 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.
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:'],
// Google Fonts pour le rendu du CV (cv-html.ts injecte un <link> 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',
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,
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion apps/app/server/api/candidature/generate.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -183,6 +183,16 @@ export default defineEventHandler(async (event): Promise<GenerateCandidatureResp

return { cv, candidatureId: candidature.id }
} catch (err) {
// Course concurrente sur le dernier crédit : la transaction de débit relit le
// solde et lève si un autre appel a consommé entre-temps. On renvoie un 403
// propre (pas un 500), cohérent avec le gate d'entrée.
if (err instanceof InsufficientCreditsError) {
throw createError({
statusCode: 403,
message: 'Tu n’as plus de crédits — achète un pack pour continuer',
data: { code: QUOTA_EXCEEDED_CODE },
})
}
// Garde-fou de provenance : le LLM a produit un élément non sourcé — rejeté.
if (err instanceof ProvenanceError) {
throw createError({
Expand Down
26 changes: 26 additions & 0 deletions apps/app/server/api/csp-report.post.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
17 changes: 17 additions & 0 deletions apps/app/server/api/cv/export-pdf.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,26 @@
*/

import type { CvDesign } from '@cvo/shared'
import { FREE_TIER_QUOTAS } from '@cvo/shared'
import { requireUserId } from '../../utils/session'
import { prisma } from '../../utils/prisma'
import { isUsageAllowed, recordUsageEvent } from '../../utils/metering'
import { buildCvHtml } from '../../utils/cv-html'
import { renderHtmlToPdf } from '../../utils/pdf'
import { loadBaseCvDesign } from '../../utils/cv-design'
import { parseRenderInput } from '../../utils/cv-render-input'

export default defineEventHandler(async (event) => {
// 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
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions apps/app/server/api/cv/preview.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
29 changes: 29 additions & 0 deletions apps/app/server/plugins/00.validate-env.ts
Original file line number Diff line number Diff line change
@@ -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)')
}
})
23 changes: 23 additions & 0 deletions apps/app/server/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ 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',
},

// 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.
Expand Down
29 changes: 17 additions & 12 deletions apps/app/server/utils/cv-render-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
})

Expand Down
8 changes: 6 additions & 2 deletions apps/app/server/utils/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<p>Bonjour,</p><p>Cliquez sur ce lien pour vous connecter (valable 10 minutes) :</p><p><a href="${magicLinkUrl}">${magicLinkUrl}</a></p><p>Si vous n'avez pas demandé ce lien, ignorez cet e-mail.</p><p>— L'équipe CV Optimizer</p>`,
})
} 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.')
}
}
Loading
Loading