@nodefony/security 10.0.0-alpha.7 → 10.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-user-add.js +2 -18
  5. package/dist/nodefony/command/security-user-password.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -3
  7. package/dist/nodefony/service/auditService.js +3 -2
  8. package/dist/nodefony/service/tokenService.js +3 -2
  9. package/dist/nodefony/service/totp.js +5 -3
  10. package/dist/nodefony/service/webAuthn.js +3 -2
  11. package/dist/nodefony/service/webhooks.js +5 -3
  12. package/dist/nodefony/src/SecuredArea.js +7 -0
  13. package/dist/nodefony/src/realtime/frameAuthorizer.js +23 -9
  14. package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
  15. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  16. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  17. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  18. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  19. package/dist/types/nodefony/config/config.d.ts +2 -0
  20. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
  21. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +15 -0
  22. package/dist/types/nodefony/src/SecuredArea.d.ts +6 -0
  23. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +22 -8
  24. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  25. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  26. package/docs/api-keys.md +62 -60
  27. package/docs/audit.md +77 -78
  28. package/docs/authenticators.md +55 -43
  29. package/docs/authorization.md +128 -23
  30. package/docs/cors.md +29 -29
  31. package/docs/csrf.md +26 -26
  32. package/docs/external-jwt.md +4 -4
  33. package/docs/firewall.md +80 -29
  34. package/docs/headers.md +26 -26
  35. package/docs/oauth2.md +69 -62
  36. package/docs/obtenir-un-jeton.md +2 -2
  37. package/docs/tokens.md +55 -55
  38. package/docs/totp.md +73 -72
  39. package/docs/webauthn.md +81 -81
  40. package/docs/webhooks.md +43 -37
  41. package/package.json +16 -13
package/docs/firewall.md CHANGED
@@ -52,10 +52,10 @@ flowchart TD
52
52
  AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
53
53
  ```
54
54
 
55
- `Firewall.isSecure()` (`firewall.ts:705`) rattache la requête à une **zone** via
56
- `Firewall.matchPath()` (`firewall.ts:696`) ; `Firewall.handleSecurity()` (`firewall.ts:738`) décide.
55
+ `Firewall.isSecure()` (`firewall.ts:721`) rattache la requête à une **zone** via
56
+ `Firewall.matchPath()` (`firewall.ts:712`) ; `Firewall.handleSecurity()` (`firewall.ts:754`) décide.
57
57
  Les zones sont triées par **spécificité** dans `#build()` — `list.sort` par longueur de motif :
58
- le plus long gagne, pas le premier déclaré (`firewall.ts:191`).
58
+ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
59
59
 
60
60
  ## 📖 Lexique
61
61
 
@@ -74,7 +74,7 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:191`).
74
74
  ### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
75
75
 
76
76
  Le scaffold déclare deux zones dans `nodefony.config.ts` — c'est la forme canonique (un **objet par
77
- nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:902`) :
77
+ nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
78
78
 
