@nodefony/security 10.0.0-beta.5 → 10.0.0-beta.7

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 (104) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +63 -61
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +30 -11
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  8. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  9. package/dist/nodefony/errors/SsrfError.js +9 -1
  10. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  11. package/dist/nodefony/errors/index.js +7 -5
  12. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  13. package/dist/nodefony/keycloak/scaffold.js +31 -14
  14. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  15. package/dist/nodefony/service/accessTokenVerifier.js +6 -6
  16. package/dist/nodefony/service/apiKeys.js +8 -4
  17. package/dist/nodefony/service/auditService.js +48 -3
  18. package/dist/nodefony/service/authFlow.js +9 -6
  19. package/dist/nodefony/service/authorization.js +1 -1
  20. package/dist/nodefony/service/firewall.js +281 -44
  21. package/dist/nodefony/service/keycloak.js +1 -1
  22. package/dist/nodefony/service/oauth2.js +8 -4
  23. package/dist/nodefony/service/securityHeaders.js +340 -47
  24. package/dist/nodefony/service/tokenService.js +16 -11
  25. package/dist/nodefony/service/totp.js +10 -5
  26. package/dist/nodefony/service/webAuthn.js +46 -7
  27. package/dist/nodefony/service/webhooks.js +11 -4
  28. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  29. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  30. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  31. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  32. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  33. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
  34. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
  35. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
  36. package/dist/nodefony/src/csp.js +265 -10
  37. package/dist/nodefony/src/cspReports.js +307 -0
  38. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  39. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  40. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  41. package/dist/nodefony/src/oauth/metadata.js +1 -1
  42. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  43. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  44. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  45. package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
  46. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  47. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  48. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  49. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  50. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  51. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  52. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  53. package/dist/nodefony/testing/index.js +26 -36
  54. package/dist/types/index.d.ts +5 -1
  55. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  56. package/dist/types/nodefony/config/config.d.ts +8 -0
  57. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  58. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  59. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  60. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  61. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  62. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  63. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  64. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  65. package/dist/types/nodefony/errors/index.d.ts +3 -0
  66. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  67. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  68. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  69. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  70. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  71. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  72. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  73. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  74. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  75. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  76. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  77. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  78. package/dist/types/nodefony/src/csp.d.ts +142 -0
  79. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  80. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  81. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  82. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  83. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  84. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
  85. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  86. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  87. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  88. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  89. package/dist/types/nodefony/testing/index.d.ts +6 -0
  90. package/docs/api-keys.md +3 -1
  91. package/docs/audit.md +112 -28
  92. package/docs/authenticators.md +6 -4
  93. package/docs/csrf.md +3 -2
  94. package/docs/external-jwt.md +5 -1
  95. package/docs/firewall.md +3 -2
  96. package/docs/headers.md +427 -71
  97. package/docs/keycloak.md +4 -4
  98. package/docs/login-page.md +4 -3
  99. package/docs/obtenir-un-jeton.md +17 -10
  100. package/docs/tokens.md +1 -1
  101. package/docs/totp.md +2 -2
  102. package/docs/webauthn.md +2 -2
  103. package/docs/webhooks.md +63 -18
  104. package/package.json +17 -17
package/docs/headers.md CHANGED
@@ -51,18 +51,16 @@ contrôleur, absent sur un fichier statique ou une page d'erreur 404, c'est-à-d
51
51
  un attaquant dépose son contenu.
52
52
 
53
53
  D'où le découpage : ce qui doit couvrir **tout ce qui sort du process** est posé au plus tôt ; ce
54
- qui dépend de la **réponse applicative** (le CSP, qui doit connaître le nonce et la route) est posé
55
- après le routage.
54
+ qui dépend de la **réponse applicative** (le CSP, qui doit connaître le nonce, la route et le
55
+ **type** de la réponse) est posé au moment où la réponse écrit ses en-têtes : le pare-feu, armé au
56
+ routage, choisit alors sa politique sur le `Content-Type`.
56
57
 
57
58
  ```mermaid
58
59
  flowchart TD
59
- RAW["Requête entrante"] --> T["onHttpRequest — socle TRANSPORT (@nodefony/http)<br/>X-Content-Type-Options · X-Frame-Options · HSTS (TLS seulement)"]
60
- T --> COV["couvre AUSSI : fichiers statiques, 404/500,<br/>et un serveur SANS module security"]
61
- T --> PIPE["pipeline : routing / resolve"]
62
- PIPE --> A["applySecurityHeaders — couche APPLICATIVE (@nodefony/security)<br/>CSP · Referrer-Policy · COOP/COEP/CORP · Origin-Agent-Cluster · Permissions-Policy"]
63
- A --> CSPQ{"le CSP porte-t-il<br/>un nonce ?"}
64
- CSPQ -->|non| STAT["CSP figé au boot — 0 allocation par requête"]
65
- CSPQ -->|oui| NONCE["cspFor(nonce) — 1 join par requête<br/>nonce généré paresseusement sur le Context"]
60
+ T["transport<br/>(toute réponse)"] --> W{"writeHead :<br/>quel type ?"}
61
+ W -->|document| DOC["CSP complète + nonce<br/>tous les en-têtes"]
62
+ W -->|JavaScript| JS["CSP sans nonce<br/>Referrer · COEP · CORP"]
63
+ W -->|autre, 204| OTH["default-src 'none'<br/>Referrer · CORP"]
66
64
  ```
67
65
 
68
66
  **Une seule source par en-tête** : `@nodefony/security` ne ré-émet **jamais** les trois en-têtes
@@ -72,20 +70,23 @@ contradictoires sur la même réponse.
72
70
 
73
71
  ## 📖 Lexique
74
72
 
