@nodefony/security 10.0.0-alpha.1

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 (258) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +182 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +151 -0
  6. package/dist/nodefony/command/security-secrets.js +158 -0
  7. package/dist/nodefony/command/security-token.js +335 -0
  8. package/dist/nodefony/command/security-user-add.js +131 -0
  9. package/dist/nodefony/command/security-user-delete.js +102 -0
  10. package/dist/nodefony/command/security-user-list.js +77 -0
  11. package/dist/nodefony/config/config.js +366 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +35 -0
  13. package/dist/nodefony/contracts/IAccessVoter.js +13 -0
  14. package/dist/nodefony/contracts/IApiKey.js +1 -0
  15. package/dist/nodefony/contracts/IAuditEvent.js +1 -0
  16. package/dist/nodefony/contracts/IAuditStore.js +1 -0
  17. package/dist/nodefony/contracts/IAuthenticator.js +1 -0
  18. package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
  19. package/dist/nodefony/contracts/IFirewall.js +1 -0
  20. package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
  21. package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
  22. package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
  23. package/dist/nodefony/contracts/ISecuredArea.js +1 -0
  24. package/dist/nodefony/contracts/IToken.js +1 -0
  25. package/dist/nodefony/contracts/ITokenStore.js +1 -0
  26. package/dist/nodefony/contracts/ITotpSecret.js +1 -0
  27. package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
  28. package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
  29. package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
  30. package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
  31. package/dist/nodefony/contracts/IWebhookStore.js +1 -0
  32. package/dist/nodefony/contracts/index.js +2 -0
  33. package/dist/nodefony/errors/AccessDeniedError.js +14 -0
  34. package/dist/nodefony/errors/ApiKeyError.js +21 -0
  35. package/dist/nodefony/errors/AuthenticationError.js +14 -0
  36. package/dist/nodefony/errors/CsrfError.js +23 -0
  37. package/dist/nodefony/errors/InvalidTargetError.js +39 -0
  38. package/dist/nodefony/errors/SsrfError.js +17 -0
  39. package/dist/nodefony/errors/ThrottledError.js +21 -0
  40. package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
  41. package/dist/nodefony/errors/WebAuthnError.js +21 -0
  42. package/dist/nodefony/errors/index.js +9 -0
  43. package/dist/nodefony/service/accessTokenVerifier.js +77 -0
  44. package/dist/nodefony/service/apiKeys.js +310 -0
  45. package/dist/nodefony/service/auditService.js +145 -0
  46. package/dist/nodefony/service/authFlow.js +332 -0
  47. package/dist/nodefony/service/authorization.js +95 -0
  48. package/dist/nodefony/service/cors.js +81 -0
  49. package/dist/nodefony/service/csrf.js +97 -0
  50. package/dist/nodefony/service/firewall.js +699 -0
  51. package/dist/nodefony/service/oauth2.js +153 -0
  52. package/dist/nodefony/service/securityHeaders.js +80 -0
  53. package/dist/nodefony/service/tokenService.js +486 -0
  54. package/dist/nodefony/service/totp.js +209 -0
  55. package/dist/nodefony/service/webAuthn.js +343 -0
  56. package/dist/nodefony/service/webhooks.js +539 -0
  57. package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
  58. package/dist/nodefony/src/SecuredArea.js +51 -0
  59. package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
  60. package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
  61. package/dist/nodefony/src/admin/adminAudit.js +37 -0
  62. package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
  63. package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
  64. package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
  65. package/dist/nodefony/src/audit/auditBridge.js +82 -0
  66. package/dist/nodefony/src/audit/auditFilters.js +60 -0
  67. package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
  68. package/dist/nodefony/src/audit/readAuditContext.js +24 -0
  69. package/dist/nodefony/src/audit/recordAudit.js +16 -0
  70. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
  71. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
  72. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
  73. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
  74. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
  75. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
  76. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
  77. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
  78. package/dist/nodefony/src/authenticator/bearer.js +2 -0
  79. package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
  80. package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
  81. package/dist/nodefony/src/crypto/secretCipher.js +79 -0
  82. package/dist/nodefony/src/csp.js +54 -0
  83. package/dist/nodefony/src/csrfToken.js +65 -0
  84. package/dist/nodefony/src/net/ssrfGuard.js +130 -0
  85. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +65 -0
  87. package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
  88. package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
  89. package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
  90. package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
  91. package/dist/nodefony/src/sessionIdentity.js +35 -0
  92. package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
  93. package/dist/nodefony/src/token/AnonymousToken.js +40 -0
  94. package/dist/nodefony/src/token/JwtKeystore.js +160 -0
  95. package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
  96. package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
  97. package/dist/nodefony/src/token/UserToken.js +67 -0
  98. package/dist/nodefony/src/token/jwtRuntime.js +19 -0
  99. package/dist/nodefony/src/token/secretFile.js +134 -0
  100. package/dist/nodefony/src/token/tokenCriteria.js +35 -0
  101. package/dist/nodefony/src/token/tokenFilters.js +72 -0
  102. package/dist/nodefony/src/token/tokenSort.js +40 -0
  103. package/dist/nodefony/src/token/tokenStatus.js +35 -0
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
  106. package/dist/nodefony/src/totp/totpCipher.js +30 -0
  107. package/dist/nodefony/src/totp/totpCrypto.js +226 -0
  108. package/dist/nodefony/src/totp/totpOperations.js +129 -0
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
  110. package/dist/nodefony/src/voter/RoleVoter.js +32 -0
  111. package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
  112. package/dist/nodefony/src/voter/voterRegistry.js +20 -0
  113. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
  114. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
  115. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
  116. package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
  117. package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
  118. package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
  119. package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
  120. package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
  121. package/dist/nodefony/src/webhook/webhookSort.js +48 -0
  122. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
  123. package/dist/types/index.d.ts +157 -0
  124. package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
  125. package/dist/types/nodefony/command/security-token.d.ts +44 -0
  126. package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
  127. package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
  128. package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
  129. package/dist/types/nodefony/config/config.d.ts +295 -0
  130. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  131. package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
  132. package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
  133. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
  134. package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
  135. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
  136. package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
  137. package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
  138. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
  139. package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
  140. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
  141. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
  142. package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
  143. package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
  144. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
  145. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
  146. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
  147. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
  148. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
  149. package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
  150. package/dist/types/nodefony/contracts/index.d.ts +9 -0
  151. package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
  152. package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
  153. package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
  154. package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
  155. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
  156. package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
  157. package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
  158. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
  159. package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
  160. package/dist/types/nodefony/errors/index.d.ts +8 -0
  161. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
  162. package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
  163. package/dist/types/nodefony/service/auditService.d.ts +30 -0
  164. package/dist/types/nodefony/service/authFlow.d.ts +123 -0
  165. package/dist/types/nodefony/service/authorization.d.ts +33 -0
  166. package/dist/types/nodefony/service/cors.d.ts +48 -0
  167. package/dist/types/nodefony/service/csrf.d.ts +57 -0
  168. package/dist/types/nodefony/service/firewall.d.ts +148 -0
  169. package/dist/types/nodefony/service/oauth2.d.ts +66 -0
  170. package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
  171. package/dist/types/nodefony/service/tokenService.d.ts +103 -0
  172. package/dist/types/nodefony/service/totp.d.ts +58 -0
  173. package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
  174. package/dist/types/nodefony/service/webhooks.d.ts +160 -0
  175. package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
  176. package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
  177. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
  178. package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
  179. package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
  180. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
  181. package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
  182. package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
  183. package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
  184. package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
  185. package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
  186. package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
  187. package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
  188. package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
  189. package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
  190. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
  191. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
  192. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
  193. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
  194. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
  195. package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
  196. package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
  197. package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
  198. package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
  199. package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
  200. package/dist/types/nodefony/src/csp.d.ts +39 -0
  201. package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
  202. package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
  203. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
  204. package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
  205. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
  206. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
  207. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
  208. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
  209. package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
  210. package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
  211. package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
  212. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
  213. package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
  214. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
  215. package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
  216. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
  217. package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
  218. package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
  219. package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
  220. package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
  221. package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
  222. package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
  223. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
  224. package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
  225. package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
  226. package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
  227. package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
  228. package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
  229. package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
  230. package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
  231. package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
  232. package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
  233. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
  234. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
  235. package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
  236. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
  237. package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
  238. package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
  239. package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
  240. package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
  241. package/docs/api-keys.md +691 -0
  242. package/docs/audit.md +751 -0
  243. package/docs/authenticators.md +487 -0
  244. package/docs/authorization.md +497 -0
  245. package/docs/cors.md +497 -0
  246. package/docs/csrf.md +392 -0
  247. package/docs/external-jwt.md +181 -0
  248. package/docs/firewall.md +546 -0
  249. package/docs/headers.md +616 -0
  250. package/docs/index.md +207 -0
  251. package/docs/lexique.md +190 -0
  252. package/docs/oauth2.md +575 -0
  253. package/docs/obtenir-un-jeton.md +225 -0
  254. package/docs/tokens.md +520 -0
  255. package/docs/totp.md +804 -0
  256. package/docs/webauthn.md +733 -0
  257. package/docs/webhooks.md +1016 -0
  258. package/package.json +83 -0