79
79
  ```typescript
80
80
  // nodefony.config.ts (extrait généré par `nodefony create app`)
@@ -203,9 +203,9 @@ Point commun de sécurité : **message d'échec uniforme** (`"Invalid token"` /
203
203
  Credential = l'**identifiant** posé dans le blob de session (jamais un secret).
204
204
 
205
205
  - **N'ouvre jamais la session lui-même** : il exige une session reprise portant un user
206
- (`supports()`, `SessionAuthenticator.ts:43`). C'est `AuthFlow.login()` (BFF) qui ouvre et
206
+ (`supports()`, `SessionAuthenticator.ts:75`). C'est `AuthFlow.login()` (BFF) qui ouvre et
207
207
  régénère l'ID (anti-fixation).
208
- - **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts:70`) → rôles frais,
208
+ - **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts:96`) → rôles frais,
209
209
  révocation et verrouillage effectifs immédiatement.
210
210
  - **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login
211
211
  (pas de popup Basic).
@@ -226,7 +226,7 @@ Credential = `Authorization: Basic base64(identifiant:motdepasse)` — RFC 7617,
226
226
 
227
227
  ### `jwt` — Bearer JWT signé, durci RFC 8725
228
228
 
229
- Credential = `Authorization: Bearer <jws>` de structure compacte `a.b.c` (`JwtAuthenticator.ts:14`).
229
+ Credential = `Authorization: Bearer <jws>` de structure compacte `a.b.c` (`JwtAuthenticator.ts:21`).
230
230
  Réservé API service↔service / agents (le web reste sur la session). Access token **EdDSA** signé par
231
231
  le keystore du serveur. Défenses **dures**, prouvées en test (RFC 8725 JWT BCP), toutes dans
232
232
  `JwtAuthenticator.authenticate()` :
@@ -234,15 +234,15 @@ le keystore du serveur. Défenses **dures**, prouvées en test (RFC 8725 JWT BCP
234
234
  - **allowlist d'algorithmes** `["EdDSA"]` — l'algo n'est **jamais** choisi d'après l'en-tête du
235
235
  token ; `alg=none` rejeté (`JwtAuthenticator.ts:120`).
236
236
  - **clé par `kid` depuis le JWKS LOCAL** (`createLocalJWKSet`) — jamais `jku`/`jwk` de l'en-tête
237
- (anti-injection de clé / SSRF, `JwtAuthenticator.ts:155`).
237
+ (anti-injection de clé / SSRF, `JwtAuthenticator.ts:174`).
238
238
  - **`aud` + `iss` obligatoires** + `typ:"at+jwt"` (un refresh présenté comme access est rejeté) +
239
- exp/nbf (`JwtAuthenticator.ts:105-108`).
239
+ exp/nbf (`JwtAuthenticator.ts:119-124`).
240
240
  - **révocation** malgré l'auto-portage : denylist `jti` + `invalidBefore` par sujet
241
- (`JwtAuthenticator.ts:122-132`).
241
+ (`JwtAuthenticator.ts:138-148`).
242
242
  - **sujet revérifié** à réception (`loadUserByIdentifier(sub)`) : compte disparu/inactif/verrouillé
243
- = rejet (`JwtAuthenticator.ts:174-187`).
243
+ = rejet (`JwtAuthenticator.ts:190-201`).
244
244
 
245
- Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:162-172`).
245
+ Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:178-188`).
246
246
 
247
247
  > [!WARNING]
248
248
  > Un JWT est **auto-porté** : sans état serveur il n'est **pas** révocable. C'est la denylist
@@ -251,20 +251,20 @@ Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:162-172`).
251
251
 
252
252
  ### `apikey` — clé d'API opaque (PAT), révocable
253
253
 
254
- Credential = `Authorization: Bearer <prefix>_…` (`ApiKeyAuthenticator.ts:67`). Contrairement au JWT,
254
+ Credential = `Authorization: Bearer <prefix>_…` (`ApiKeyAuthenticator.ts:75-79`). Contrairement au JWT,
255
255
  c'est un **bearer opaque** : sa vérité vit côté serveur (`ITokenStore`) → **révocable immédiatement**.
256
256
 
257
257
  Défenses de `ApiKeyAuthenticator.authenticate()` :
258
258
 
259
259
  - **forme + CRC validés AVANT tout accès au store** — anti-DoS (`parseApiKey()`,
260
- `ApiKeyAuthenticator.ts:98`) ;
261
- - lookup par **hash sha256** : le secret n'existe nulle part au repos (`:105`) ;
260
+ `ApiKeyAuthenticator.ts:103`) ;
261
+ - lookup par **hash sha256** : le secret n'existe nulle part au repos (`ApiKeyAuthenticator.ts:109`) ;
262
262
  - révocation (`revokedAt`), expiration (`expiresAt`), **ban en masse** du porteur