75
- | Terme | Développé — et ce que ça veut dire |
76
- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
77
- | CSP | _Content-Security-Policy_ : liste blanche des sources autorisées (scripts, styles, images…). Première barrière anti-XSS. |
78
- | XSS | _Cross-Site Scripting_ : un attaquant fait exécuter **son** JavaScript dans la page de ta victime, avec ses cookies. |
79
- | Nonce | _number used once_ : jeton aléatoire régénéré à **chaque requête**, qui autorise un `<script>` inline **précis** et lui seul. |
80
- | Clickjacking | Ton site est chargé en `<iframe>` transparente au-dessus d'un piège : la victime croit cliquer ailleurs, elle clique chez toi. |
81
- | MIME sniffing | Le navigateur ignore le `Content-Type` et **devine** le type d'un fichier — un `.txt` uploadé peut finir exécuté comme script. |
82
- | HSTS | _HTTP Strict-Transport-Security_ (RFC 6797) : le navigateur mémorise « ce domaine, c'est HTTPS uniquement ». |
83
- | Referrer | En-tête que le navigateur envoie au site suivant pour dire d'où l'on vient — donc une **fuite d'URL** potentielle. |
84
- | COOP / COEP / CORP | _Cross-Origin **Opener** / **Embedder** / **Resource** Policy_ : trois verrous d'isolation entre origines (Spectre, vol d'assets). |
85
- | OAC | _Origin-Agent-Cluster_ : demande au navigateur d'isoler l'origine dans son propre processus/heap. |
86
- | Permissions-Policy | Coupe l'accès aux API sensibles du navigateur (caméra, micro, géolocalisation) pour la page **et ses iframes**. |
87
- | Fragment CSP | Directives additionnelles déclarées par un module (`directive → sources`), fusionnées dans le CSP de base. |
88
- | Downgrade | Un attaquant réseau force la connexion en HTTP clair pour la lire ou la modifier. |
73
+ | Terme | Développé — et ce que ça veut dire |
74
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | CSP | _Content-Security-Policy_ : liste blanche des sources autorisées (scripts, styles, images…). Première barrière anti-XSS. |
76
+ | XSS | _Cross-Site Scripting_ : un attaquant fait exécuter **son** JavaScript dans la page de ta victime, avec ses cookies. |
77
+ | Nonce | _number used once_ : jeton aléatoire régénéré à **chaque requête**, qui autorise un `<script>` inline **précis** et lui seul. |
78
+ | Clickjacking | Ton site est chargé en `<iframe>` transparente au-dessus d'un piège : la victime croit cliquer ailleurs, elle clique chez toi. |
79
+ | MIME sniffing | Le navigateur ignore le `Content-Type` et **devine** le type d'un fichier — un `.txt` uploadé peut finir exécuté comme script. |
80
+ | HSTS | _HTTP Strict-Transport-Security_ (RFC 6797) : le navigateur mémorise « ce domaine, c'est HTTPS uniquement ». |
81
+ | Referrer | En-tête que le navigateur envoie au site suivant pour dire d'où l'on vient — donc une **fuite d'URL** potentielle. |
82
+ | COOP | _Cross-Origin-Opener-Policy_ : coupe le lien entre ta **page** et les fenêtres d'une autre origine qu'elle ouvre ou qui l'ont ouverte (`window.opener`). Lu sur un document seulement. |
83
+ | COEP | _Cross-Origin-Embedder-Policy_ : ta **page** (ou ton worker) refuse toute ressource tierce qui n'a pas dit « tu peux m'inclure ». Lu sur un document ou un script de worker. |
84
+ | CORP | _Cross-Origin-Resource-Policy_ : posé sur une **ressource** (image, script, JSON), dit quelles origines ont le droit de l'inclure. C'est la réponse que COEP attend. |
85
+ | Isolation cross-origin | COOP `same-origin` + COEP `require-corp` : la page est seule dans son processus (`crossOriginIsolated`), condition de `SharedArrayBuffer` et des horloges fines. |
86
+ | OAC | _Origin-Agent-Cluster_ : demande au navigateur d'isoler l'origine dans son propre processus/heap. |
87
+ | Permissions-Policy | Coupe l'accès aux API sensibles du navigateur (caméra, micro, géolocalisation) pour la page **et ses iframes**. |
88
+ | Fragment CSP | Directives additionnelles déclarées par un module (`directive → sources`), fusionnées dans le CSP de base. |
89
+ | Downgrade | Un attaquant réseau force la connexion en HTTP clair pour la lire ou la modifier. |
89
90
 
90
91
  ## Qu'est-ce que c'est ? — un panneau d'instructions collé sur chaque réponse
91
92
 