package/docs/cors.md ADDED
@@ -0,0 +1,497 @@
1
+ ---
2
+ title: "CORS — partage cross-origin contrôlé"
3
+ navTitle: CORS
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: cors
7
+ coverageModule: security
8
+ coverageFiles: "cors.ts"
9
+ section: "Sécurité"
10
+ audience: [developer]
11
+ tags:
12
+ [
13
+ security,
14
+ cors,
15
+ preflight,
16
+ access-control,
17
+ vary,
18
+ credentials,
19
+ cswsh,
20
+ owasp,
21
+ fetch-standard,
22
+ ]
23
+ version: "doc"
24
+ status: stable
25
+ updated: 2026-07-19
26
+ source: "src/packages/@nodefony/security/docs/cors.md"
27
+ ---
28
+
29
+ # CORS — autoriser (ou non) les appels cross-origin
30
+
31
+ > Par défaut un navigateur **interdit** à `https://app.example.com` de lire la réponse de
32
+ > `https://api.example.com` (Same-Origin Policy). CORS est le protocole qui **assouplit** cette règle,
33
+ > côté serveur, en posant des en-têtes `Access-Control-*`. Ce n'est pas une défense — c'est l'inverse :
34
+ > un moyen d'**ouvrir** des portes précises sans tout ouvrir. Nodefony en fait une politique **pure**,
35
+ > **fail-safe** et **verrouillée au boot**. Ancré sur
36
+ > `src/packages/@nodefony/security/nodefony/service/cors.ts`.
37
+
38
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **CORS**
39
+
40
+ ## 🧠 Le modèle mental — deux moments, une allowlist
41
+
42
+ ```mermaid
43
+ flowchart TD
44
+ REQ["Requête avec un en-tête Origin"] --> WS{"réponse HTTP ?"}
45
+ WS -->|non = WebSocket| SKIP["no-op — le WS a sa propre garde<br/>(checkWebsocketOrigin, anti-CSWSH)"]
46
+ WS -->|oui| PF{"preflight ?<br/>OPTIONS + Access-Control-Request-Method"}
47
+ PF -->|oui| PH["Cors.preflightHeaders(origin)"]
48
+ PF -->|non = requête réelle| AH["Cors.actualHeaders(origin)"]
49
+ PH --> WL{"origine dans l'allowlist ?"}
50
+ AH --> WL
51
+ WL -->|non| NONE["null → AUCUN en-tête posé<br/>le navigateur bloque, 0 info divulguée"]
52
+ WL -->|oui| SET["Access-Control-Allow-*<br/>+ Vary: Origin si l'origine est reflétée"]
53
+ PH --> C204["204 — court-circuit total :<br/>ni routing, ni parse, ni authentification"]
54
+ ```
55
+
56
+ Deux moments distincts, une seule allowlist. Le **preflight** est l'`OPTIONS` que le navigateur envoie
57
+ _de lui-même_, avant une requête « non simple », pour demander la permission. La **requête réelle** est
58
+ celle que ton code a écrite. Les deux consultent la même liste d'origines : une origine absente ⇒
59
+ **aucun en-tête** ⇒ le navigateur bloque tout seul.
60
+
61
+ ## 📖 Lexique
62
+
63
+ | Terme | Sens |
64
+ | -------------------- | ------------------------------------------------------------------------------------------------------------ |
65
+ | **Origine** | Le triplet `scheme://host:port` (`https://app.example.com`). Deux ports différents = deux origines. |
66
+ | **SOP** | _Same-Origin Policy_ : le navigateur isole les origines et refuse par défaut la lecture cross-origin. |
67
+ | **CORS** | _Cross-Origin Resource Sharing_ : le protocole par lequel le serveur déclare ses exceptions à la SOP. |
68
+ | **Preflight** | Requête `OPTIONS` d'autorisation, envoyée par le navigateur **avant** une requête non simple. |
69
+ | **Requête simple** | GET/HEAD/POST avec un `Content-Type` basique — pas de preflight, le navigateur envoie directement. |
70
+ | **Credentials** | Cookies / `Authorization` transportés cross-origin. Exige un opt-in explicite des deux côtés. |
71
+ | **Reflet d'origine** | Renvoyer l'origine du client comme valeur `Access-Control-Allow-Origin`, au lieu du joker `*`. |
72
+ | `Vary: Origin` | Dit aux caches que la réponse **dépend** de l'`Origin` — sans lui, un cache partagé mélange les origines. |
73
+ | **Allowlist** | La liste blanche d'origines autorisées (`cors.origins`). Match **exact**, jamais par sous-chaîne. |
74
+ | **CSWSH** | _Cross-Site WebSocket Hijacking_ : une page tierce ouvre un WebSocket authentifié par le cookie du visiteur. |
75
+ | **Fetch Standard** | La norme WHATWG qui définit le protocole CORS (elle a remplacé la recommandation W3C CORS). |
76
+
77
+ ## Qu'est-ce que le CORS — et ce qu'il n'est PAS
78
+
79
+ Une analogie : la SOP est un **portier** qui, par défaut, refuse de remettre le courrier d'un immeuble
80
+ à quelqu'un d'un autre immeuble. CORS n'est pas un vigile de plus — c'est la **liste des voisins**
81
+ que le propriétaire affiche au portier : « à ceux-là, tu peux remettre le courrier ».
82
+
83
+ Trois conséquences que beaucoup de développeurs découvrent trop tard :
84
+
85
+ 1. **CORS ne protège pas ton serveur.** Il s'applique dans le **navigateur**. Un `curl`, un script
86
+ Python, un agent — tout ce qui n'est pas un navigateur — ignore CORS et reçoit ta réponse en entier.
87
+ La protection du serveur, c'est le [firewall](./firewall.md) et l'authentification.
88
+ 2. **La faille n'est donc jamais « CORS absent », mais « CORS trop permissif ».** Refléter
89
+ _n'importe quelle_ origine **avec credentials**, c'est autoriser tout site du web à lire les données
90
+ authentifiées de tes utilisateurs, avec leur propre cookie de session. C'est le vecteur n°1 des
91
+ fuites CORS recensées par l'OWASP.
92
+ 3. **CORS ≠ CSRF.** CORS régit la **lecture** cross-origin d'une réponse ; le [CSRF](./csrf.md) protège
93
+ l'**écriture** (une mutation déclenchée à l'insu du visiteur). Les deux se croisent : une origine
94
+ que tu autorises explicitement en CORS n'est pas traitée comme une tentative CSRF (voir plus bas).
95
+
96
+ > [!IMPORTANT]
97
+ > Ouvrir une origine en CORS, c'est autoriser **le JavaScript de cette origine à lire tes réponses**,
98
+ > pas juste « à t'appeler ». Si la route renvoie des données d'un utilisateur connecté et que
99
+ > `credentials` est actif, l'origine ajoutée devient de facto un lecteur légitime de ces données.
100
+
101
+ ## La vision Nodefony — une politique pure, fail-safe, verrouillée au boot
102
+
103
+ `Cors` (`cors.ts:33`) est une classe **pure et synchrone** : elle ne touche ni au réseau, ni à la
104
+ requête — elle prend une origine et renvoie la table des en-têtes à poser, ou `null`. Elle est
105
+ instanciée **une seule fois au boot** par le firewall, si et seulement si la section est activée
106
+ (`firewall.ts:213`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
107
+ requête se réduit à une lecture de `Set` (voir Performance).
108
+
109
+ Trois invariants de sécurité, tenus par construction :
110
+
111
+ - **Jamais `*` avec credentials.** La combinaison est **rejetée au boot** par un `refine` Zod
112
+ (`config.ts:144`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
113
+ instanciée à la main avec cette combinaison, `Cors.#allowOrigin()` **reflète l'origine** au lieu
114
+ d'émettre `*` (`cors.ts:58`).
115
+ - **Reflet d'origine ⇒ `Vary: Origin`.** Dès que la valeur `Allow-Origin` n'est pas `*`, la politique
116
+ ajoute `Vary` elle-même, sans que l'appelant ait à y penser (`Cors.reflectsOrigin()`, `cors.ts:63` ;
117
+ posé en `cors.ts:81` et `cors.ts:94`). Sans lui, un cache partagé servirait à une origine la réponse
118
+ taillée pour une autre — un empoisonnement de cache.
119
+ - **Origine inconnue ⇒ `null` ⇒ aucun en-tête.** Pas de message d'erreur, pas de 403 bavard
120
+ (`cors.ts:74`, `cors.ts:92`). La réponse part normalement mais n'est pas partageable : le navigateur
121
+ bloque, et un attaquant n'apprend **rien** sur le contenu de ton allowlist.
122
+
123
+ Le contrat d'entrée est `ICorsOptions` (`cors.ts:4`) — exactement le sous-ensemble `cors` de la config
124
+ du module, rien de plus. La sortie est une table nom → valeur (`CorsHeaders`, `cors.ts:15`) que
125
+ l'appelant recopie sur la réponse.
126
+
127
+ ## 🚀 Démarrage rapide
128
+
129
+ **Le besoin.** Ton API Nodefony sert `https://api.example.com`. Ton front est déployé sur
130
+ `https://app.example.com` — **une autre origine**. Il s'authentifie avec le cookie de session (BFF), et
131
+ il affiche une pagination qui lit un en-tête `X-Total-Count`. Sans configuration, le navigateur bloque
132
+ chaque appel du front.
133
+
134
+ ### 1. La config — dans `nodefony.config.ts`
135
+
136
+ ```typescript
137
+ // nodefony.config.ts (extrait généré par `nodefony create app`, puis complété)
138
+ use("@nodefony/security", {
139
+ cors: {
140
+ // Allowlist EXACTE : scheme + host + port. Un sous-domaine n'est PAS inclus.
141
+ origins: ["https://app.example.com"],
142
+ // Le front envoie le cookie de session → opt-in obligatoire des deux côtés.
143
+ // Avec credentials, `*` est refusé au boot : on liste les origines.
144
+ credentials: true,
145
+ // Ce que le JS du front a le droit de LIRE dans la réponse (au-delà des
146
+ // en-têtes « sûrs » que le navigateur expose toujours).
147
+ exposedHeaders: ["X-Total-Count"],
148
+ // Le navigateur met le résultat du preflight en cache 10 minutes.
149
+ maxAgeS: 600,
150
+ },
151
+ });
152
+ ```
153
+
154
+ ### 2. Le controller — rien de spécifique à CORS
155
+
156
+ ```typescript
157
+ // nodefony/controllers/ArticleController.ts — complet, compile tel quel
158
+ import { controller, Controller, Get } from "@nodefony/framework";
159
+
160
+ @controller("/api/articles")
161
+ class ArticleController extends Controller {
162
+ // Aucun code CORS ici : la politique est GLOBALE et posée AVANT le routing.
163
+ // Un preflight n'atteint même jamais cette classe.
164
+ @Get("/")
165
+ async list() {
166
+ return this.renderJson([{ id: 1, title: "Hello" }]);
167
+ }
168
+ }
169
+
170
+ export default ArticleController;
171
+ ```
172
+
173
+ ### 3. Ce qu'on observe
174
+
175
+ ```bash
176
+ # 1) LE PREFLIGHT — celui que le navigateur envoie tout seul avant un fetch non simple.
177
+ curl -si -X OPTIONS http://localhost:5151/api/articles \
178
+ -H 'Origin: https://app.example.com' \
179
+ -H 'Access-Control-Request-Method: GET'
180
+ # HTTP/1.1 204 No Content
181
+ # Access-Control-Allow-Origin: https://app.example.com
182
+ # Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
183
+ # Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
184
+ # Access-Control-Max-Age: 600
185
+ # Access-Control-Allow-Credentials: true
186
+ # Vary: Origin
187
+ ```
188
+
189
+ ```bash
190
+ # 2) LA REQUÊTE RÉELLE — Expose-Headers apparaît ici, jamais au preflight.
191
+ curl -si http://localhost:5151/api/articles -H 'Origin: https://app.example.com'
192
+ # HTTP/1.1 200 OK
193
+ # Access-Control-Allow-Origin: https://app.example.com
194
+ # Access-Control-Allow-Credentials: true
195
+ # Access-Control-Expose-Headers: X-Total-Count
196
+ # Vary: Origin
197
+
198
+ # 3) UNE ORIGINE INCONNUE — la réponse part, SANS aucun en-tête CORS.
199
+ curl -si http://localhost:5151/api/articles -H 'Origin: https://evil.com'
200
+ # HTTP/1.1 200 OK
201
+ # (aucun Access-Control-* → le navigateur refuse de livrer le corps au JS ;
202
+ # curl, lui, voit tout : CORS s'applique dans le navigateur, pas au serveur)
203
+ ```
204
+
205
+ ## ⚙️ Configuration et mises en situation
206
+
207
+ La section `cors` de la config du module (`corsSchema`, `config.ts:117` ; branchée à la racine en
208
+ `config.ts:117`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
209
+
210
+ <!-- prettier-ignore -->
211
+ | Option | Type | Défaut | Effet |
212
+ | --- | --- | --- | --- |
213
+ | `enabled` | `boolean` | `true` | `false` ⇒ aucune politique instanciée, `handleCors` est un no-op total. |
214
+ | `origins` | `string[]` | `[]` | L'allowlist. **`[]` = aucune origine autorisée** (fermé par défaut). |
215
+ | `credentials` | `boolean` | `false` | Autorise cookies/`Authorization` cross-origin. Interdit avec `origins:["*"]`. |
216
+ | `methods` | `string[]` | `GET, POST, PUT, PATCH, DELETE, OPTIONS` | Annoncées au preflight via `Access-Control-Allow-Methods`. |
217
+ | `allowedHeaders` | `string[]` | `Authorization, Content-Type, X-Requested-With` | En-têtes de requête que le front a le droit d'envoyer. |
218
+ | `exposedHeaders` | `string[]` | `[]` | En-têtes de **réponse** que le JS a le droit de lire. Requête réelle seulement. |
219
+ | `maxAgeS` | `int` | `600` | Durée de cache du preflight, en secondes. |
220
+
221
+ > [!TIP]
222
+ > Le défaut `origins: []` avec `enabled: true` n'est pas une incohérence : la politique existe, mais
223
+ > refuse **toutes** les origines. Une app qui ne configure rien n'a donc aucune ouverture cross-origin
224
+ > accidentelle — il faut un geste explicite pour ouvrir.
225
+
226
+ ### Situation 1 — un front sur un autre domaine, avec session (le cas courant)
227
+
228
+ Ton SPA est sur `https://app.example.com`, ton API sur `https://api.example.com`, et l'utilisateur est
229
+ connecté par cookie. Il faut **deux** opt-ins : le serveur (`credentials: true`) et le client
230
+ (`fetch(url, { credentials: "include" })`).
231
+
232
+ ```typescript
233
+ cors: {
234
+ origins: ["https://app.example.com"],
235
+ credentials: true,
236
+ }
237
+ ```
238
+
239
+ | Le client envoie… | Le serveur répond… |
240
+ | ---------------------------------------- | ------------------------------------------------------------------------------------ |
241
+ | `Origin: https://app.example.com` | `Allow-Origin: https://app.example.com` + `Allow-Credentials: true` + `Vary: Origin` |
242
+ | `Origin: https://app.example.com:8443` | rien — le **port** fait partie de l'origine, match exact |
243
+ | `Origin: https://sub.app.example.com` | rien — un sous-domaine n'est **pas** couvert par le parent |
244
+ | `Origin: http://app.example.com` | rien — le **scheme** fait partie de l'origine (downgrade refusé) |
245
+ | aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:997`) |
246
+
247
+ ### Situation 2 — une API publique en lecture seule (le joker `*`)
248
+
249
+ Une API de documentation, de statut, de tarifs : pas d'identité, pas de cookie, n'importe qui peut la
250
+ lire depuis n'importe quelle page.
251
+
252
+ ```typescript
253
+ cors: {
254
+ origins: ["*"],
255
+ credentials: false, // OBLIGATOIRE avec le joker
256
+ }
257
+ ```
258
+
259
+ Ici, et seulement ici, la politique émet littéralement `*` (`cors.ts:58`) — et donc **pas de
260
+ `Vary: Origin`** : la réponse est identique pour tout le monde, elle est cachable telle quelle.
261
+
262
+ ### Situation 3 — le contre-exemple piégeux : `*` + credentials
263
+
264
+ C'est la configuration qu'on écrit « pour débloquer le dev » et qui devient une fuite en production.
265
+
266
+ ```typescript
267
+ // ❌ REFUSÉ AU BOOT — l'app ne démarre pas
268
+ cors: { origins: ["*"], credentials: true }
269
+
270
+ // ✅ Lister les origines explicitement
271
+ cors: { origins: ["https://app.example.com", "https://admin.example.com"], credentials: true }
272
+ ```
273
+
274
+ > [!WARNING]
275
+ > `origins: ["*"]` **avec** `credentials: true` est rejeté par le schéma Zod au démarrage
276
+ > (`config.ts:144`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
277
+ > combinaison, si un serveur la contournait en reflétant chaque origine, laisserait **tout site du
278
+ > web** lire les réponses authentifiées de tes utilisateurs. Ne « corrige » jamais cette erreur en
279
+ > reflétant l'origine reçue sans la valider.
280
+
281
+ ### Situation 4 — le front doit lire un en-tête que tu ajoutes
282
+
283
+ Ton API pagine et renvoie `X-Total-Count`. Le JS appelle `response.headers.get("X-Total-Count")` et
284
+ récupère… `null`. Ce n'est pas un bug : le navigateur n'expose au JS qu'une poignée d'en-têtes sûrs.
285
+ Tout le reste doit être **déclaré**.
286
+
287
+ ```typescript
288
+ cors: {
289
+ origins: ["https://app.example.com"],
290
+ exposedHeaders: ["X-Total-Count", "X-Request-Id", "ETag"],
291
+ }
292
+ ```
293
+
294
+ Cet en-tête n'est posé **que sur la requête réelle** (`cors.ts:96`), jamais sur le preflight — un
295
+ preflight ne transporte pas de corps, il n'y a rien à exposer. Symétrie à ne pas confondre :
296
+ `allowedHeaders` = ce que le front a le droit d'**envoyer** ; `exposedHeaders` = ce qu'il a le droit de
297
+ **lire**.
298
+
299
+ ## 🏗️ Architecture interne — où la politique s'insère
300
+
301
+ ```mermaid
302
+ sequenceDiagram
303
+ participant B as Navigateur
304
+ participant K as HttpKernel.handleHttp
305
+ participant F as Firewall.handleCors
306
+ participant C as Cors (politique pure)
307
+ participant R as Router / Controller
308
+
309
+ B->>K: OPTIONS /api/articles (Origin + Access-Control-Request-Method)
310
+ K->>F: handleCors(context)
311
+ F->>C: preflightHeaders(origin)
312
+ C-->>F: table d'en-têtes (ou null)
313
+ F-->>K: 204
314
+ K-->>B: 204 + Access-Control-* (le Router n'a jamais été appelé)
315
+
316
+ B->>K: GET /api/articles (Origin)
317
+ K->>F: handleCors(context)
318
+ F->>C: actualHeaders(origin)
319
+ C-->>F: table d'en-têtes (ou null)
320
+ F-->>K: undefined
321
+ K->>R: routing, firewall, controller…
322
+ R-->>B: 200 + Access-Control-*
323
+ ```
324
+
325
+ `Firewall.handleCors()` (`firewall.ts:991`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
+ (`http-kernel.ts:1258`), à la ligne `http-kernel.ts:1258` — **avant le routing**. La raison est
327
+ concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
328
+ router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
329
+ credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
330
+
331
+ Quatre sorties en no-op, dans cet ordre (`firewall.ts:797`) :
332
+
333
+ 1. CORS désactivé ⇒ `#cors` est `null`, retour immédiat ;
334
+ 2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:587`) ;
335
+ 3. la réponse n'expose pas `setHeader` ⇒ c'est un **WebSocket**, il n'y a pas d'en-tête HTTP à poser
336
+ (`firewall.ts:1013`) ;
337
+ 4. origine hors allowlist ⇒ la table est `null`, aucun en-tête n'est posé — mais un preflight reste
338
+ court-circuité en 204 (`firewall.ts:822`).
339
+
340
+ **La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
341
+ `Access-Control-Request-Method` (`firewall.ts:808`). Un `OPTIONS` nu — celui d'un client qui interroge
342
+ les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
343
+
344
+ ### Ce que chaque moment pose
345
+
346
+ | Moment | Déclencheur | En-têtes posés | Ancrage |
347
+ | ------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------ |
348
+ | **Preflight** | `OPTIONS` + `Access-Control-Request-Method` | `Allow-Origin`, `Allow-Methods`, `Allow-Headers`, `Max-Age` (+ `Vary`, + `Allow-Credentials`) | `cors.ts:72` |
349
+ | **Requête réelle** | tout le reste, avec un `Origin` | `Allow-Origin` (+ `Vary`, + `Allow-Credentials`, + **`Expose-Headers`**) | `cors.ts:90` |
350
+
351
+ Deux nuances utiles :
352
+
353
+ - **`Allow-Headers` est statique**, dérivé de la config — la politique ne réfléchit pas le
354
+ `Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
355
+ point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
356
+ - **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
357
+ (`http-kernel.ts:1312`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
358
+ tes routes.
359
+
360
+ Le contrat est publié dans l'interface du firewall (`IFirewall.ts:34`) : `number | undefined` — `204`
361
+ signifie « je suis un preflight, réponds et arrête-toi ».
362
+
363
+ ## 🛡️ CORS, CSRF et en-têtes de sécurité — qui protège quoi
364
+
365
+ Trois briques voisines, souvent confondues. Une seule ligne chacune :
366
+
367
+ | Brique | Régit… | S'applique… | Menace bloquée |
368
+ | ---------------------------------------- | -------------------------------------- | ---------------------- | ------------------------------------ |
369
+ | **CORS** | la **lecture** cross-origin | dans le navigateur | fuite de données authentifiées |
370
+ | **[CSRF](./csrf.md)** | l'**écriture** cross-site | au serveur (rejet 403) | mutation déclenchée à l'insu du user |
371
+ | **[En-têtes](./headers.md)** (CSP, COOP) | ce que la **page** a le droit de faire | dans le navigateur | XSS, injection, fenêtres croisées |
372
+
373
+ Les deux premières se parlent. Au boot, la liste des origines de confiance CSRF est l'**union** de
374
+ `csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:589`) : ce que tu autorises explicitement en
375
+ CORS ne peut pas être, au même instant, traité comme une tentative CSRF.
376
+
377
+ L'inverse n'est pas vrai, et c'est délibéré : `csrf.trustedOrigins` déclare un **alias de domaine**
378
+ légitime de ton app (une façade multi-domaine) sans pour autant exposer tes réponses au JS de cette
379
+ origine (`config.ts:180`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
380
+ `csrf.trustedOrigins`.
381
+
382
+ ## 🔌 Et le WebSocket ?
383
+
384
+ **Les navigateurs n'appliquent pas CORS aux WebSockets.** Une page tierce peut ouvrir un
385
+ `new WebSocket("wss://api.example.com/…")` et le handshake partira **avec le cookie de session de la
386
+ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un contexte WS
387
+ (`firewall.ts:991`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
388
+
389
+ La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
390
+ (`http-kernel.ts:599`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
391
+ si elle est refusée. Sa doctrine :
392
+
393
+ - **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
394
+ - **loopback toléré en development** uniquement, pour le cross-port Vite ↔ serveur ;
395
+ - **allowlist explicite** `allowedOrigins` par type de serveur pour une SPA cross-origin en production
396
+ (compilée une seule fois puis mémoïsée) ;
397
+ - **pas d'`Origin` ⇒ accepté** : un client non-navigateur n'a aucun besoin de CSWSH, il se connecte
398
+ directement — refuser ne protégerait personne et casserait tous les clients légitimes.
399
+
400
+ Retenir : `cors.origins` ouvre le **HTTP**, `allowedOrigins` (config `@nodefony/http`) ouvre le **WS**.
401
+ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
402
+
403
+ ## 📜 Normes appliquées
404
+
405
+ | Sujet | Norme | Comment le code s'y conforme |
406
+ | --------------------------------- | --------------------------- | --------------------------------------------------------------------------- |
407
+ | Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts:33`) — preflight vs requête réelle séparés |
408
+ | Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:822`) |
409
+ | `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:144`) + reflet défensif (`cors.ts:58`) |
410
+ | Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
411
+ | Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
412
+ | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:599`) |
413
+
414
+ ## ⚡ Performance & mémoire
415
+
416
+ La politique est **précalculée au boot** : les listes `methods`, `allowedHeaders`, `exposedHeaders` et
417
+ `maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42`), jamais par requête. Il
418
+ ne reste à l'exécution qu'un `Set.has()` sur l'origine.
419
+
420
+ Le coût par requête est donc :
421
+
422
+ - **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:997`) ;
423
+ - **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:1013`) ;
424
+ - **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts:166`) ;
425
+ - **une petite table d'en-têtes** allouée uniquement pour une requête cross-origin autorisée. Une
426
+ origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts:74`).
427
+
428
+ ## 📡 Observabilité — Studio
429
+
430
+ La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
431
+ `Firewall.describe()` (`firewall.ts:505`), qui délègue à `Firewall.#describeDefenses()`
432
+ (`firewall.ts:575`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
+ `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:594`) — aucun secret ne transite par
434
+ cette surface.
435
+
436
+ - **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:348`), protégé
437
+ `ROLE_NODEFONY_ADMIN`.
438
+ - **Écran** : console **Firewall** → section _Défenses_ (`FirewallDefenses`,
439
+ `FirewallDefenses.tsx:114`), carte CORS à côté des cartes CSRF, en-têtes et throttle.
440
+ - **Schéma de config** : `securityConfigJsonSchema()` alimente le formulaire d'édition de Studio —
441
+ la section `cors` y apparaît avec ses libellés et défauts, sans UI écrite à la main.
442
+
443
+ Utile en incident : comparer ce que Studio affiche avec ce que tu crois avoir déployé règle en dix
444
+ secondes les « pourtant j'ai bien mis l'origine ».
445
+
446
+ ## ⚠️ Pièges (symptôme → cause → correction)
447
+
448
+ | Symptôme | Cause | Correction |
449
+ | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
450
+ | Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:144`) | Lister les origines, ou passer `credentials:false` |
451
+ | Requête bloquée alors que l'origine « est » dans la liste | Match **exact** : port, scheme ou sous-domaine divergent | Écrire l'origine complète `scheme://host:port`, une entrée par variante |
452
+ | `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur | Normal — reproduire avec un `Origin` explicite (`curl -H 'Origin: …'`) |
453
+ | Cookie non envoyé malgré `credentials:true` | Opt-in serveur seul : le client n'a pas `credentials: "include"` | Activer les deux côtés (et un cookie `SameSite=None; Secure` en cross-site) |
454
+ | `response.headers.get("X-…")` renvoie `null` | En-tête non déclaré dans `exposedHeaders` (`cors.ts:96`) | L'ajouter à `cors.exposedHeaders` |
455
+ | Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
456
+ | Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
457
+ | `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
458
+ | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:599`) — vérifier `allowedOrigins` |
459
+ | Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
460
+
461
+ ## 🧪 Tests & couverture
462
+
463
+ Trois familles couvrent la brique — les compteurs exacts vivent dans la carte de l'aperçu, régénérée
464
+ depuis vitest, jamais figés ici :
465
+
466
+ - **unitaires** — `cors.test.ts` (`src/packages/@nodefony/security/tests/unit/`) : la matrice
467
+ fonctionnelle de la politique pure. Allowlist et reflet, joker sans credentials, credentials
468
+ (reflet obligatoire, jamais `*`), `exposedHeaders` présent sur la requête réelle et **absent** du
469
+ preflight, `reflectsOrigin`.
470
+ - **attaque (red-team)** — `cors.attack.test.ts` (même dossier), dérivé de la **menace** et non de
471
+ l'implémentation. Vecteur central : le _allowlist bypass_ — neuf origines qu'un comparateur naïf
472
+ (sous-chaîne, préfixe, `endsWith`, casse, slash final, `userinfo`, port ajouté, downgrade de scheme)
473
+ accepterait à tort, plus le cas `Origin: null` des iframes sandbox et redirections. Chacune doit
474
+ renvoyer `null` au preflight **et** à la requête réelle. Un contrôle **positif** garde le banc
475
+ honnête : sans lui, « tout refuser » serait trivialement vert.
476
+ - **intégration (serveur réel)** — `cors.test.ts` (`src/packages/@nodefony/http/nodefony/tests/http/`) :
477
+ le câblage de bout en bout sur le serveur live, origine de confiance `https://trusted.example`.
478
+ Preflight autorisé → 204 + en-têtes + `Vary` ; preflight non autorisé → 204 **sans** `Allow-Origin` ;
479
+ requête réelle reflétée ; requête same-origin sans aucun en-tête CORS.
480
+
481
+ Ce qui **n'existe pas** et n'est pas nécessaire : pas de test de charge dédié à CORS (le chemin chaud
482
+ est un `Set.has()` couvert par les bancs de charge HTTP généraux), pas de banc de contrat multi-backend
483
+ (la brique ne persiste rien, elle n'a pas d'adapter).
484
+
485
+ Couverture : `npm run coverage` dans `@nodefony/security`. Revue de sécurité de la brique → skill
486
+ `nodefony-security-review` (mode red/blue-team), conformité aux normes → skill `nodefony-rfc`.
487
+
488
+ ## 🔗 Pour aller plus loin
489
+
490
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
491
+ - 🧭 **Pages sœurs** : [En-têtes de sécurité](headers.md) · [CSRF](csrf.md)
492
+
493
+ - Protéger les **mutations** cross-site (distinct de CORS) → [csrf](./csrf.md)
494
+ - Le pare-feu qui pose les en-têtes et court-circuite le preflight → [firewall](./firewall.md)
495
+ - CSP, HSTS, COOP/COEP/CORP → [headers](./headers.md)
496
+ - Vue d'ensemble du module → [index](./index.md)
497
+ - Où CORS s'insère dans le pipeline → [pipeline-requete](../../../../../docs/architecture/pipeline-requete.md)