263
- (`invalidBefore` vs `createdAt`, `:117-120`) ;
264
- - **sujet revérifié** à chaque requête — rôles frais (`:122`) ;
265
- - `lastUsedAt` écrit en **throttlé** — pas une écriture par requête (`:127-134`).
263
+ (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:111-124`) ;
264
+ - **sujet revérifié** à chaque requête — rôles frais (`ApiKeyAuthenticator.ts:127`) ;
265
+ - `lastUsedAt` écrit en **throttlé** — pas une écriture par requête (`ApiKeyAuthenticator.ts:136-143`).
266
266
 
267
- Le token porte `scopes`, `apiKeyId`, `tenantId` (`:138-140`). `jwt` et `apikey` **cohabitent** dans
267
+ Le token porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:147-149`). `jwt` et `apikey` **cohabitent** dans
268
268
  une zone : ils se discriminent par la forme (JWT = `a.b.c`, PAT = `prefix_…`).
269
269
 
270
270
  ### `anonymous` — accepter l'anonymat, explicitement
@@ -280,7 +280,7 @@ Sans lui, zone protégée + aucune preuve = 401.
280
280
 
281
281
  Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
282
282
  porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
283
- handshake des zones protégées `realtime` (`firewall.ts:289`).
283
+ handshake des zones protégées `realtime` (`firewall.ts:300`).
284
284
 
285
285
  - **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
286
286
  l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
@@ -320,7 +320,7 @@ laisser chercher pourquoi `context.session` est nul.
320
320
  ## ⚙️ Ordre et modes (`mode: "first"` vs `"all"`)
321
321
 
322
322
  La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
323
- (`firewall.ts:1112`). Le `mode` dit comment la parcourir. Trois situations concrètes :
323
+ (`firewall.ts:1128`). Le `mode` dit comment la parcourir. Trois situations concrètes :
324
324
 
325
325
  ### Situation 1 — humains ET machines sur la même API (`first`, le mode courant)
326
326
 
@@ -373,7 +373,7 @@ danger: {
373
373
 
374
374
  Le client doit présenter **les deux preuves** dans la même requête (cookie + `Authorization:
375
375
  Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte l'identité
376
- (`firewall.ts:936-939`) — ici la preuve mot de passe, la plus fraîche.
376
+ (`firewall.ts:1199`) — ici la preuve mot de passe, la plus fraîche.
377
377
 
378
378
  > [!TIP]
379
379
  > Un nom d'authenticator inconnu en config **fait échouer le boot** —
@@ -431,6 +431,57 @@ muets (volume, pas un signal). Les voters sont instanciés **une fois au boot**
431
431
  ou futur (`mtls`, `agent`…) — est traité comme scopable, donc **bridé par défaut**.
432
432
  - En une ligne : rôles = qui tu es ; scopes = ce qu'une **clé** a le droit de faire.
433
433
 
434
+ ### `roles` — le rôle exigé PAR DÉFAUT dans une zone
435
+
436
+ Une zone qui ne liste que des authenticators exige une **identité**, rien de plus : toute route
437
+ qu'elle couvre est alors ouverte à **n'importe quel compte connecté**. C'est acceptable pour une
438
+ application ; c'est un défaut ouvert pour une surface d'administration, où l'oubli d'une garde ne
439
+ devrait jamais se solder par une fuite.
440
+
441
+ `roles` renverse ce défaut. Il se déclare **une fois sur la zone**, au lieu d'être recopié sur
442
+ chaque route :
443
+
444
+ ```typescript
445
+ use("@nodefony/security", {
446
+ areas: {
447
+ admin: {
448
+ pattern: "^/back-office(/|$)",
449
+ authenticators: ["session"],
450
+ roles: ["ROLE_ADMIN"], // ← exigé par défaut dans toute la zone
451
+ },
452
+ },
453
+ });
454
+ ```
455
+
456
+ Les rôles listés sont en **OU** — un seul suffit — et la **hiérarchie s'applique** : un
457
+ `ROLE_NODEFONY_ADMIN` satisfait `ROLE_ADMIN` si la hiérarchie le dit. Un refus est un **403**
458
+ (authentifié, mais pas autorisé), jamais un 401. Omettre la clé laisse le comportement historique
459
+ inchangé : la zone n'exige qu'une identité.
460
+
461
+ #### Ce que le rôle de zone n'écrase PAS
462
+
463
+ C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
464
+ explicite du code — jamais un contournement :
465
+
466
+ | Ce qui décide à la place | Pourquoi |
467
+ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
468
+ | Une **garde d'action** (`@IsGranted`, `@RequireScope`) | L'action a déjà dit ce qu'elle exige. Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
469
+ | Une route **`selfGuarded`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
470
+ | Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
471
+ | L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
472
+
473
+ Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
474
+ une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
475
+ L'invariant tenu est donc `api.request {path}` ≤ `GET {path}` — on n'obtient jamais par la socket ce
476
+ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rôle d'administration ne
477
+ puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
478
+ destinataire.
479
+
480
+ > ⚠️ **`selfGuarded` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
481
+ > qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
482
+ > que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
483
+ > Dans le doute, une garde d'action explicite est toujours préférable.
484
+
434
485
  ### La hiérarchie de rôles
435
486
 
436
487
  `RoleHierarchyWalker` (`src/RoleHierarchyWalker.ts`) : `ROLE_ADMIN` hérite `ROLE_USER`, etc.
@@ -462,11 +513,11 @@ scopes, métier), un même jury, combinables.
462
513
 
463
514
  ## 🔌 HTTP et WebSocket — le même firewall
464
515
 
465
- `Firewall.#wireRealtime()` (`firewall.ts:268`) câble, pour toute zone protégée `realtime !== false`
466
- (opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:289`)
467
- **et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:337`). Même résolution de zone que HTTP.
516
+ `Firewall.#wireRealtime()` (`firewall.ts:279`) câble, pour toute zone protégée `realtime !== false`
517
+ (opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:300`)
518
+ **et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:348`). Même résolution de zone que HTTP.
468
519
  Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
469
- `firewall.ts:1191`) : le **code de fermeture** suffit.
520
+ `firewall.ts:1207`) : le **code de fermeture** suffit.
470
521
 