@@ -156,8 +157,9 @@ export default defineConfig(() => ({
156
157
  headers: {
157
158
  // `{{nonce}}` est substitué par un jeton FRAIS à chaque requête (cf plus bas).
158
159
  csp:
159
- "default-src 'self'; script-src 'self' 'nonce-{{nonce}}'; " +
160
- "style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
160
+ "default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; " +
161
+ "style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'; " +
162
+ "img-src 'self' data:; " +
161
163
  "object-src 'none'; base-uri 'self'; form-action 'self'",
162
164
  cspNonces: true,
163
165
  // Ne fuiter que l'origine, et rien vers un site en clair.
@@ -179,23 +181,32 @@ d'enum (`referrerPolicy`, `coop`, `corp`…). Une valeur hors enum casse le boot
179
181
  ### 2. Ce qu'on observe
180
182
 
181
183
  ```bash
182
- # Route applicative : socle transport + couche applicative, sur la MÊME réponse.
183
- curl -sI http://localhost:5151/nodefony/test/index
184
+ # Une PAGE : socle transport + couche applicative complète, sur la MÊME réponse.
185
+ curl -sI http://localhost:5151/nodefony/test/html
184
186
 
185
187
  # HTTP/1.1 200 OK
186
188
  # X-Content-Type-Options: nosniff ← transport (@nodefony/http)
187
189
  # X-Frame-Options: DENY ← transport (@nodefony/http)
188
- # Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-vZ9…'; …
190
+ # Content-Type: text/html; charset=utf-8
191
+ # Content-Security-Policy: default-src 'self'; script-src 'nonce-vZ9…' 'strict-dynamic' 'self'; …
189
192
  # Referrer-Policy: strict-origin-when-cross-origin
190
193
  # Cross-Origin-Opener-Policy: same-origin
191
194
  # Cross-Origin-Resource-Policy: same-origin
192
195
  # Permissions-Policy: camera=(), microphone=(), geolocation=()
196
+
197
+ # Une réponse d'API : la politique courte, et seulement ce qu'un JSON fait lire.
198
+ curl -sI http://localhost:5151/nodefony/test/index
199
+
200
+ # Content-Type: application/json
201
+ # Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
202
+ # Referrer-Policy: strict-origin-when-cross-origin
203
+ # Cross-Origin-Resource-Policy: same-origin
193
204
  ```
194
205
 
195
206
  ```bash
196
207
  # Le nonce change à CHAQUE requête — deux appels, deux jetons.
197
- curl -sI http://localhost:5151/nodefony/test/index | grep -o "nonce-[^']*"
198
- curl -sI http://localhost:5151/nodefony/test/index | grep -o "nonce-[^']*"
208
+ curl -sI http://localhost:5151/nodefony/test/html | grep -o "nonce-[^']*"
209
+ curl -sI http://localhost:5151/nodefony/test/html | grep -o "nonce-[^']*"
199
210
  # nonce-2Qk1r0h8… (≠)
200
211
  # nonce-Xa7pLd3f…
201
212
 
@@ -255,19 +266,222 @@ Choisir en cinq secondes — puis le détail dans les cartes.
255
266
  **La menace** : n'importe quelle entrée non échappée (commentaire, nom d'utilisateur, paramètre
256
267
  réfléchi) devient un vecteur d'exécution de code. Le CSP est le filet quand l'échappement a raté.
257
268
 
258
- **Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts#headersSchema`) : seul `script-src`
259
- est **strict** — `'self'` plus le nonce de la requête, ce qui est la vraie défense XSS. Le reste
260
- couvre les besoins réels d'une app moderne (CSS-in-JS via `style-src 'unsafe-inline'`, images
261
- `data:`/`blob:`, workers, fetch/WS same-origin) et ajoute les durcissements gratuits :
262
- `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`.
269
+ **Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts#headersSchema`) : `script-src`
270
+ est **strict** — `'nonce-{{nonce}}' 'strict-dynamic' 'self'`, ce qui est la vraie défense XSS ;
271
+ `style-src` exige le même nonce des `<style>` (voir plus bas). Le reste couvre les besoins réels
272
+ d'une app moderne (images `data:`/`blob:`, workers, fetch/WS same-origin) et ajoute les durcissements
273
+ gratuits :
274
+ `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`, `upgrade-insecure-requests`.
275
+
276
+ **Aucun hôte tiers dans le défaut.** `img-src` vaut `'self' data: blob:` : les photos de profil
277
+ d'un fournisseur de connexion ne sont autorisées que s'il est **configuré** — chaque fournisseur
278
+ porte l'hôte de ses avatars dans son enregistrement (`oauthProviderRegistry.ts#registerOAuthProvider`,
279
+ option `imageSources`), et la connexion sociale le déclare au démarrage. Studio autorise
280
+ `https://www.gravatar.com` sur **ses seules pages** (`@Csp` de `StudioController.ts#StudioController`).
281
+ Une application qui affiche d'autres images tierces les ajoute par `headers.cspSources` (ci-dessous),
282
+ sans réécrire la politique.
283
+
284
+ **`'strict-dynamic'` : seul le nonce autorise une balise.** Un navigateur CSP3 ignore alors
285
+ `'self'` et les hôtes (`'self'` ne reste que pour les navigateurs CSP2). Sans lui, tout fichier
286
+ JavaScript posé sur ton origine — un téléversement, une réponse mal typée — s'exécutait dès qu'une
287
+ injection écrivait `<script src="/uploads/x.js">`. En retour, un `<script>` sans nonce — en ligne
288
+ ou `src`, même servi par ton origine — ne s'exécute plus. Dans une vue rendue par `renderView`, le
289
+ framework le signe pour toi (ci-dessous) ; dans du HTML écrit à la main, écris
290
+ `<script nonce="${this.context?.cspNonce}" src="…">`. Ce que ce script charge ensuite (un
291
+ `import()`, un script qu'il crée) hérite de sa confiance : un chargeur tiers signé (paiement,
292
+ carte) charge ses propres scripts sans que tu déclares leurs hôtes.
293
+
294
+ Deux cas où il n'y a pas de nonce, et où `'strict-dynamic'` part avec lui pour ne pas bloquer
295
+ toute la page :
296
+
297
+ - **une page servie telle quelle** — un `.html` de `public/`, une réponse relayée par le proxy
298
+ inverse : aucun gabarit ne l'a écrite. Elle reçoit la politique sans nonce ni `'strict-dynamic'`,
299
+ et `'self'` décide, comme avant. C'est sans perte : un fichier sert les mêmes octets à tous, il
300
+ n'a pas de point d'injection côté serveur ;
301
+ - **`cspNonces: false`** : le nonce est retiré de la politique, `'strict-dynamic'` aussi — sauf si
302
+ la directive garde un hash (`'sha256-…'`), qui continue d'autoriser ses scripts.
303
+
304
+ Une politique que tu écris toi-même sans `{{nonce}}` n'est jamais retouchée.
305
+
306
+ **Styles : le même nonce, et rien à faire dans une application générée.** Le défaut écrit
307
+ `style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'`. En présence
308
+ d'un nonce, le navigateur ignore `'unsafe-inline'` : un `<style>` injecté dans ta page — qui peut
309
+ exfiltrer un jeton par des sélecteurs d'attribut, sans une ligne de JavaScript — est bloqué. Un
310
+ attribut `style="…"` reste permis : il ne porte pas de sélecteur. Ce qui est déjà en place :
311
+
312
+ - les feuilles en `<link rel="stylesheet">` de ton origine passent par `'self'` ; les styles que
313
+ React ou Vue écrivent par le CSSOM (`element.style`) ne sont pas gouvernés par la politique ;
314
+ - chaque document rendu par le framework annonce son nonce par
315
+ `<meta property="csp-nonce" nonce="…">` (`cspNonce.ts#cspNonceMetaTag`). Le client de Vite
316
+ y signe les styles du rechargement à chaud ; l'entrée Angular générée le donne à `CSP_NONCE` ;
317
+ - un `<style>` que **ton** code crée à l'exécution : `adoptStyles(css)` (`nodefony/client`) le pose
318
+ en feuille construite, que la politique ne gouverne pas ; une bibliothèque qui veut un nonce le
319
+ lit par `readCspNonce()` (Mantine : `getStyleNonce`, Emotion : `createCache({ nonce })`) ;
320
+ - dans une vue Eta rendue par `renderView` : **rien à faire** — le framework signe chaque
321
+ `<script>` et `<style>` écrit dans le gabarit (`viewNonce.ts#signTemplateTags`), jamais ce
322
+ que les données y insèrent ; le nonce reste lisible ailleurs par `<%= it.cspNonce %>`. Un
323
+ `<script>` dont un attribut vient des données (`src="<%= it.url %>"`) n'est pas signé : le
324
+ signer exécuterait l'URL qu'elles apportent. Si tu la tiens pour sûre, signe-le toi-même
325
+ (`<script nonce="<%= it.cspNonce %>" src="<%= it.url %>">`).
326
+
327
+ **Une bibliothèque CSS-in-JS**, dans cet ordre de préférence :
328
+
329
+ 1. **« zéro runtime »** (vanilla-extract, Linaria, Panda CSS…) : elle écrit des feuilles au build,
330
+ servies en `<link>` par `'self'` — rien à signer, rien à régler ;
331
+ 2. **qui accepte un nonce** : donne-lui `readCspNonce()` (`nodefony/client`) — Emotion :
332
+ `createCache({ key: "css", nonce: readCspNonce() })`, Mantine : `getStyleNonce`, goober :
333
+ `window.__nonce__ = readCspNonce()` avant son premier appel. Cherche ce crochet avant de
334
+ conclure qu'il n'existe pas : il porte souvent un nom de variable globale ;
335
+ 3. **sans option de nonce** : elle crée un `<style>` conteneur que la politique refuse, et ses
336
+ styles ne s'appliquent pas, même si elle y écrit ensuite par le CSSOM. Signe toi-même les
337
+ `<style>` que les scripts créent, AVANT d'importer la bibliothèque (premier import de ton
338
+ entrée) :
339
+
340
+ ```ts
341
+ import { readCspNonce } from "nodefony/client";
342
+
343
+ const nonce = readCspNonce();
344
+ if (nonce) {
345
+ // Une bibliothèque qui réécrit `nonce` avec une valeur vide (`undefined`) écraserait la
346
+ // signature sans un mot : l'affectation d'une valeur vide rend le nonce de la page.
347
+ const base = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "nonce");
348
+ if (base?.get && base.set) {
349
+ const { get, set } = base;
350
+ Object.defineProperty(HTMLStyleElement.prototype, "nonce", {
351
+ configurable: true,
352
+ get() {
353
+ return get.call(this);
354
+ },
355
+ set(value: unknown) {
356
+ const usable =
357
+ typeof value === "string" && value !== "" && value !== "undefined";
358
+ set.call(this, usable ? value : nonce);
359
+ },
360
+ });
361
+ }
362
+ Document.prototype.createElement = new Proxy(
363
+ Document.prototype.createElement,
364
+ {
365
+ apply(target, thisArg, args) {
366
+ const element: unknown = Reflect.apply(target, thisArg, args);
367
+ if (element instanceof HTMLStyleElement) element.nonce = nonce;
368
+ return element;
369
+ },
370
+ },
371
+ );
372
+ }
373
+ ```
374
+
375
+ Le geste ne signe que ce qu'un script CRÉE : un `<style>` glissé dans la page par une injection
376
+ HTML (`innerHTML`, gabarit) reste refusé. Il modifie deux méthodes natives pour toute la page —
377
+ c'est ce qui le réserve à ce cas. Éprouvé sous Chrome, Firefox et WebKit, écriture par nœud
378
+ texte comme par `insertRule`.
379
+
380
+ Dernier recours, et global : retirer le nonce des seuls styles en réécrivant la politique — la
381
+ défense des scripts reste entière, seule celle contre l'exfiltration par CSS injecté tombe.
382
+
383
+ ```ts
384
+ use("@nodefony/security", {
385
+ headers: {
386
+ // Le défaut, sauf `style-src` : plus de nonce pour les styles, scripts inchangés.
387
+ csp:
388
+ "default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; " +
389
+ "style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; " +
390
+ "connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; " +
391
+ "form-action 'self'; upgrade-insecure-requests",
392
+ },
393
+ });
394
+ ```
395
+
396
+ Un `@Csp` de route ne peut pas le faire à sa place : il AJOUTE des sources, et un navigateur
397
+ ignore `'unsafe-inline'` tant qu'un nonce figure dans la directive (CSP3 § « Does a source list
398
+ allow all inline behavior »).
399
+
400
+ Là où le nonce est retiré (page servie telle quelle, `cspNonces: false`), `'unsafe-inline'`
401
+ reprend la main : la page garde ses styles.
402
+
403
+ **Derrière un proxy inverse (nginx, haproxy, CDN) : la politique à nonce vient de
404
+ l'application, jamais du proxy.** Le nonce change à chaque réponse et n'existe que dans
405
+ l'application, qui l'écrit à la fois dans l'en-tête et dans la page. Un proxy relaie l'en-tête
406
+ tel quel — éprouvé derrière de vrais nginx et haproxy (`reverse-proxy.test.ts`) : une seule
407
+ politique, le même nonce que la page, `upgrade-insecure-requests` posé seulement quand le client
408
+ arrive en `https`. Trois configurations de proxy cassent ce contrat :
409
+
410
+ - **le proxy AJOUTE sa propre CSP** (`add_header Content-Security-Policy …`,
411
+ `http-response add-header …`) : le navigateur applique alors les DEUX politiques, et une
412
+ ressource doit passer chacune (CSP3 § « The effect of multiple policies »). Celle du proxy n'a
413
+ pas le nonce : tes scripts et tes styles signés sont bloqués. N'y mets rien qui touche aux
414
+ scripts ou aux styles — ou rien du tout ;
415
+ - **le proxy REMPLACE la CSP** (`proxy_hide_header` puis `add_header`) : le nonce disparaît de la
416
+ politique, il ne protège plus rien. Si l'exploitation tient à écrire la politique au proxy,
417
+ coupe celle de l'application (`csp: ""`) : c'est alors une politique sans nonce, en
418
+ connaissance de cause ;
419
+ - **le proxy ou le CDN MET EN CACHE une page signée** : il sert le même nonce à tous les
420
+ visiteurs, et un nonce connu d'avance n'autorise plus rien de façon sûre. Nodefony envoie
421
+ `Cache-Control: private, no-cache` sur toute page à nonce (sauf si ton contrôleur en écrit un
422
+ autre) : un cache qui respecte la norme ne la garde pas. Une règle qui l'ignore
423
+ (`proxy_ignore_headers Cache-Control`, « tout mettre en cache » d'un CDN) la remet en cache :
424
+ n'en pose jamais sur une page à nonce. Les fichiers JS, CSS et images gardent leur cache long.
425
+
426
+ #### Poser la CSP au proxy (nginx, ingress)
427
+
428
+ Tu préfères écrire la politique dans nginx ou l'ingress ? C'est permis, en un réglage :
429
+ l'application se retire au lieu de doubler la politique.
430
+
431
+ ```ts
432
+ // nodefony/config/security.ts
433
+ export default (ctx) =>
434
+ ({
435
+ headers: { csp: "" }, // l'application n'envoie plus de CSP : le proxy décide seul
436
+ }) satisfies ISecurityConfigInput;
437
+ ```
438
+
439
+ - **Au proxy, une politique sans nonce** : il ne connaît pas celui que l'application écrit dans la
440
+ page. Point de départ sûr : la politique par défaut (`npx nodefony inspect config`,
441
+ clé `headers.csp` de `@nodefony/security`) privée de ses `'nonce-{{nonce}}'` et de
442
+ `'strict-dynamic'` — celle que Nodefony pose déjà sur une page servie telle quelle.
443
+ - **Ce que tu perds** : la politique ne juge plus que l'origine. Un script injecté qui pointe vers
444
+ un fichier de ton propre domaine (fichier téléversé, JSONP) passe, là où le nonce le bloquait.
445
+ - **Ce qui ne change pas** : `Referrer-Policy`, COOP et les autres en-têtes restent posés par
446
+ l'application. Aucun `Cache-Control` n'est imposé : sans nonce, la page redevient cachable.
447
+ - **Rejoue tes pages derrière le proxy avant de conclure** : un script écrit en ligne dans la page,
448
+ sans nonce, y est bloqué.
449
+ - **Jamais les deux à la fois** : CSP de l'application ET CSP du proxy, le navigateur applique les
450
+ deux (première puce ci-dessus).
451
+
452
+ **`upgrade-insecure-requests` ne part que sur une page servie en `https`.** Le navigateur réécrit
453
+ alors en `https` chaque sous-ressource écrite en `http://` (image, script, socket) au lieu de la
454
+ charger en clair. Sur une page servie en `http` — le développement sur `http://localhost` — la
455
+ même réécriture viserait un `https` que le serveur ne sert pas, et la page perdrait ses
456
+ ressources : la directive y est donc retirée. Les deux variantes de la politique sont composées au
457
+ démarrage (`securityHeaders.ts#SecurityHeaders`) ; par réponse, Nodefony lit seulement le schéma
458
+ de la requête, qui tient compte du proxy de confiance. Derrière un proxy qui termine le TLS,
459
+ régler `trustProxy` du module http, sinon la page est vue en clair et la directive ne part pas.
460
+ Pour la couper partout, la retirer de `csp` ; pour l'avoir dans une politique écrite à la main,
461
+ l'y ajouter.
462
+
463
+ `base-uri` reste à `'self'` plutôt qu'à `'none'` : `'self'` empêche déjà une balise `<base>`
464
+ injectée de détourner les scripts vers un hôte tiers, et `'none'` casserait les applications qui
465
+ posent `<base href="/">` ou vivent sous un sous-chemin.
263
466
 
264
467
  **Pourquoi ce compromis** : un CSP qui casse l'application est désactivé par le premier développeur
265
468
  pressé. Un CSP strict là où ça compte (`script-src`) et permissif là où ça ne coûte rien (styles,
266
469
  images) survit en production — c'est celui-là qui protège vraiment.
267
470
 
268
471
  Le CSP couvre aussi le clickjacking, via `frame-ancestors`, plus finement que `X-Frame-Options`
269
- (liste d'origines plutôt que tout-ou-rien). Les deux cohabitent : les navigateurs modernes
270
- privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les anciens.
472
+ (liste d'origines plutôt que tout-ou-rien). Les deux cohabitent : un navigateur CSP ignore
473
+ `X-Frame-Options` dès que `frame-ancestors` est présent, l'ancien en-tête reste le filet des
474
+ autres. **Un seul réglage pilote les deux** — `frameOptions` du module http (section suivante) :
475
+ security en dérive `frame-ancestors` (`csp.ts#withFrameAncestors`), sauf si ta politique déclare
476
+ déjà la directive. C'est ainsi qu'on autorise un portail tiers à t'encadrer :
477
+
478
+ ```ts
479
+ use("@nodefony/security", {
480
+ headers: {
481
+ csp: "default-src 'self'; script-src 'nonce-{{nonce}}' 'self'; frame-ancestors https://portail.example",
482
+ },
483
+ });
484
+ ```
271
485
 
272
486
  ### `X-Frame-Options` — non, tu ne m'encadres pas
273
487
 
@@ -280,6 +494,17 @@ Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions
280
494
  s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
281
495
  pour un HTML statique servi directement depuis `public/`.
282
496
 
497
+ Le même réglage fixe `frame-ancestors` dans la politique de contenu :
498
+
499
+ | `frameOptions` | `X-Frame-Options` | `frame-ancestors` |
500
+ | --------------- | ----------------- | ----------------- |
501
+ | `DENY` (défaut) | `DENY` | `'none'` |
502
+ | `SAMEORIGIN` | `SAMEORIGIN` | `'self'` |
503
+ | `null` | absent | absent |
504
+
505
+ Une édition à chaud de `frameOptions` prévient le pare-feu
506
+ (`firewall.ts#Firewall.onTransportHeadersChanged`) : les deux en-têtes ne divergent jamais.
507
+
283
508
  ### `X-Content-Type-Options` — arrête de deviner
284
509
 
285
510
  **La menace** : le MIME sniffing. Un fichier téléversé, servi avec un `Content-Type` imprécis, est
@@ -332,6 +557,15 @@ _tabnabbing_ et pour les canaux auxiliaires type Spectre.
332
557
  conditions de l'**isolation cross-origin**, indispensable si tu veux `SharedArrayBuffer` ou des
333
558
  timers haute résolution.
334
559
 
560
+ **Lu sur** : un document (page HTML) ouvert dans une fenêtre. Sur un JSON, une image, un script,
561
+ le navigateur l'ignore — aucune fenêtre n'y est ouverte.
562
+
563
+ **Exemple** : ta page de connexion ouvre la fenêtre d'un fournisseur OAuth. Sans COOP, ce site
564
+ garde `window.opener` et peut, s'il est compromis, rediriger ton onglet vers une copie de ta page
565
+ (_tabnabbing_). Avec `same-origin`, la référence vaut `null`. ⚠️ Un flux de connexion par fenêtre
566
+ surgissante qui renvoie son résultat par `window.opener.postMessage` cesse alors de fonctionner :
567
+ `same-origin-allow-popups` garde le lien vers les fenêtres que TA page ouvre.
568
+
335
569
  ### `Cross-Origin-Embedder-Policy` — je n'embarque que du consenti
336
570
 
337
571
  **La menace** : ta page embarque des ressources tierces qui n'ont jamais donné leur accord, et les
@@ -340,6 +574,14 @@ place dans ton processus.
340
574
  `require-corp` (`securityHeaders.ts#SecurityHeaders.constructor`) exige que **chaque** ressource tierce s'annonce comme
341
575
  partageable (CORP ou CORS). C'est le complément de COOP pour l'isolation complète.
342
576
 
577
+ **Lu sur** : un document, et le script d'un worker (un worker charge lui aussi des ressources).
578
+ Sur une image ou un JSON, il ne contrôle rien.
579
+
580
+ **Exemple** : ta page affiche `<img src="https://cdn.tiers.example/logo.png">`. Avec
581
+ `require-corp`, l'image ne s'affiche que si le CDN répond `Cross-Origin-Resource-Policy:
582
+ cross-origin` (ou sert l'image en CORS). `credentialless` est le compromis : la ressource tierce
583
+ est chargée SANS cookies, et n'a plus besoin de consentir.
584
+
343
585
  > [!WARNING]
344
586
  > `coep: "require-corp"` **casse toutes les ressources tierces non conformes** — polices Google,
345
587
  > images de CDN, iframes de paiement. C'est pour cette raison qu'il est absent des défauts, et
@@ -354,6 +596,14 @@ mesurer, les mettre en cache, ou monter une attaque par inclusion.
354
596
  `same-origin` (`securityHeaders.ts#SecurityHeaders.constructor`) interdit toute inclusion externe ; `same-site` autorise tes
355
597
  propres sous-domaines ; `cross-origin` ouvre — c'est ce qu'il faut sur une CDN publique assumée.
356
598
 
599
+ **Lu sur** : toute ressource incluse sans CORS — image, script, média, JSON appelé par une balise.
600
+ C'est l'en-tête des **ressources**, là où COOP et COEP sont ceux des **pages**.
601
+
602
+ **Exemple** : `https://evil.example` écrit `<img src="https://ton-app/api/me">`. Le navigateur
603
+ y joint les cookies de sa victime, et la réponse — son profil — atterrit dans le processus de la
604
+ page hostile, où une attaque de type Spectre peut la lire. Avec `same-origin`, le navigateur
605
+ bloque la réponse AVANT qu'elle n'entre dans ce processus.
606
+
357
607
  ### `Origin-Agent-Cluster` — un bac à sable par origine
358
608
 
359
609
  **La menace** : plusieurs origines partageant heap et processus, donc des canaux auxiliaires
@@ -400,21 +650,21 @@ Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts#securityHea
400
650
  **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts#HttpKernel.onConfigChanged`)
401
651
  recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
402
652
 
403
- | Option | Type | Défaut | Effet |
404
- | ------------------------------------------- | ----------------- | ---------- | ------------------------------------------------------------------ |
405
- | `contentTypeOptions` | chaîne ou `null` | `nosniff` | `X-Content-Type-Options` ; `null` = ne pas émettre. |
406
- | `frameOptions` | chaîne ou `null` | `DENY` | `X-Frame-Options` ; `SAMEORIGIN` si auto-encadrement. |
407
- | `strictTransportSecurity` | objet ou `null` | activé | `null` = pas de HSTS du tout. |
408
- | `strictTransportSecurity.maxAge` | entier (secondes) | `31536000` | Durée mémorisée par le navigateur (un an, recommandé OWASP). |
409
- | `strictTransportSecurity.includeSubDomains` | booléen | `true` | Étend la contrainte à tous les sous-domaines. |
410
- | `strictTransportSecurity.preload` | booléen | `false` | Inscription à la liste pré-chargée — **irréversible en pratique**. |
653
+ | Option | Type | Défaut | Effet |
654
+ | ------------------------------------------- | ----------------- | ---------- | -------------------------------------------------------------------------- |
655
+ | `contentTypeOptions` | chaîne ou `null` | `nosniff` | `X-Content-Type-Options` ; `null` = ne pas émettre. |
656
+ | `frameOptions` | chaîne ou `null` | `DENY` | `X-Frame-Options` ET `frame-ancestors` ; `SAMEORIGIN` si auto-encadrement. |
657
+ | `strictTransportSecurity` | objet ou `null` | activé | `null` = pas de HSTS du tout. |
658
+ | `strictTransportSecurity.maxAge` | entier (secondes) | `31536000` | Durée mémorisée par le navigateur (un an, recommandé OWASP). |
659
+ | `strictTransportSecurity.includeSubDomains` | booléen | `true` | Étend la contrainte à tous les sous-domaines. |
660
+ | `strictTransportSecurity.preload` | booléen | `false` | Inscription à la liste pré-chargée — **irréversible en pratique**. |
411
661
 
412
662
  > [!WARNING]
413
663
  > Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
414
664
  > (`config.ts#headersSchema`) mais **ne pilotent rien** : la couche
415
- > applicative ne les lit pas (`securityHeaders.ts#ISecurityHeadersOptions`), elles ne servent qu'à l'introspection
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#headersSchema`) : Nodefony n'émet aucun
665
+ > applicative ne les lit pas (`securityHeaders.ts#ISecurityHeadersOptions`). `frameguard` est
666
+ > **dépréciée** (retrait à la prochaine version majeure). Pour changer réellement `X-Frame-Options`
667
+ > et `frame-ancestors`, c'est `securityHeaders.frameOptions` **du module http**. Même remarque pour `hidePoweredBy` (`config.ts#headersSchema`) : Nodefony n'émet aucun
418
668
  > `X-Powered-By`, l'option est un no-op documenté.
419
669
 
420
670
  ## 🏗️ Le CSP en détail — deux régimes, trois façons de l'étendre
@@ -428,7 +678,8 @@ quelle dans la table gelée du boot (`securityHeaders.ts#SecurityHeaders.constru
428
678
  Détail de robustesse : si tu désactives `cspNonces` en laissant le placeholder dans la chaîne, le
429
679
  token résiduel `'nonce-{{nonce}}'` est **purgé** (`securityHeaders.ts#SecurityHeaders.constructor`). Sans ça, tu servirais un
430
680
  CSP contenant un nonce littéral jamais émis — donc un `script-src` qui bloque **tout**, y compris tes
431
- propres scripts. Le code refuse de produire un CSP cassé.
681
+ propres scripts. `'strict-dynamic'` part avec lui quand la directive n'a plus ni nonce ni hash : seul,
682
+ il ferait ignorer `'self'` et bloquerait aussi tout. Le code refuse de produire un CSP cassé.
432
683
 
433
684
  ### Régime 2 — nonce par requête (la vraie défense anti-XSS)
434
685
 
@@ -456,15 +707,74 @@ le repli statique et le `writeHead` (`http-kernel.ts#HttpKernel.runHttpPipeline`
456
707
  faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
457
708
  être avant l'écriture des en-têtes pour pouvoir en poser.
458
709
 
459
- ### Étendre le CSP — trois portées, une seule mécanique
710
+ ### Quelle politique sur quelle réponse — le type décide
711
+
712
+ Une politique de contenu ne protège que ce que le navigateur **rend** : une page, un SVG, le
713
+ script d'un worker. Sur un JSON ou une image, elle ne gouverne rien — sauf l'encadrement. Le
714
+ pare-feu choisit donc la politique au moment où la réponse écrit ses en-têtes, d'après son
715
+ `Content-Type` (`securityHeaders.ts#responseKindOf`, ADR-0024 §C11) :
716
+
717
+ | La réponse est… | Content-Security-Policy | Autres en-têtes applicatifs |
718
+ | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------- |
719
+ | un document : HTML, ou tout type XML (`text/xml`, `*+xml`, dont SVG) | complète, avec nonce | tous |
720
+ | un document servi tel quel (fichier de `public/`, relais) | complète, sans nonce ni `'strict-dynamic'` (styles : `'unsafe-inline'`) | tous |
721
+ | du JavaScript (`text/javascript`…) | complète, **sans** nonce (celle qu'un worker prend) | `Referrer-Policy`, COEP, CORP |
722
+ | autre chose : JSON, CSS, image, police, média, 204 | `default-src 'none'`, plus le `frame-ancestors` de la complète | `Referrer-Policy`, CORP |
723
+ | un `304 Not Modified` du pipeline (contrôleur) | **aucune** | aucun |
724
+
725
+ Pourquoi chaque ligne :
726
+
727
+ - **Le nonce n'est tiré que pour un document.** Une réponse d'API ne paie plus le générateur
728
+ aléatoire, et sa politique passe de ~360 octets à ~40.
729
+ - **L'encadrement suit la politique complète** : avec `http.securityHeaders.frameOptions:
730
+ "SAMEORIGIN"`, un PDF ou une image que ta page encadre le reste.
731
+ - **Une page XML est un document** : le navigateur la rend. Sous la politique courte, un formulaire
732
+ injecté n'y relèverait d'aucun `form-action` — `default-src` ne couvre ni `form-action` ni
733
+ `base-uri`.
734
+ - **Un `304` du pipeline ne porte rien** : ses en-têtes REMPLACENT ceux de la page gardée en cache
735
+ (RFC 9111 §4.3.4). Une politique courte y réduirait la page, un nonce neuf la désaccorderait de
736
+ ses balises. Un fichier statique en `304` porte en revanche la politique de son `200` : le serveur
737
+ statique écrit ses en-têtes avant de savoir la copie fraîche. C'est sans effet : c'est
738
+ exactement la politique de son `200`, qui ne porte pas de nonce.
739
+ - **Une réponse sans contenu** (204, corps vide) prend la courte, même si un `Content-Type` y a été
740
+ posé : rien n'y sera rendu.
741
+ - **Un fichier statique** est rangé d'après son extension. Sans extension connue — l'`index.html`
742
+ d'un dossier, ou toute adresse qui finit par `/` —, il est traité en document : jamais une
743
+ politique plus faible par défaut. Un document servi ainsi reçoit la politique sans nonce (voir
744
+ `'strict-dynamic'` plus haut).
745
+
746
+ Ce que l'application écrit n'est **jamais réduit** :
747
+
748
+ - une route `@Csp` sert SA politique quel que soit son type (un `frame-ancestors` posé sur un PDF) ;
749
+ - un en-tête posé à la main par le contrôleur (`this.response.setHeader(…)`) gagne sur celui du
750
+ pare-feu ;
751
+ - `headers.csp: ""` coupe toute politique, courte comprise.
752
+
753
+ ### Étendre le CSP — quatre portées, une seule mécanique
460
754
 
461
755
  | Portée | Outil | Pour quoi | Recalculé |
462
756
  | ------------------ | ------------------------------- | ----------------------------------------------- | ------------------- |
463
757
  | Application | `headers.csp` en config | ta politique de base | au boot |
758
+ | Application | `headers.cspSources` en config | **ajouter** des sources sans réécrire la base | au boot |
464
759
  | Module | `Firewall.registerCspOrigins()` | besoin **permanent** d'un module (ex. Vite) | à l'enregistrement |
465
760
  | Route / contrôleur | `@Csp({ … })` | besoin **ponctuel** d'une réponse (iframe, CDN) | par requête décorée |
466
761
 
467
- Les trois convergent vers `mergeCspFragments()` (`csp.ts#mergeCspFragments`), et c'est un **merge structuré**, pas
762
+ **`headers.cspSources` — ajouter sans réécrire.** Recopier toute la politique pour y ajouter un
763
+ hôte la figerait : elle ne suivrait plus le défaut. On déclare plutôt des sources **par contribution
764
+ nommée** :
765
+
766
+ ```ts
767
+ use("@nodefony/security", {
768
+ headers: { cspSources: { cdn: { "img-src": ["https://cdn.example.com"] } } },
769
+ });
770
+ ```
771
+
772
+ Indexée par NOM, et non par directive, parce que la fusion de configuration remplace un tableau case
773
+ par case : un module qui complète la section par `module-security` sous un autre nom s'ajoute,
774
+ il n'écrase pas. Une contribution malformée est ignorée et journalisée, l'application démarre ; une
775
+ source ouverte à tout hôte est gardée (c'est ton choix) et signalée. Sans effet quand `csp` vaut `""`.
776
+
777
+ Les quatre convergent vers `mergeCspFragments()` (`csp.ts#mergeCspFragments`), et c'est un **merge structuré**, pas
468
778
  une concaténation. Pourquoi c'est vital : en CSP, une directive **répétée est ignorée** après sa
469
779
  première occurrence (W3C CSP Level 3 §3, rationnel documenté `csp.ts#CspFragment`). Concaténer
470
780
  `"script-src 'self'"` et `"script-src https://cdn"` produirait un en-tête où la seconde est purement
@@ -480,6 +790,46 @@ les tests fiables.
480
790
  `@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
481
791
  `securityHeaders.ts#SecurityHeaders.cspForExtra`) ; le cas courant reste le simple `join`.
482
792
 
793
+ ### Rapports de violation — voir ce que la politique bloque
794
+
795
+ Une politique stricte casse sans bruit : le navigateur bloque une image ou un script, et le serveur
796
+ n'en sait rien. Par défaut, la politique **demande donc ses rapports** — `report-uri` (lu par tous
797
+ les navigateurs) et `report-to nodefony-csp` (Reporting API, honorée seulement vers une origine
798
+ sûre : https ou localhost), avec l'en-tête `Reporting-Endpoints` qui nomme le groupe :
799
+
800
+ ```http
801
+ Content-Security-Policy: …; report-uri /nodefony/security/api/csp-report; report-to nodefony-csp
802
+ Reporting-Endpoints: nodefony-csp="/nodefony/security/api/csp-report"
803
+ ```
804
+
805
+ Le navigateur poste ses rapports sur `POST /nodefony/security/api/csp-report`
806
+ (`CspReportController.ts#CspReportController`). Cette porte est publique — le navigateur n'envoie
807
+ aucun identifiant — donc tout ce qu'elle reçoit est traité comme écrit par un attaquant
808
+ (`cspReports.ts#CspReportCollector`) :
809
+
810
+ - corps lu en flux et **borné à 8 Ko** (`CSP_REPORT_MAX_BYTES`), 413 au-delà ;
811
+ - deux types de contenu seulement, `application/csp-report` et `application/reports+json` ;
812
+ - **quota par adresse** (120 rapports par minute) ;
813
+ - URL **expurgées** de leur requête, de leur fragment et de leurs identifiants — une requête peut
814
+ porter un jeton ; une URL qui n'est pas HTTP(S) se réduit à son schéma ;
815
+ - l'**extrait de code n'est jamais gardé** : c'est une chaîne de l'attaquant ;
816
+ - stockage **en mémoire seule**, dédoublonné (une répétition incrémente un compteur), 200 violations
817
+ distinctes au plus ; le journal dit chaque violation neuve une fois, 20 lignes par minute au plus.
818
+
819
+ La réponse est toujours 204 : l'émetteur n'apprend rien de ce qui a été gardé. L'administrateur lit
820
+ les violations sur `GET /nodefony/security/api/csp-reports` (rôle `ROLE_NODEFONY_ADMIN`), la plus
821
+ récente d'abord — champs à afficher comme du **texte**.
822
+
823
+ | `headers.cspReport` | Effet |
824
+ | --------------------- | ------------------------------------------------------------------------------- |
825
+ | `true` (défaut) | rapports vers la route du framework, collectés et lisibles par l'administrateur |
826
+ | `"https://…"`, `"/…"` | rapports vers **ton** collecteur ; aucune route de réception côté application |
827
+ | `false` | aucun rapport demandé |
828
+
829
+ Une politique que tu écris avec son propre `report-uri` ou `report-to` est servie telle quelle. Une
830
+ URL qu'un en-tête ne peut pas porter est retirée et journalisée en `ERROR` — l'application démarre
831
+ sans rapports plutôt que de ne pas démarrer.
832
+
483
833
  ## 🧩 Extension — déclarer un fragment CSP depuis son module
484
834
 
485
835
  Un module qui a besoin d'origines supplémentaires ne doit **jamais** poser l'en-tête lui-même : il en
@@ -487,19 +837,23 @@ Un module qui a besoin d'origines supplémentaires ne doit **jamais** poser l'en
487
837
  inefficace, au pire il casse la page. Il **déclare** son besoin, security fusionne.
488
838
 
489
839
  ```typescript
490
- // Dans le service d'un module — le firewall est résolu PAR NOM (aucun import de security).
491
- const firewall = this.container?.get?.("firewall") as
492
- | { registerCspOrigins?(m: string, f: Record<string, string[]>): void }
493
- | undefined;
840
+ // Dans le service d'un module — le firewall est résolu PAR NOM et typé par le
841
+ // contrat de @nodefony/http (aucun import de security).
842
+ import type { IFirewallGate } from "@nodefony/http";
494
843
 
495
- firewall?.registerCspOrigins?.("mon-module", {
844
+ this.get<IFirewallGate>("firewall")?.registerCspOrigins?.("mon-module", {
496
845
  "connect-src": ["https://api.partenaire.tld"],
497
846
  "img-src": ["https://cdn.partenaire.tld"],
498
847
  });
499
848
  ```
500
849
 
501
- Trois propriétés à retenir :
850
+ Quatre propriétés à retenir :
502
851
 
852
+ - **À tout moment** : une contribution reçue avant que le firewall ait composé sa politique
853
+ (`onBoot`) est gardée, puis fusionnée à la composition (`firewall.ts#Firewall.registerCspOrigins`) — l'ordre
854
+ des modules ne décide de rien. Seul le service `firewall` doit déjà exister : un module qui
855
+ déclare au démarrage le fait donc à `onReady`, comme `@nodefony/realtime` pour une socket servie
856
+ sur une autre origine (`realtime.publicOrigin`, `RealtimeService.ts#RealtimeService.init`).
503
857
  - **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
504
858
  `registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
505
859
  - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts#Firewall.unregisterCspOrigins`) retire le fragment et
@@ -557,23 +911,25 @@ L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Z
557
911
  porte sa description, ce qui en fait la référence toujours à jour des défauts.
558
912
 
559
913
  > [!NOTE]
560
- > La ligne « frameguard » de cet écran reflète la **config security**, pas la valeur réellement émise
561
- > par le transport. La source de vérité pour `X-Frame-Options`, c'est la réponse HTTP elle-même :
562
- > `curl -I` tranche en une seconde.
914
+ > La ligne « frameguard » de cet écran reflète la valeur réellement ÉMISE par le transport, lue
915
+ > chez le noyau HTTP (`firewall.ts#Firewall.describe`), pas la clé inerte de la config security.
916
+ > La réponse HTTP elle-même reste la preuve : `curl -I` tranche en une seconde.
563
917
 
564
918
  ## ⚠️ Pièges (symptôme → cause → correction)
565
919
 
566
- | Symptôme | Cause dans le code | Correction |
567
- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
568
- | `frameguard: "sameorigin"` en config security sans effet | Clé **inerte** — non lue par la couche applicative (`securityHeaders.ts#ISecurityHeadersOptions`) | Régler `securityHeaders.frameOptions` du module **http** |
569
- | `<script>` inline bloqué | Le template ne reprend pas le nonce | Rendre `<script nonce="…">` depuis `context.cspNonce` |
570
- | CSP contenant `'nonce-{{nonce}}'` littéral | `cspNonces` désactivé, placeholder laissé | Nodefony purge le résiduel (`securityHeaders.ts#ISecurityHeadersOptions.cspNonces`) ; retirer le placeholder |
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
- | 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#HttpKernel.getErrorRenderer`) | Comportement conforme RFC 6797 — vérifier sur le port HTTPS |
574
- | Le CSP est plus large en dev qu'en prod | `@nodefony/frontend` déclare les origines Vite (`FrontendService.ts#FrontendService.#buildEntries`) | Attendu : le fragment n'existe pas en production |
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
- | 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 |
920
+ | Symptôme | Cause dans le code | Correction |
921
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
922
+ | `frameguard: "sameorigin"` en config security sans effet | Clé **inerte** — non lue par la couche applicative (`securityHeaders.ts#ISecurityHeadersOptions`) | Régler `securityHeaders.frameOptions` du module **http** |
923
+ | `<script>` inline bloqué | Le template ne reprend pas le nonce | Rendre `<script nonce="…">` depuis `context.cspNonce` |
924
+ | CSP contenant `'nonce-{{nonce}}'` littéral | `cspNonces` désactivé, placeholder laissé | Nodefony purge le résiduel (`securityHeaders.ts#ISecurityHeadersOptions.cspNonces`) ; retirer le placeholder |
925
+ | 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` |
926
+ | 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 |
927
+ | HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts#HttpKernel.getErrorRenderer`) | Comportement conforme RFC 6797 — vérifier sur le port HTTPS |
928
+ | Le CSP est plus large en dev qu'en prod | `@nodefony/frontend` déclare les origines Vite (`FrontendService.ts#FrontendService.#buildEntries`) | Attendu : le fragment n'existe pas en production |
929
+ | Violation en console, mais aucun rapport reçu | Le navigateur n'envoie pas de rapports — Brave les bloque (vie privée), en `report-uri` comme en `report-to` | Éprouver sous Chrome, Edge ou Firefox ; l'onglet Réseau doit montrer `POST csp-report` → 204 |
930
+ | Aucun rapport reçu en `report-to` en développement | La Reporting API n'envoie que vers une origine sûre (https, localhost) | Attendu : `report-uri` porte les rapports en http ; tester sur le port HTTPS |
931
+ | Aucun en-tête applicatif | Module security absent ou `headers.enabled: false` | Le socle transport reste actif ; réactiver security pour CSP/Referrer/isolation |
932
+ | 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 |
577
933
 
578
934
  ## 🧪 Tests & couverture
579
935