471
522
  ## 🛡️ En-têtes de sécurité, CSRF, CORS
472
523
 
@@ -474,7 +525,7 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
474
525
  au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1045`) :
475
526
  alloué seulement si une directive en a besoin.
476
527
  - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
477
- (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:764`), puis double-submit `x-csrf-token` ≡
528
+ (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:971`), puis double-submit `x-csrf-token` ≡
478
529
  cookie + HMAC (`firewall.ts:778`).
479
530
  - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
480
531
 
@@ -486,10 +537,10 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
486
537
  | Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
487
538
  | JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
488
539
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
489
- | Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:764`) |
540
+ | Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:777-780`) |
490
541
  | Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
491
542
  | CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:948`) |
492
- | Modèle | Zero Trust | `firewall.ts:611` (aucune preuve → 401) |
543
+ | Modèle | Zero Trust | `firewall.ts:818-833` (aucune preuve → 401) |
493
544
 
494
545
  ## ⚡ Performance & mémoire
495
546
 
package/docs/headers.md CHANGED
@@ -122,8 +122,8 @@ fait l'inverse : **tout ce qui est constant est calculé une fois au démarrage*
122
122
  statique) et la **gèle** avec `Object.freeze` (`securityHeaders.ts:77`). Par requête, le firewall
123
123
  se contente de la parcourir et de la poser : zéro concaténation, zéro objet créé.
124
124
  - Côté transport, même principe : `HttpKernel.computeSecurityHeaderCaches()`
125
- (`http-kernel.ts:330`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
- boot ; `onHttpRequest` (`http-kernel.ts:819`) ne fait plus que trois `setHeader`.
125
+ (`http-kernel.ts:335`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
+ boot ; `onHttpRequest` (`http-kernel.ts:949`) ne fait plus que trois `setHeader`.
127
127
  - Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
128
128
  (`Context.ts:253`) ne génère ses 128 bits (`randomBytes(16)` en base64) qu'à la première lecture,
129
129
  puis mémoïse. Une réponse qui n'a aucun script inline à signer ne paie aucun appel crypto.
@@ -173,7 +173,7 @@ export default defineConfig(() => ({
173
173
  ```
174
174
 
175
175
  Les clés sont **typées et auto-complétées** : le module augmente le registre `NodefonyModuleConfig`
176
- du core (`index.ts:28`), donc `use("@nodefony/security", …)` propose les clés **et** les valeurs
176
+ du core (`index.ts:33`), donc `use("@nodefony/security", …)` propose les clés **et** les valeurs
177
177
  d'enum (`referrerPolicy`, `coop`, `corp`…). Une valeur hors enum casse le boot, pas la production.
178
178
 
179
179
  ### 2. Ce qu'on observe
@@ -255,7 +255,7 @@ Choisir en cinq secondes — puis le détail dans les cartes.
255
255
  **La menace** : n'importe quelle entrée non échappée (commentaire, nom d'utilisateur, paramètre
256
256
  réfléchi) devient un vecteur d'exécution de code. Le CSP est le filet quand l'échappement a raté.
257
257
 
258
- **Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts:218`) : seul `script-src`
258
+ **Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts:238`) : seul `script-src`
259
259
  est **strict** — `'self'` plus le nonce de la requête, ce qui est la vraie défense XSS. Le reste
260
260
  couvre les besoins réels d'une app moderne (CSS-in-JS via `style-src 'unsafe-inline'`, images
261
261
  `data:`/`blob:`, workers, fetch/WS same-origin) et ajoute les durcissements gratuits :
@@ -275,8 +275,8 @@ privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les ancie
275
275
  victime est capté par ton interface.
276
276
 
277
277
  Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions`
278
- (`http-kernel.ts:270`) — et configuré côté `@nodefony/http` avec `frameOptions`
279
- (`http/nodefony/config/config.ts:121`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
278
+ (`http-kernel.ts:267`) — et configuré côté `@nodefony/http` avec `frameOptions`
279
+ (`http/nodefony/config/config.ts:136`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
280
280
  s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
281
281
  pour un HTML statique servi directement depuis `public/`.
282
282
 
@@ -297,15 +297,15 @@ vient précisément des fichiers servis hors pipeline applicatif — un banc liv
297
297
  installer un intercepteur.
298
298
 
299
299
  La chaîne est assemblée au boot par `HttpKernel.computeSecurityHeaderCaches()`
300
- (`http-kernel.ts:330`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
300
+ (`http-kernel.ts:335`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
301
301
 
302
302
  Elle n'est posée que **sur une réponse HTTPS ou HTTP/2** — le cache `secHsts` est conditionné au type
303
- de serveur (`http-kernel.ts:965`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
303
+ de serveur (`http-kernel.ts:969`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
304
304
  soit ignoré : l'émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines
305
305
  inclus.
306
306
 
307
307
  > [!CAUTION]
308
- > `preload: true` (`http/nodefony/config/config.ts:92`) inscrit ton domaine dans la liste
308
+ > `preload: true` (`http/nodefony/config/config.ts:107`) inscrit ton domaine dans la liste
309
309
  > pré-chargée des navigateurs. C'est un **engagement quasi irréversible** : tout sous-domaine
310
310
  > incapable de servir en HTTPS devient inaccessible, et la sortie de liste prend des mois. À ne
311
311
  > jamais activer « pour voir ».
@@ -315,7 +315,7 @@ inclus.
315
315
  **La menace** : la fuite d'URL. Chemins parlants, identifiants de session dans une query, jetons de
316
316
  réinitialisation — tout part chez le site suivant via le `Referer`.
317
317
 
318
- Défaut Nodefony : `no-referrer` (`security/nodefony/config/config.ts:263`), la valeur la plus stricte. La valeur est un
318
+ Défaut Nodefony : `no-referrer` (`security/nodefony/config/config.ts:278`), la valeur la plus stricte. La valeur est un
319
319
  **enum W3C fermé** — huit valeurs validées au boot, donc pas de faute de frappe qui passerait en
320
320
  silence (l'écriture libre `no-refferer` casserait la protection sans prévenir).
321
321
 
@@ -379,7 +379,7 @@ config security est la première source de confusion sur ce sujet.
379
379
 
380
380
  ### Couche applicative — `use("@nodefony/security", { headers })`
381
381
 
382
- Dérivé du schéma Zod `headersSchema` (`config.ts:194`).
382
+ Dérivé du schéma Zod `headersSchema` (`config.ts:206`).
383
383
 
384
384
  <!-- prettier-ignore -->
385
385
  | Option | Type | Défaut | Effet |
@@ -396,8 +396,8 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:194`).
396
396
 
397
397
  ### Socle transport — `use("@nodefony/http", { securityHeaders })`
398
398
 
399
- Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:108`). Ces trois réglages sont
400
- **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:290`)
399
+ Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:123`). Ces trois réglages sont
400
+ **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:385`)
401
401
  recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
402
402
 
403
403
  | Option | Type | Défaut | Effet |
@@ -411,10 +411,10 @@ recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
411
411
 
412
412
  > [!WARNING]
413
413
  > Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
414
- > (`config.ts:200`, `config.ts:227`, `config.ts:233`) mais **ne pilotent rien** : la couche
414
+ > (`config.ts:212`, `config.ts:223`, `config.ts:247`, `config.ts:257`) mais **ne pilotent rien** : la couche
415
415
  > applicative ne les lit pas (`securityHeaders.ts:6`), elles ne servent qu'à l'introspection
416
416
  > affichée dans Studio. Pour changer réellement `X-Frame-Options`, c'est `securityHeaders.frameOptions`
417
- > **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:254`) : Nodefony n'émet aucun
417
+ > **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:282`) : Nodefony n'émet aucun
418
418
  > `X-Powered-By`, l'option est un no-op documenté.
419
419
 
420
420
  ## 🏗️ Le CSP en détail — deux régimes, trois façons de l'étendre
@@ -439,7 +439,7 @@ Le chemin complet, sans surprise :
439
439
 
440
440
  1. **Au boot**, la chaîne CSP est **pré-découpée** autour de `{{nonce}}` (`securityHeaders.ts:58`).
441
441
  Aucun parsing ni regex n'aura lieu pendant une requête.
442
- 2. **Par requête**, `Firewall.applySecurityHeaders()` (`firewall.ts:835`) lit `context.cspNonce` —
442
+ 2. **Par requête**, `Firewall.applySecurityHeaders()` (`firewall.ts:1045`) lit `context.cspNonce` —
443
443
  ce qui **génère** le jeton à cet instant (`Context.ts:253`) — puis appelle
444
444
  `SecurityHeaders.cspFor()` (`securityHeaders.ts:100`) : un seul `join`.
445
445
  3. **Dans la vue**, le contrôleur relit `context.cspNonce`, qui est **mémoïsé** : l'en-tête et le
@@ -452,7 +452,7 @@ rester imprévisible, jamais pilotable par le client — contrairement au `reque
452
452
  une corrélation entrante.
453
453
 
454
454
  **Placement dans le pipeline** : `applySecurityHeaders` est appelé **après le resolve** et **avant**
455
- le repli statique et le `writeHead` (`http-kernel.ts:1372`). Cet ordre n'est pas cosmétique : il
455
+ le repli statique et le `writeHead` (`http-kernel.ts:1386`). Cet ordre n'est pas cosmétique : il
456
456
  faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
457
457
  être avant l'écriture des en-têtes pour pouvoir en poser.
458
458
 
@@ -476,7 +476,7 @@ directive absente est ajoutée en fin. La fonction est **pure et déterministe**
476
476
  les tests fiables.
477
477
 
478
478
  **Coût** : le merge d'un module est payé **une fois**, au (dés)enregistrement
479
- (`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1085`), jamais par requête. Le merge d'une route
479
+ (`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1101`), jamais par requête. Le merge d'une route
480
480
  `@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
481
481
  `securityHeaders.ts:115`) ; le cas courant reste le simple `join`.
482
482
 
@@ -502,15 +502,15 @@ Trois propriétés à retenir :
502
502
 
503
503
  - **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
504
504
  `registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
505
- - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1074`) retire le fragment et
505
+ - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1090`) retire le fragment et
506
506
  reconstruit le CSP de base. C'est ce que fait `@nodefony/frontend` à l'arrêt du serveur Vite.
507
507
  - **Idempotent** : la reconstruction repart **toujours** du `headers.csp` d'origine
508
- (`firewall.ts:1088`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
508
+ (`firewall.ts:1104`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
509
509
  enregistrements.
510
510
 
511
511
  L'exemple de référence vit dans le framework : en développement, `@nodefony/frontend` déclare les
512
512
  origines du serveur Vite et `'unsafe-eval'` (exigé par le Fast Refresh de React) via
513
- `FrontendService.#viteCspFragment()` (`FrontendService.ts:1012`) — ce qui explique qu'un CSP observé
513
+ `FrontendService.#viteCspFragment()` (`FrontendService.ts:1034`) — ce qui explique qu'un CSP observé
514
514
  en dev soit plus large qu'en production, où ce fragment n'existe pas.
515
515
 
516
516
  ## 📜 Normes appliquées
@@ -519,12 +519,12 @@ en dev soit plus large qu'en production, où ce fragment n'existe pas.
519
519
  | ------------------------------------ | -------------------------------- | ------------------------------------------------------------ |
520
520
  | Politique de sécurité du contenu | W3C CSP Level 3 | `mergeCspFragments()` (`csp.ts:56`), directive non dupliquée |
521
521
  | Nonce CSP (unicité, imprévisibilité) | W3C CSP Level 3 §6.7.4 | `Context.cspNonce` — 128 bits CSPRNG (`Context.ts:253`) |
522
- | HSTS | RFC 6797 | posé sur TLS uniquement (`http-kernel.ts:839`) |
522
+ | HSTS | RFC 6797 | posé sur TLS uniquement (`http-kernel.ts:969`) |
523
523
  | Champ structuré booléen | RFC 8941 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
524
- | Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:239`) |
524
+ | Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:267`) |
525
525
  | Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
526
526
  | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:963`) |
527
- | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:330`) |
527
+ | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:335`) |
528
528
 
529
529
  ## ⚡ Performance & mémoire
530
530
 
@@ -538,7 +538,7 @@ Le coût est concentré au boot, par construction :
538
538
  court-circuite entièrement ce chemin sinon. La paresse de `Context.cspNonce` (`Context.ts:253`)
539
539
  protège en plus les chemins internes qui n'atteignent jamais le firewall.
540
540
  - **Merge CSP** : jamais dans le chemin chaud. Le fragment d'un module est fusionné à
541
- l'enregistrement (`firewall.ts:1067`) ; celui d'une route ne coûte que sur les routes `@Csp`.
541
+ l'enregistrement (`firewall.ts:1086`) ; celui d'une route ne coûte que sur les routes `@Csp`.
542
542
  - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:958-966`), avec
543
543
  un test `!== null` qui annule le coût des en-têtes désactivés.
544
544
 
@@ -570,7 +570,7 @@ porte sa description, ce qui en fait la référence toujours à jour des défaut
570
570
  | CSP contenant `'nonce-{{nonce}}'` littéral | `cspNonces` désactivé, placeholder laissé | Nodefony purge le résiduel (`securityHeaders.ts:63`) ; retirer le placeholder |
571
571
  | Une extension `script-src` de module sans effet | Second en-tête / directive dupliquée (ignorée, W3C CSP3 §3) | Déclarer un fragment via `registerCspOrigins()`, jamais un `setHeader` |
572
572
  | Assets tiers cassés après activation de l'isolation | `coep: "require-corp"` exige CORP/CORS sur **chaque** ressource | Retirer `coep` ou faire annoncer les ressources |
573
- | HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts:839`) | Comportement conforme RFC 6797 — vérifier sur le port HTTPS |
573
+ | HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts:969`) | Comportement conforme RFC 6797 — vérifier sur le port HTTPS |
574
574
  | Le CSP est plus large en dev qu'en prod | `@nodefony/frontend` déclare les origines Vite (`FrontendService.ts:695`) | Attendu : le fragment n'existe pas en production |
575
575
  | Aucun en-tête applicatif | Module security absent ou `headers.enabled: false` | Le socle transport reste actif ; réactiver security pour CSP/Referrer/isolation |
576
576
  | Domaine injoignable après activation de `preload` | Inscription à la liste pré-chargée, sortie très lente | Ne jamais activer sans plan HTTPS sur **tous** les sous-domaines |