@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
@@ -0,0 +1,1016 @@
1
+ ---
2
+ title: "Webhooks — notifier un système tiers, signé et borné"
3
+ navTitle: Webhooks
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: webhooks
7
+ coverageModule: security
8
+ coverageFiles: "webhook"
9
+ section: "Sécurité"
10
+ audience: [developer]
11
+ tags:
12
+ [
13
+ webhooks,
14
+ standard-webhooks,
15
+ hmac,
16
+ signature,
17
+ ssrf,
18
+ retry,
19
+ backoff,
20
+ audit,
21
+ owasp,
22
+ ]
23
+ version: "doc"
24
+ status: stable
25
+ updated: 2026-07-19
26
+ source: "src/packages/@nodefony/security/docs/webhooks.md"
27
+ ---
28
+
29
+ # Webhooks — notifier un système tiers, signé et borné
30
+
31
+ > Un webhook, c'est **ton serveur qui appelle celui de quelqu'un d'autre** pour dire « il vient de se
32
+ > passer quelque chose ». L'inversion est totale par rapport à une API : ce n'est plus le tiers qui
33
+ > interroge, c'est toi qui pousses. Deux dangers naissent de cette inversion — le destinataire doit
34
+ > pouvoir **prouver** que le message vient bien de toi (signature HMAC), et l'URL de destination,
35
+ > fournie par un humain, ne doit jamais devenir un **levier vers ton réseau interne** (SSRF). Ancré
36
+ > sur `src/packages/@nodefony/security/nodefony/service/webhooks.ts`,
37
+ > `nodefony/src/webhook/` et `nodefony/src/net/ssrfGuard.ts`.
38
+
39
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Webhooks**
40
+
41
+ ## 🧠 Le modèle mental — un abonné du journal d'audit
42
+
43
+ Nodefony n'a pas de « bus d'événements métier » derrière ses webhooks. Le dispatcher est **un abonné
44
+ du journal d'audit de sécurité** : ce qui part est exactement ce que l'audit enregistre (login,
45
+ refus d'accès, jeton révoqué, session ouverte…), rien d'autre.
46
+
47
+ ```mermaid
48
+ flowchart LR
49
+ AUD["AuditService.record()<br/>événement de sécurité"] --> D{"des endpoints<br/>abonnés ?"}
50
+ D -->|non| STOP["retour immédiat<br/>0 allocation"]
51
+ D -->|oui| Q["file bornée<br/>+ pool borné"]
52
+ Q --> SIG["SSRF re-vérifié<br/>+ signature HMAC"]
53
+ SIG --> POST["POST vers le tiers"]
54
+ POST -->|2xx| OK["livré"]
55
+ POST -->|réessayable| R["backoff exponentiel"]
56
+ POST -->|définitif| FAIL["abandon + compteur d'échecs"]
57
+ R --> Q
58
+ ```
59
+
60
+ Deux frontières décident de tout : **`WebhookDispatcher.onAuditEvent()`**
61
+ (`WebhookDispatcher.ts:132`) qui filtre sans jamais bloquer l'émetteur, et
62
+ **`WebhookDispatcher.#process()`** (`WebhookDispatcher.ts:189`) qui signe, livre et classe l'issue.
63
+ Le détail de chaque étape est plus bas, dans **Architecture interne**.
64
+
65
+ ### À quoi ça sert, concrètement
66
+
67
+ Trois usages courants, tous branchés sur des événements que Nodefony émet déjà :
68
+
69
+ | Ce que tu veux | Tu abonnes | Le tiers qui reçoit |
70
+ | -------------------------------------------------------------------------- | ------------------------------------ | ---------------------------------- |
71
+ | Être prévenu quand quelqu'un s'acharne sur un compte | `login.failure` | un canal Slack, un SMS d'astreinte |
72
+ | Garder une trace inviolable des accès, hors de l'application | `*` | un SIEM, un bucket d'archives |
73
+ | Couper l'accès d'un salarié partout ailleurs quand sa session est révoquée | `token.revoked`, `session.destroyed` | ton annuaire, ton outil de tickets |
74
+
75
+ Le premier, en entier — un serveur qui prévient une équipe quand un compte est attaqué :
76
+
77
+ ```bash
78
+ # 1. On s'abonne aux échecs de connexion. Le secret n'est montré QU'ICI.
79
+ curl -sk -b /tmp/jar -X POST https://localhost:5152/nodefony/security/api/webhooks \
80
+ -H 'content-type: application/json' \
81
+ -d '{"url":"https://alertes.exemple.com/nodefony","events":["login.failure"],
82
+ "description":"Alerte tentatives de connexion"}'
83
+ # → {"endpoint":{"id":"wh_9Xq2…"},"secret":"whsec_Zm9vYmFy…"}
84
+ ```
85
+
86
+ À la cinquième tentative ratée d'« alice », le serveur d'alertes reçoit ceci — et **rien d'autre** ne
87
+ part (les autres événements ne sont pas souscrits) :
88
+
89
+ ```json
90
+ {
91
+ "id": "msg_7Yb1kQ2pR8sT",
92
+ "type": "login.failure",
93
+ "data": {
94
+ "actor": "alice",
95
+ "outcome": "failure",
96
+ "reason": "invalid_credentials"
97
+ }
98
+ }
99
+ ```
100
+
101
+ > [!NOTE]
102
+ > Ce qui peut partir est **ce que le journal d'audit de sécurité enregistre** : connexions, refus
103
+ > d'accès, jetons, sessions, passkeys. Tes propres événements applicatifs — « commande payée »,
104
+ > « stock épuisé » — ne passent pas par là : il n'existe pas encore de bus d'événements métier dans
105
+ > Nodefony. C'est une limite, pas un oubli, et elle est répétée plus bas.
106
+
107
+ ## 📖 Lexique
108
+
109
+ | Terme | Sens |
110
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
111
+ | Webhook | Notification HTTP sortante : ton serveur `POST` un événement vers l'URL d'un tiers. |
112
+ | Endpoint | Une **destination** enregistrée : URL + secret de signature + liste d'événements souscrits. |
113
+ | Standard Webhooks | Convention publique de signature de webhook (`webhook-id`, `webhook-timestamp`, `webhook-signature`) — standardwebhooks.com. |
114
+ | HMAC | _Hash-based Message Authentication Code_ (RFC 2104) : empreinte calculée avec un secret partagé — prouve l'émetteur. |
115
+ | SSRF | _Server-Side Request Forgery_ : faire émettre au serveur une requête vers une cible **interne** (loopback, `10.x`, métadonnées cloud). |
116
+ | DNS rebinding | Le DNS répond une IP publique au contrôle, puis une IP privée à la connexion — d'où le **pin** de l'IP validée. |
117
+ | Backoff exponentiel | Délai de réessai qui double à chaque tentative, jusqu'à un plafond. |
118
+ | Anti-rejeu | Refuser un message déjà vu / trop ancien — ici via `webhook-id` + `webhook-timestamp`, **couverts par la signature**. |
119
+ | Auto-désactivation | Un endpoint qui échoue N fois d'affilée est désactivé automatiquement (façon GitHub). |
120
+ | PAT / secret `whsec_…` | Le secret de signature partagé avec le destinataire ; **chiffré au repos**, jamais haché (le serveur doit le relire). |
121
+
122
+ ## Qu'est-ce que c'est ? — et quelles failles ça ferme
123
+
124
+ Le mécanisme est banal : un `POST` JSON. Ce qui est difficile, c'est le contexte hostile des **deux
125
+ côtés de la ligne**.
126
+
127
+ **Côté destinataire — « qui m'écrit ? »** Une URL de webhook est publique par construction :
128
+ n'importe qui peut la découvrir et lui envoyer un faux « paiement validé ». Sans preuve
129
+ cryptographique, le destinataire ne peut pas distinguer ton serveur d'un attaquant. La signature
130
+ HMAC ferme cette faille : seul le porteur du secret partagé peut produire l'empreinte du corps exact.
131
+
132
+ **Côté émetteur — « où est-ce que j'écris ? »** L'URL de destination est saisie par un administrateur
133
+ dans une console. Si le serveur l'appelle sans contrôle, un administrateur (ou un compte compromis)
134
+ peut le transformer en proxy vers `http://169.254.169.254/` — l'endpoint de métadonnées d'une VM
135
+ cloud, qui livre les **credentials IAM** de la machine. C'est la faille **SSRF** (OWASP A10:2021),
136
+ et elle est bien plus grave qu'elle n'en a l'air : elle traverse le pare-feu réseau par définition,
137
+ puisque c'est ton propre serveur qui fait l'appel.
138
+
139
+ **Côté framework — « et si le destinataire est mort ? »** Un endpoint qui ne répond jamais tient une
140
+ socket ouverte. Multiplié par un pic d'événements, c'est une saturation de descripteurs de fichiers
141
+ et une croissance mémoire illimitée : un tiers défaillant devient un **déni de service sur ton
142
+ propre serveur**.
143
+
144
+ ## La vision Nodefony — honnête sur la source, dur sur les bornes
145
+
146
+ Trois partis pris, tous vérifiables dans le code :
147
+
148
+ 1. **La source est le journal d'audit, pas un bus métier.** `WebhookService.#attachDispatcher()`
149
+ (`webhooks.ts:185`) s'abonne au service `auditService` et à lui seul. Si l'audit est absent, le
150
+ dispatcher n'existe pas — le CRUD d'endpoints reste disponible, mais **rien ne part**. C'est une
151
+ limite assumée : les webhooks notifient des **événements de sécurité**.
152
+ 2. **Le secret est chiffré, jamais haché.** Contrairement à une clé d'API (qu'on vérifie donc qu'on
153
+ peut hacher), le serveur doit **relire** le secret pour signer chaque livraison → AES-256-GCM avec
154
+ une clé dérivée HKDF propre au domaine webhook (`deriveWebhookKey()`, `webhookCipher.ts:30`).
155
+ 3. **Le tiers ne peut pas nuire au framework.** File bornée, concurrence bornée, historique borné,
156
+ abandon annoncé : le dispatcher est écrit pour qu'un endpoint mort coûte un log, pas une panne
157
+ (`WebhookDispatcher.#enqueue()`, `WebhookDispatcher.ts:146`).
158
+
159
+ ## 🚀 Démarrage rapide
160
+
161
+ ### 1. Activer les webhooks et poser la clé de chiffrement
162
+
163
+ Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts:654`).
164
+ La seule chose que tu dois vraiment fournir, c'est la **clé de chiffrement des secrets de signature** :
165
+ sans elle, une clé éphémère est générée en dev (avec un WARNING), et en production les webhooks sont
166
+ **désactivés** — un secret chiffré par une clé perdue au redémarrage serait illisible
167
+ (`WebhookService.#resolveKey()`, `webhooks.ts:299`).
168
+
169
+ ```bash
170
+ # Génère les clés du module security et guide le câblage en 3 fichiers.
171
+ npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env.local
172
+ ```
173
+
174
+ ```typescript
175
+ // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
176
+ // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
177
+ import { defineConfig, defineEnv, envString, use } from "nodefony";
178
+
179
+ export const env = defineEnv({
180
+ // Clé de chiffrement des secrets de signature — `nodefony security:secrets`.
181
+ NF_WEBHOOK_KEY: envString({ optional: true }),
182
+ });
183
+
184
+ export default defineConfig<typeof env>((ctx) => ({
185
+ modules: [
186
+ use("@nodefony/security", {
187
+ webhooks: {
188
+ // Clé AES (32 octets base64) — depuis l'environnement, JAMAIS en dur.
189
+ encryptionKey: ctx.env.NF_WEBHOOK_KEY,
190
+ // Registre des endpoints : "auto" suit l'infra database déclarée
191
+ // (repli memory ANNONCÉ) ; "drizzle"/"mongoose" pour un choix explicite.
192
+ store: "auto",
193
+ // En dev seulement : viser un récepteur local en http://127.0.0.1.
194
+ // En prod, ces deux défauts stricts protègent du SSRF.
195
+ denyPrivateIps: ctx.isProd,
196
+ allowHttp: !ctx.isProd,
197
+ },
198
+ }),
199
+ "@nodefony/framework",
200
+ ],
201
+ }));
202
+ ```
203
+
204
+ > [!WARNING]
205
+ > `denyPrivateIps: false` **désactive tout le contrôle d'IP** : le garde retourne immédiatement sur
206
+ > `allowPrivate`, sans résoudre le DNS ni comparer quoi que ce soit (`ssrfGuard.ts:153`). C'est un
207
+ > réglage de poste de développement, à ne jamais laisser fuiter en production.
208
+
209
+ ### 2. Enregistrer un abonnement (API d'administration)
210
+
211
+ Il n'y a **pas de déclaration d'endpoint en config** : un abonnement est une donnée, créée à
212
+ l'exécution via le data plane admin `/nodefony/security/api/webhooks`, gardé
213
+ `ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts:205`).
214
+
215
+ ```bash
216
+ # Session admin (le BFF de login pose le cookie)
217
+ curl -sk -c /tmp/jar -H 'Content-Type: application/json' \
218
+ -d '{"username":"admin","password":"admin"}' \
219
+ https://localhost:5152/nodefony/security/api/auth/login > /dev/null
220
+
221
+ # Créer l'abonnement : URL + actions souscrites ("*" = toutes)
222
+ curl -sk -b /tmp/jar -H 'Content-Type: application/json' \
223
+ -d '{"url":"https://hooks.example.com/nodefony",
224
+ "events":["login.success","login.failure","access.denied"],
225
+ "description":"SIEM"}' \
226
+ https://localhost:5152/nodefony/security/api/webhooks
227
+ ```
228
+
229
+ ```json
230
+ {
231
+ "endpoint": { "id": "wh_9Xq2…", "url": "https://hooks.example.com/nodefony", "enabled": true, "failureCount": 0, … },
232
+ "secret": "whsec_5m1n…"
233
+ }
234
+ ```
235
+
236
+ > [!IMPORTANT]
237
+ > Le champ `secret` n'apparaît **qu'ici**, une seule fois. C'est lui qu'on colle dans la
238
+ > configuration du destinataire. Perdu, il ne se retrouve pas : il se **fait révéler** par un admin
239
+ > (`POST …/webhooks/{id}/reveal`, audité) ou il se **remplace** par une rotation.
240
+
241
+ ### 3. Le récepteur — le strict minimum d'abord
242
+
243
+ C'est la moitié que **tu** écris, côté destinataire. Voici la version courte : elle tient en une
244
+ vingtaine de lignes et fait le seul geste indispensable — **recalculer l'empreinte sur les octets
245
+ reçus**.
246
+
247
+ ```typescript
248
+ // nodefony/controller/HookMiniController.ts — récepteur minimal
249
+ import { createHmac, timingSafeEqual } from "node:crypto";
250
+ import { Buffer } from "node:buffer";
251
+ // prettier-ignore
252
+ import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
253
+
254
+ const SECRET = (process.env.NODEFONY_HOOK_SECRET ?? "").replace(/^whsec_/, "");
255
+
256
+ @controller("/hooks")
257
+ class HookMiniController extends Controller {
258
+ @BypassFirewall // une livraison arrive sans session : c'est la signature qui authentifie
259
+ @Post("/mini")
260
+ async receive(
261
+ @Body({ stream: true }) stream: NodeJS.ReadableStream,
262
+ @Headers() h: Record<string, string | string[] | undefined>,
263
+ ) {
264
+ const chunks: Buffer[] = [];
265
+ for await (const c of stream) chunks.push(Buffer.from(c as Buffer));
266
+ const raw = Buffer.concat(chunks).toString("utf8");
267
+
268
+ const got = Buffer.from(String(h["webhook-signature"] ?? "").slice(3)); // après "v1,"
269
+ const want = Buffer.from(
270
+ createHmac("sha256", Buffer.from(SECRET, "base64"))
271
+ .update(`${h["webhook-id"]}.${h["webhook-timestamp"]}.${raw}`)
272
+ .digest("base64"),
273
+ );
274
+ if (got.length !== want.length || !timingSafeEqual(got, want)) {
275
+ return this.renderJson({ error: "bad signature" }, 401);
276
+ }
277
+ this.log(`reçu : ${(JSON.parse(raw) as { type: string }).type}`, "INFO");
278
+ return this.renderJson({ ok: true });
279
+ }
280
+ }
281
+
282
+ export default HookMiniController;
283
+ ```
284
+
285
+ > [!WARNING]
286
+ > **Pourquoi le corps est lu en flux (`@Body({ stream: true })`) même dans la version minimale** :
287
+ > l'empreinte porte sur les **octets exacts** envoyés. Un corps parsé puis re-sérialisé
288
+ > (`JSON.stringify`) change d'espaces ou d'ordre de clés et **toutes** les signatures deviennent
289
+ > invalides — c'est l'erreur n°1 des intégrations de webhooks. Le `timingSafeEqual` n'est pas
290
+ > négociable non plus : comparer avec `===` laisse fuiter la signature attendue, caractère par
291
+ > caractère, par le temps de réponse.
292
+ >
293
+ > Ce récepteur minimal ne fait **que** vérifier l'empreinte. Il ne refuse pas un message rejoué ni
294
+ > un message vieux d'un mois. Pour la production, prends la version complète ci-dessous.
295
+
296
+ ### La version complète — anti-rejeu, multi-signature, déduplication
297
+
298
+ Trois règles s'ajoutent : refuser un horodatage hors fenêtre (**anti-rejeu**), accepter une
299
+ signature parmi **plusieurs** (le temps d'une rotation de secret), et **dédupliquer** par
300
+ `webhook-id` avant d'agir — un réessai rejoue le même identifiant, et livrer deux fois une commande
301
+ n'est pas la même chose que la livrer une fois.
302
+
303
+ ```typescript
304
+ // nodefony/controller/HookController.ts — récepteur complet, compile tel quel
305
+ import { createHmac, timingSafeEqual } from "node:crypto";
306
+ import { Buffer } from "node:buffer";
307
+ // prettier-ignore
308
+ import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
309
+
310
+ /** Secret `whsec_…` donné par l'émetteur — via l'environnement, jamais en dur. */
311
+ const SECRET = process.env.NODEFONY_HOOK_SECRET ?? "";
312
+ /** Fenêtre anti-rejeu (s) : un message plus vieux est refusé. */
313
+ const TOLERANCE_S = 300;
314
+
315
+ /** Premier élément d'un en-tête possiblement multi-valué. */
316
+ const one = (v: string | string[] | undefined): string =>
317
+ (Array.isArray(v) ? v[0] : v) ?? "";
318
+
319
+ /** Comparaison en temps constant — jamais `===` sur une signature. */
320
+ function safeEqual(a: string, b: string): boolean {
321
+ const [x, y] = [Buffer.from(a), Buffer.from(b)];
322
+ return x.length === y.length && timingSafeEqual(x, y);
323
+ }
324
+
325
+ @controller("/hooks")
326
+ class HookController extends Controller {
327
+ // Route PUBLIQUE : une livraison arrive sans session ni bearer.
328
+ // C'est la signature qui authentifie, pas le firewall.
329
+ @BypassFirewall
330
+ @Post("/nodefony")
331
+ async receive(
332
+ @Body({ stream: true }) stream: NodeJS.ReadableStream,
333
+ @Headers() headers: Record<string, string | string[] | undefined>,
334
+ ) {
335
+ // 1. Octets EXACTS reçus — le HMAC ne survit pas à un re-JSON.stringify.
336
+ const chunks: Buffer[] = [];
337
+ for await (const c of stream) {
338
+ chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string, "utf8"));
339
+ }
340
+ const raw = Buffer.concat(chunks).toString("utf8");
341
+ const id = one(headers["webhook-id"]);
342
+ const ts = one(headers["webhook-timestamp"]);
343
+ const sig = one(headers["webhook-signature"]);
344
+ if (!id || !ts || !sig) return this.renderJson({ error: "unsigned" }, 400);
345
+
346
+ // 2. Anti-rejeu. L'horodatage étant COUVERT par la signature, un attaquant
347
+ // ne peut pas le rajeunir pour rentrer dans la fenêtre.
348
+ const age = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
349
+ if (!Number.isFinite(age) || age > TOLERANCE_S) {
350
+ return this.renderJson({ error: "stale" }, 400);
351
+ }
352
+
353
+ // 3. Recalculer le HMAC sur `{id}.{timestamp}.{body}`. L'en-tête peut porter
354
+ // PLUSIEURS signatures séparées par un espace : accepter si l'une matche.
355
+ const b64 = SECRET.startsWith("whsec_") ? SECRET.slice(6) : SECRET;
356
+ const expected = createHmac("sha256", Buffer.from(b64, "base64"))
357
+ .update(`${id}.${ts}.${raw}`)
358
+ .digest("base64");
359
+ const ok = sig.split(" ").some((p) => {
360
+ const c = p.indexOf(",");
361
+ return (
362
+ c > 0 && p.slice(0, c) === "v1" && safeEqual(p.slice(c + 1), expected)
363
+ );
364
+ });
365
+ if (!ok) return this.renderJson({ error: "bad signature" }, 401);
366
+
367
+ // 4. Dédupliquer par `webhook-id` AVANT d'agir (un retry rejoue le MÊME id),
368
+ // puis répondre 2xx vite : le travail long part en tâche de fond.
369
+ const event = JSON.parse(raw) as { id: string; type: string };
370
+ this.log(`webhook ${event.id} — ${event.type}`, "INFO");
371
+ return this.renderJson({ ok: true });
372
+ }
373
+ }
374
+
375
+ export default HookController;
376
+ ```
377
+
378
+ ### 4. Ce qu'on observe
379
+
380
+ Sur le réseau, une livraison ressemble à ceci — trois en-têtes de signature, un corps enveloppé :
381
+
382
+ ```http
383
+ POST /nodefony HTTP/1.1
384
+ content-type: application/json
385
+ user-agent: Nodefony-Webhooks/1.0
386
+ webhook-id: msg_7Yb1kQ2pR8sT
387
+ webhook-timestamp: 1795000000
388
+ webhook-signature: v1,K9c0Zq8m…=
389
+
390
+ {"id":"msg_7Yb1kQ2pR8sT","timestamp":"2026-07-19T10:00:00.000Z",
391
+ "type":"login.failure",
392
+ "data":{"id":"a-3f","ts":1795000000000,"category":"auth","action":"login.failure",
393
+ "outcome":"failure","actor":"alice","reason":"invalid_credentials"}}
394
+ ```
395
+
396
+ Et côté serveur, l'historique des dernières livraisons se relit par l'API d'admin :
397
+
398
+ ```bash
399
+ curl -sk -b /tmp/jar https://localhost:5152/nodefony/security/api/webhooks/wh_9Xq2…/deliveries
400
+ # {"deliveries":[{"ts":…,"messageId":"msg_7Yb1…","type":"login.failure","attempt":0,
401
+ # "ok":false,"status":500,"error":"HTTP 500","durationMs":42, …}]}
402
+ ```
403
+
404
+ Un `attempt` supérieur à 0 signale que la trace enregistrée est celle d'une **issue finale après
405
+ retries** : les tentatives intermédiaires ne sont pas tracées, seule l'issue passe par
406
+ `recordDelivery()` (`WebhookDispatcher.ts:247`).
407
+
408
+ ## Quels événements partent en webhook ?
409
+
410
+ Réponse honnête et vérifiable : **les actions du journal d'audit de sécurité, et rien d'autre**. Le
411
+ dispatcher est branché sur `AuditService.subscribe()` (`auditService.ts:205`), appelé dans le
412
+ fire-and-forget de `AuditService.record()` (`auditService.ts:180`). Aucun autre point d'émission
413
+ n'existe dans le code.
414
+
415
+ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:16`) donnent la surface réelle :
416
+
417
+ | Catégorie | Ce qu'elle trace (exemples d'`action`) |
418
+ | ---------- | -------------------------------------------------------------------------- |
419
+ | `auth` | Login/logout, chaîne d'authentification (`login.success`, `login.failure`) |
420
+ | `authz` | Accès accordé/refusé, voters, `@IsGranted` (`access.denied`) |
421
+ | `token` | Jetons longue durée émis/révoqués — JWT refresh, PAT (`token.revoked`) |
422
+ | `session` | Cycle de vie de session (`session.opened`) |
423
+ | `oauth` | Login social : authorize, callback, provisioning JIT |
424
+ | `webauthn` | Passkeys : enregistrement, assertion |
425
+ | `csrf` | Défense CSRF déclenchée |
426
+ | `cors` | Preflight rejeté |
427
+ | `ws` | Verrou de frame WebSocket (`api.request` / `subscribe`) |
428
+ | `webhook` | Vie des webhooks eux-mêmes (`webhook.created`, `webhook.disabled`) |
429
+ | `config` | Mutation de config runtime depuis Studio |
430
+
431
+ ### La syntaxe d'abonnement
432
+
433
+ Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubscription()`
434
+ (`WebhookDispatcher.ts:30`) :
435
+
436
+ | Motif | Matche | Usage typique |
437
+ | ----------------- | ----------------------------------------------------- | ------------------------------- |
438
+ | `"*"` | **toutes** les actions | un SIEM qui veut tout |
439
+ | `"login.success"` | l'action exacte, et elle seule | une alerte ciblée |
440
+ | `"login.*"` | toute action **préfixée** `login.` (`login.failure`…) | suivre une famille d'événements |
441
+
442
+ > [!CAUTION]
443
+ > Les événements de catégorie `webhook` **ne déclenchent jamais de livraison**
444
+ > (`WebhookDispatcher.ts:136`), même avec un abonnement `"*"`. C'est une garde anti-amplification :
445
+ > sans elle, un échec de livraison auditerait `webhook.disabled`, qui déclencherait une livraison,
446
+ > qui échouerait… Un banc d'attaque le prouve (`webhookDispatch.attack.test.ts`).
447
+
448
+ ## 🔐 La signature — Standard Webhooks v1
449
+
450
+ Nodefony implémente le schéma **Standard Webhooks v1** (standardwebhooks.com) plutôt que RFC 9421
451
+ (_HTTP Message Signatures_, trop lourd pour un webhook) ou un HMAC maison façon GitHub/Stripe. La
452
+ raison est la friction consommateur : une bibliothèque cliente existe déjà dans la plupart des
453
+ langages.
454
+
455
+ ### Ce qui est signé, exactement
456
+
457
+ ```
458
+ base signée = {webhook-id}.{webhook-timestamp}.{corps JSON}
459
+ signature = "v1," + base64( HMAC-SHA256( secret, base ) )
460
+ ```
461
+
462
+ `buildSignatureBase()` (`webhookSignature.ts:30`) construit la base,
463
+ `signStandardWebhook()` (`webhookSignature.ts:47`) produit la valeur d'en-tête. Trois conséquences
464
+ qui comptent :
465
+
466
+ - **L'identifiant du message est couvert** → un attaquant ne peut pas rejouer un corps valide sous
467
+ un nouvel `id` pour contourner la déduplication du destinataire.
468
+ - **L'horodatage est couvert** → il ne peut pas être rajeuni pour échapper à la fenêtre anti-rejeu.
469
+ La fenêtre elle-même est **appliquée par le récepteur**, c'est lui qui la fait respecter.
470
+ - **Le corps exact est couvert** → toute altération d'un octet invalide la signature
471
+ (`webhookSignature.test.ts` couvre ce cas).
472
+
473
+ ### Les trois en-têtes posés
474
+
475
+ `webhookSignatureHeaders()` (`webhookSignature.ts:60`) retourne :
476
+
477
+ | En-tête | Contenu | Rôle côté récepteur |
478
+ | ------------------- | --------------------------- | ------------------------------------ |
479
+ | `webhook-id` | `msg_<aléatoire base64url>` | clé de **déduplication** des retries |
480
+ | `webhook-timestamp` | epoch **secondes** | fenêtre **anti-rejeu** |
481
+ | `webhook-signature` | `v1,<base64(HMAC-SHA256)>` | preuve de l'émetteur |
482
+
483
+ Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts:108`) ;
484
+ `parseWebhookSecret()` (`webhookSignature.ts:22`) décode la partie base64 — le préfixe n'entre pas
485
+ dans la clé HMAC, et un secret déjà sans préfixe est toléré.
486
+
487
+ ### Le secret au repos — chiffré, pas haché
488
+
489
+ Une clé d'API se **hache** (on la vérifie, on ne la relit jamais). Un secret de signature doit être
490
+ **relu à chaque livraison** pour recalculer le HMAC → il est chiffré en AES-256-GCM, avec une clé
491
+ dérivée par HKDF-SHA256 (RFC 5869) sur un contexte **propre au domaine webhook**
492
+ (`WEBHOOK_DERIVATION`, `webhookCipher.ts:21`).
493
+
494
+ Ce cloisonnement n'est pas cosmétique : un blob webhook ne se déchiffre **pas** avec la clé TOTP, et
495
+ réciproquement — un banc d'attaque vérifie cette confusion de domaine, ainsi que le refus des blobs
496
+ tronqués et du downgrade de version de format (`webhookSsrf.attack.test.ts`).
497
+
498
+ > [!WARNING]
499
+ > Ne modifie **jamais** `WEBHOOK_DERIVATION` : tous les secrets déjà stockés deviendraient
500
+ > illisibles, et toutes les livraisons partiraient avec une signature que personne ne peut vérifier.
501
+
502
+ ### Faire tourner le secret
503
+
504
+ Le besoin arrive vite : le secret a fuité dans un ticket, ou la politique impose une rotation
505
+ annuelle.
506
+
507
+ ```bash
508
+ curl -sk -b /tmp/jar -X POST \
509
+ https://localhost:5152/nodefony/security/api/webhooks/wh_9Xq2…/rotate
510
+ # {"endpoint":{…}, "secret":"whsec_NOUVEAU…"}
511
+ ```
512
+
513
+ `WebhookService.rotateSecret()` (`webhooks.ts:553`) régénère et rechiffre. Comportement à connaître
514
+ **avant** de cliquer :
515
+
516
+ - l'ancien secret cesse d'être valide **immédiatement** — il n'y a pas de fenêtre de recouvrement
517
+ côté émetteur (une seule signature est posée par livraison) ;
518
+ - la séquence sans coupure est donc : **désactiver** l'endpoint (`PATCH … {"enabled":false}`) →
519
+ **tourner** → déployer le nouveau secret chez le destinataire → **réactiver** ;
520
+ - le récepteur, lui, peut accepter deux secrets pendant la bascule — c'est pour cela que l'exemple
521
+ de récepteur ci-dessus itère sur les signatures de l'en-tête.
522
+
523
+ ## 🛡️ Défenses SSRF — ce qu'une URL de destination ne peut pas être
524
+
525
+ C'est la partie la plus attaquée de la brique, et celle qui porte le plus de tests
526
+ (`webhookSsrf.attack.test.ts`). Le contrôle vit dans `assertPublicUrl()` (`ssrfGuard.ts:128`),
527
+ appliqué **deux fois** : à l'enregistrement, et **à nouveau juste avant chaque livraison**.
528
+
529
+ ### Ce qui est refusé
530
+
531
+ | Ce que l'attaquant tente | Exemple | Défense |
532
+ | --------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ |
533
+ | Schéma exotique (Gopher, Redis, `file`…) | `redis://1.1.1.1:6379/` | allowlist stricte `https:` (+ `http:` si `allowHttp`) — `ssrfGuard.ts:139` |
534
+ | Identifiants embarqués pour masquer l'hôte réel | `https://trusted.com@169.254.169.254/` | refus de `username`/`password` dans l'URL (`SsrfError`, `ssrfGuard.ts:145`) |
535
+ | IP littérale interne | `https://127.0.0.1/` | 15 plages IPv4 bloquées (`BLOCKED_V4`, `ssrfGuard.ts:22`) |
536
+ | Métadonnées cloud (credentials IAM) | `http://169.254.169.254/` | plage `169.254.0.0/16` bloquée |
537
+ | IPv6 interne, ULA, link-local, NAT64, 6to4 | `https://[fe80::1%25eth0]/` | 9 plages IPv6 bloquées (`BLOCKED_V6`, `ssrfGuard.ts:41`) |
538
+ | IPv4-mapped IPv6 sous toutes ses notations | `::ffff:7f00:1`, `0:0:0:0:0:ffff:127.0.0.1` | rabattu nativement sur les règles IPv4 par `BlockList` (`isBlockedAddress()`, `ssrfGuard.ts:79`) |
539
+ | IP encodée en dword/hex/octal dans le nom d'hôte | `http://2130706433/` | on passe l'IP **résolue** à `isBlockedAddress()`, jamais la chaîne d'hôte (`ssrfGuard.ts:171`) |
540
+ | Nom DNS public qui **résout** vers du privé | `hook.evil.com → 10.0.0.5` | toutes les IP résolues sont contrôlées, une seule interne = refus |
541
+ | **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`webhookDelivery.ts:75`) |
542
+ | **Redirection** `302 → 169.254.169.254` | — | `node:http(s)` ne suit **jamais** les 3xx ; le 3xx est rendu tel quel |
543
+
544
+ ### Les deux subtilités qui font la différence
545
+
546
+ **Le pin d'IP.** Valider puis se reconnecter, c'est laisser une fenêtre : le DNS peut répondre une IP
547
+ publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts:52`)
548
+ installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
549
+ d'hôte pour le SNI/TLS et l'en-tête `Host`. Le second contrôle SSRF avant livraison —
550
+ `resolveTarget()` (`WebhookDispatcher.ts:209`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
551
+ **abandonnée sans retry** (une cible devenue interne ne redeviendra pas légitime au réessai).
552
+
553
+ **Le non-suivi des redirections.** `fetch()` suit les 3xx par défaut — ce qui annulerait tout le
554
+ travail précédent. Le choix de `node:http(s)` natif n'est donc pas seulement une économie de
555
+ dépendance : c'est une **propriété de sécurité**, couverte par un test dédié
556
+ (`webhookDelivery.test.ts`, « 302 vers 169.254.169.254 → rendu tel quel, JAMAIS suivi »).
557
+
558
+ > [!CAUTION]
559
+ > `assertPublicUrl()` fait un `Fail-closed` sur l'inconnu : une adresse syntaxiquement invalide est
560
+ > considérée **bloquée**, et un hôte non résolvable lève. Une intégration qui « marchait avant » et
561
+ > se met à rendre 422 pointe presque toujours un DNS cassé ou une cible qui a migré en interne.
562
+
563
+ ## 🏗️ Architecture interne — le parcours d'un événement
564
+
565
+ ```mermaid
566
+ sequenceDiagram
567
+ participant A as AuditService
568
+ participant D as WebhookDispatcher
569
+ participant Q as file (maxQueue)
570
+ participant N as réseau
571
+ participant S as WebhookService
572
+
573
+ A->>D: onAuditEvent(event) — synchrone, hot-path
574
+ D->>D: endpointCount() == 0 ? → retour immédiat
575
+ D->>D: filtre enabled + matchesSubscription
576
+ D->>Q: #enqueue (ou DROP si pleine)
577
+ Note over D,Q: pump différé — queueMicrotask, hors de la pile de record()
578
+ Q->>D: #process(job) — au plus maxConcurrent
579
+ D->>D: resolveTarget → SSRF + IP pinnée
580
+ D->>D: JSON.stringify + HMAC-SHA256
581
+ D->>N: POST signé (timeout dur)
582
+ alt 2xx
583
+ N-->>D: 200
584
+ D->>S: markDelivery(ok) — failureCount = 0
585
+ else 429 / 408 / 5xx / réseau
586
+ N-->>D: échec réessayable
587
+ D->>Q: #scheduleRetry après backoffMs(attempt)
588
+ else 3xx / 4xx
589
+ N-->>D: échec définitif
590
+ D->>S: markDelivery(ko) — failureCount++ → auto-disable ?
591
+ end
592
+ D->>S: recordDelivery — trace de l'issue FINALE
593
+ ```
594
+
595
+ ### Le hot-path est protégé par construction
596
+
597
+ `onAuditEvent()` est appelé **dans** la boucle de notification de `AuditService.record()` — trois
598
+ gardes empêchent le journal d'audit de payer le prix des webhooks :
599
+
600
+ 1. **Court-circuit à coût nul** — `endpointCount()` (`webhooks.ts:648`) lit la taille d'une `Map` :
601
+ zéro endpoint = retour immédiat, aucune allocation (le cas dominant).
602
+ 2. **Travail lourd différé** — JSON, HMAC et réseau partent dans un `queueMicrotask` coalescé
603
+ (`#schedulePump()`, `WebhookDispatcher.ts:163`), jamais dans la pile de l'appelant.
604
+ 3. **Zéro E/S pour router** — `getSnapshot()` lit un **cache mémoire**, jamais le store : aucune
605
+ requête n'est faite pour décider qui doit recevoir un événement. Le cache est chargé au boot
606
+ (`#reloadSnapshot()`), tenu à jour par chaque écriture CRUD **du même pod**, et **rechargé quand
607
+ il a passé sa date de fraîcheur** — voir ci-dessous.
608
+
609
+ ### À plusieurs pods : ce que vous voyez, et quand
610
+
611
+ Le store est partagé, le cache ne l'est pas : **un endpoint créé sur un pod n'existe pour les autres
612
+ qu'après relecture.** C'est la conséquence directe du point 3 — le prix du « zéro E/S pour router ».
613
+
614
+ La fraîcheur est donc **bornée** par `security.webhooks.snapshotTtlS` (défaut **30 s**). Passé ce
615
+ délai, le premier événement d'audit déclenche une relecture **en arrière-plan** : l'événement en
616
+ cours est routé avec le cache courant, les suivants voient l'état frais. Il n'y a **aucun timer** —
617
+ un pod sans trafic ne lit rien.
618
+
619
+ > [!IMPORTANT]
620
+ > La propagation entre pods est **éventuelle, pas immédiate**. Un webhook créé à l'instant peut ne
621
+ > pas recevoir les événements des ~30 premières secondes sur les pods qui ne l'ont pas encore relu.
622
+ > Idem dans l'autre sens : une désactivation (manuelle, ou automatique après échecs répétés) met le
623
+ > même délai à s'appliquer partout. Baissez `snapshotTtlS` pour propager plus vite — au prix d'une
624
+ > lecture du store plus fréquente ; montez-le si vos endpoints changent rarement.
625
+
626
+ ```typescript
627
+ use("@nodefony/security", {
628
+ webhooks: {
629
+ // Un pod voit au plus 5 s de retard sur les créations/désactivations des autres.
630
+ snapshotTtlS: 5,
631
+ },
632
+ });
633
+ ```
634
+
635
+ Le cas qui rendait ce réglage indispensable est le plus banal : des pods démarrés **avant** toute
636
+ création de webhook ont un cache vide, court-circuitent sur `endpointCount() === 0`… et ne
637
+ rechargeaient jamais. Ils ne livraient donc rien, indéfiniment. Verrouillé par
638
+ `tests/unit/webhookMultiPod.test.ts` (deux services sur le même store), qui prouve aussi qu'une
639
+ rafale d'événements ne déclenche **qu'une** relecture, et qu'un store en panne n'est pas mitraillé.
640
+
641
+ ### Politique de retry — ce qui est réessayé, et pendant combien de temps
642
+
643
+ `classifyDelivery()` (`WebhookDispatcher.ts:45`) tranche en trois catégories :
644
+
645
+ | Issue de la tentative | Verdict | Pourquoi |
646
+ | ---------------------------------------- | ------------------- | --------------------------------------------------------- |
647
+ | `2xx` | **succès** | livré ; `failureCount` remis à 0 |
648
+ | erreur réseau / timeout (`status: null`) | **retry** | panne transitoire probable |
649
+ | `429`, `408`, `5xx` | **retry** | le destinataire dit lui-même « plus tard » / est en panne |
650
+ | `3xx` | **échec définitif** | redirection non suivie = configuration cliente à corriger |
651
+ | `4xx` (hors 408/429) | **échec définitif** | erreur cliente : réessayer ne changera rien |
652
+
653
+ Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`WebhookDispatcher.ts:55`) :
654
+ `5 s × 2^tentative`, plafonné à 5 min (`MAX_BACKOFF_MS`, `WebhookDispatcher.ts:26`).
655
+
656
+ | Tentative | 0 | 1 | 2 | 3 | 4 | 5 | 6+ |
657
+ | --------- | --- | ---- | ---- | ---- | ---- | ----- | ----------- |
658
+ | Délai | 5 s | 10 s | 20 s | 40 s | 80 s | 160 s | 300 s (max) |
659
+
660
+ Avec le défaut `maxRetries: 5`, une livraison est tentée **6 fois** sur environ 4 min 15 avant
661
+ abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
662
+ `WebhookDispatcher.ts:264`) : un pic de retries ne peut pas contourner le plafond mémoire.
663
+
664
+ > [!NOTE]
665
+ > Le backoff est **déterministe, sans jitter**. Sur un seul pod c'est sans conséquence ; sur N pods
666
+ > qui échouent simultanément, les réessais se synchronisent. La désynchronisation cross-pod dépend
667
+ > d'une file de livraison partagée — le registre d'endpoints, lui, est déjà partagé.
668
+
669
+ ### Auto-désactivation d'un endpoint mort
670
+
671
+ Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:680`) : succès →
672
+ `failureCount = 0` ; échec → incrément. Au-delà de `autoDisableThreshold` (défaut **20**), l'endpoint
673
+ est **désactivé** et un unique événement d'audit `webhook.disabled` est émis — **un par endpoint qui
674
+ meurt**, jamais un par échec (le volume resterait ingérable). Mettre le seuil à `0` désactive
675
+ complètement ce mécanisme.
676
+
677
+ ### Le destinataire est tombé — que devient l'événement ?
678
+
679
+ Le scénario vécu, du début à la fin :
680
+
681
+ 1. **Tentative 1** → `ECONNREFUSED`. Classé `retry` ; rien n'est encore écrit en base.
682
+ 2. **Tentatives 2 à 6** sur ~4 min. Toujours rien de persisté (seule l'issue finale l'est).
683
+ 3. **Abandon.** `markDelivery` écrit `lastDeliveryStatus: null`, `lastDeliveryError`, et incrémente
684
+ `failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts:605`).
685
+ 4. **L'événement est PERDU.** Il n'y a pas de file persistée : un webhook est **best-effort**. Rien
686
+ ne sera rejoué quand le destinataire reviendra.
687
+ 5. **Après 20 échecs consécutifs**, l'endpoint passe `enabled: false` et cesse de consommer des
688
+ ressources. Le réactiver est une action admin explicite (`PATCH … {"enabled":true}`).
689
+
690
+ > [!IMPORTANT]
691
+ > **Un webhook n'est pas un transport fiable.** Si la perte d'un événement est inacceptable, le
692
+ > destinataire doit pouvoir **réconcilier** (interroger périodiquement l'API d'audit) — la notification
693
+ > sert à réagir vite, pas à garantir la complétude.
694
+
695
+ ### Arrêt propre
696
+
697
+ `WebhookService.#shutdown()` (`webhooks.ts:221`) se désabonne de l'audit puis appelle
698
+ `WebhookDispatcher.shutdown()` (`WebhookDispatcher.ts:280`) : admission stoppée, **tous les timers de
699
+ retry annulés**, file relâchée. Aucun timer orphelin ne retient le process — et les timers de retry
700
+ sont `unref()` (`webhooks.ts:209`), donc ils n'empêchent jamais Node de sortir.
701
+
702
+ ## ⚙️ Configuration
703
+
704
+ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts:777`), lue via
705
+ `use("@nodefony/security", { webhooks: … })`.
706
+
707
+ | Option | Type | Défaut | Effet |
708
+ | ---------------------- | ---------- | ---------- | ----------------------------------------------------------------------------------------------------------- |
709
+ | `enabled` | `boolean` | `true` | Coupe la brique entière (le service reste inerte, aucun store ni clé résolus). |
710
+ | `store` | `string` | `"auto"` | Registre des endpoints : `auto` suit l'infra database déclarée, sinon `memory`/`drizzle`/`mongoose`. |
711
+ | `encryptionKey` | `string?` | _(aucune)_ | Matériel de clé des secrets au repos. **Absente en prod = webhooks OFF** ; en dev = clé éphémère + WARNING. |
712
+ | `signAlg` | `"sha256"` | `"sha256"` | Schéma de signature Standard Webhooks v1 (seule valeur admise ; slot Ed25519 réservé). |
713
+ | `timestampToleranceS` | `int > 0` | `300` | Fenêtre anti-rejeu **recommandée au récepteur** (voir la note ci-dessous). |
714
+ | `maxRetries` | `int ≥ 0` | `5` | Réessais après la 1ʳᵉ tentative → 6 envois au total. |
715
+ | `autoDisableThreshold` | `int ≥ 0` | `20` | Échecs consécutifs avant désactivation automatique. `0` = jamais. |
716
+ | `deliveryTimeoutMs` | `int > 0` | `10000` | Délai dur d'une tentative ; au-delà, `req.destroy()` (`webhookDelivery.ts:136`). |
717
+ | `maxConcurrent` | `int > 0` | `8` | Livraisons simultanées : borne les sockets/FD qu'un endpoint lent peut immobiliser. |
718
+ | `maxQueue` | `int > 0` | `1000` | File d'attente ; au-delà, **abandon** annoncé par log (best-effort, mémoire bornée). |
719
+ | `denyPrivateIps` | `boolean` | `true` | Contrôle SSRF. `false` **saute entièrement** la résolution et le contrôle d'IP (dev only). |
720
+ | `allowHttp` | `boolean` | `false` | Autorise `http://`. Prod : `https://` obligatoire. |
721
+
722
+ > [!NOTE]
723
+ > `timestampToleranceS` est **transporté** dans la politique de livraison
724
+ > (`getDeliveryPolicy()`, `webhooks.ts:660`) mais l'émetteur ne l'applique jamais : la fenêtre
725
+ > anti-rejeu est par nature un contrôle du **récepteur**. Traite cette valeur comme la tolérance que
726
+ > tu documentes à tes destinataires — c'est celle du récepteur qui protège.
727
+
728
+ ## Entité de persistance — le registre d'endpoints
729
+
730
+ Un endpoint est une **configuration durable**, pas un cache : il survit aux redémarrages et se
731
+ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.ts:10`), plat et
732
+ « tout `| null` ».
733
+
734
+ <!-- prettier-ignore -->
735
+ | Colonne | Sens | SQL (sqlite · postgres · mysql) | MongoDB |
736
+ | --- | --- | --- | --- |
737
+ | `id` (PK) | `wh_<aléatoire base64url>` | `text` · `text` · `varchar(512)` | `_id: String` |
738
+ | `url` | destination validée anti-SSRF | `text` | `String` |
739
+ | `secretEnc` | secret **chiffré** (`gcm1.…`), jamais clair | `text` | `String` |
740
+ | `events` | actions souscrites | `text mode:json` · `jsonb` · `json` | `[String]` |
741
+ | `enabled` | actif ? | `integer mode:bool` · `boolean` · `boolean` | `Boolean` |
742
+ | `description` | libellé console | `text` | `String` |
743
+ | `tenantId` | slot multi-tenant (réservé) | `text` | `String` |
744
+ | `createdBy` | admin créateur (traçabilité) | `text` | `String` |
745
+ | `createdAt` / `updatedAt` | epoch **ms** | `integer` · `bigint` · `bigint` | `Number` |
746
+ | `lastDeliveryAt` | dernière tentative (epoch ms) | `integer` · `bigint` · `bigint` | `Number` |
747
+ | `lastDeliveryStatus` | code HTTP de la dernière livraison | `integer` | `Number` |
748
+ | `lastDeliveryError` | message d'erreur | `text` | `String` |
749
+ | `failureCount` | échecs consécutifs | `integer` | `Number` |
750
+ | `metadata` | extras applicatifs (jamais de secret) | `text mode:json` · `jsonb` · `json` | `Object` |
751
+
752
+ Côté SQL, la table est décrite **une seule fois** en spec logique
753
+ (`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts:28`) puis déclinée
754
+ par dialecte via le `colKit`. Côté documentaire, le schéma force `_id: String` — l'identifiant
755
+ `wh_…` **est** la clé primaire, pas un `ObjectId` généré
756
+ (`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts:23`).
757
+
758
+ Ce qui **n'est pas** persisté : l'historique des livraisons. Il vit en RAM, borné à 20 entrées par
759
+ endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts:54`), corps de requête tronqué à 8 Ko, corps
760
+ de réponse à 2 Ko (`RESPONSE_BODY_CAP`, `webhookDelivery.ts:21`) — et il est **par pod**. C'est de
761
+ l'observabilité éphémère de mise au point, pas un journal d'audit.
762
+
763
+ ## Backends pris en charge — trois enregistrés, un exclu volontairement
764
+
765
+ | Backend | Enregistrement | Durable | Partagé multi-pod | Usage |
766
+ | ---------- | ------------------------------------------------------------ | :-----: | :---------------: | ------------------------------ |
767
+ | `memory` | intégré, à l'import (`webhookStoreRegistry.ts:55`) | ❌ | ❌ | dev, tests |
768
+ | `drizzle` | auto-register de l'adapter (SQLite/PostgreSQL/MySQL/MariaDB) | ✅ | ✅ | production SQL |
769
+ | `mongoose` | auto-register de l'adapter | ✅ | ✅ | production documentaire |
770
+ | `redis` | **volontairement absent** | — | — | un registre n'est pas un cache |
771
+
772
+ Redis n'est pas une omission : un endpoint est de la **configuration durable**, sa place n'est pas
773
+ dans un magasin volatil (`IWebhookStore.ts:29`).
774
+
775
+ La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts:231`)
776
+ privilégie un adapter déjà posé au container, puis résout `auto` d'après l'infra déclarée, et
777
+ **enregistre sa décision** (visible dans Studio). Deux garde-fous :
778
+
779
+ - un `store` explicite **inconnu** avorte le boot en production, et désactive la brique en dev avec
780
+ un log `CRITIC` — jamais de dégradation silencieuse ;
781
+ - `store: "memory"` en production émet un `WARNING` explicite : abonnements volatils et **par pod**.
782
+
783
+ ### Pagination du registre
784
+
785
+ `WebhookService.listPage()` (`webhooks.ts:126`) délègue au store — la console n'a **jamais** tout le
786
+ registre en RAM. Ce contrat est vérifié par un **banc unique** rejoué sur tous les backends
787
+ (`webhookPaginationContract.ts`) : mêmes 12 endpoints de seed, mêmes assertions.
788
+
789
+ - Ordre par défaut : `createdAt` DESC, départagé par `id` ASC — sans ce tiebreaker, deux endpoints
790
+ créés dans la même milliseconde pourraient changer de page et l'un ne jamais apparaître.
791
+ - Tri demandable, mais **sur un vocabulaire déclaré** : `createdAt`, `updatedAt`, `url`, `enabled`,
792
+ `failureCount`, `id` (`WEBHOOK_SORTABLE_FIELDS`, `webhookSort.ts:32`). Un champ hors liste est
793
+ refusé, pas ignoré — et le store publie ce qu'il sait trier (`ISortableSource.sortableFields`,
794
+ `IWebhookStore.ts:50`), plutôt que de laisser le front le deviner. Les colonnes **nullables** en
795
+ sont volontairement absentes : PostgreSQL range les `NULL` en tête d'un `DESC` là où
796
+ SQLite/MySQL/mémoire les rangent en queue — un tri dont l'ordre dépend de la base configurée ne
797
+ vaut pas mieux qu'un tri absent.
798
+ - Filtres appliqués **au store**, jamais après un chargement complet : `enabled`, `event`
799
+ (appartenance au tableau JSON — « qui écoute `user.created` ? »), `failing` (au moins un échec
800
+ consécutif courant — « qu'est-ce qui casse ? », `IWebhookStore.ts:35`), `q` (sous-chaîne
801
+ insensible à la casse sur `url` **ou** `description`).
802
+ - Mode unique **offset** : tous les backends d'endpoints savent le faire, aucune capacité n'est donc
803
+ à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts:69` ;
804
+ `DrizzleWebhookStore.ts:159` ; `MongooseWebhookStore.ts:188`).
805
+ - **Les compteurs suivent la recherche.** `GET webhooks/stats` déclare `search`
806
+ (`WebhookAdminApi.ts:307`) et descend le même `q` jusqu'au store : un terme sans correspondance
807
+ vide les cartes autant que le tableau. Sans cela, la console afficherait « 12 endpoints » au-dessus
808
+ d'une liste filtrée à 2 — un chiffre qui ne répond plus à la question posée à l'écran.
809
+
810
+ `IWebhookStore.listAll()` (`IWebhookStore.ts:57`) existe toujours, mais il est **réservé au snapshot
811
+ du dispatcher** : celui-ci doit connaître tous les abonnements pour ne rater aucune livraison. C'est
812
+ un cold-path (boot + après écriture CRUD), jamais un chemin d'affichage.
813
+
814
+ ## 🧰 API publique
815
+
816
+ ### Le service `webhooks`
817
+
818
+ Résolu depuis le container (`container.get("webhooks")`), toutes les méthodes sont asynchrones sauf
819
+ mention.
820
+
821
+ | Méthode | Rôle | Ancrage |
822
+ | ----------------------------- | ------------------------------------------------------------------ | ----------------- |
823
+ | `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts:434` |
824
+ | `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts:468` |
825
+ | `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts:456` |
826
+ | `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts:510` |
827
+ | `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts:414` |
828
+ | `setEnabled(id, bool)` | Révocation douce | `webhooks.ts:542` |
829
+ | `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts:553` |
830
+ | `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts:572` |
831
+ | `delete(id)` | Supprime ; `false` si absent | `webhooks.ts:580` |
832
+ | `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts:595` |
833
+ | `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts:407` |
834
+
835
+ Types et briques réutilisables exportés par `@nodefony/security` : `IWebhookEndpoint`,
836
+ `WebhookEndpointSummary`, `IWebhookStore`, `IWebhookListQuery`, `MemoryWebhookStore`,
837
+ `registerWebhookStore` — et, utilisables **hors webhooks**, `assertPublicUrl` / `isBlockedAddress` /
838
+ `SsrfError` pour valider n'importe quelle URL sortante de ton application.
839
+
840
+ ### Le data plane d'administration
841
+
842
+ Huit endpoints sous `/nodefony/security/api/webhooks`, tous `ROLE_NODEFONY_ADMIN`, composés dans le
843
+ producteur `security` — ils héritent gratuitement du RBAC fail-closed du broker, de l'audit et de la
844
+ porte d'idempotence sur les mutations.
845
+
846
+ | Méthode + chemin | Rôle | Audit |
847
+ | ------------------------------ | --------------------------------------------------------- | ------------------ |
848
+ | `GET webhooks` | Page d'endpoints + driver du store. **Jamais de secret.** | — |
849
+ | `POST webhooks` | Crée ; secret renvoyé **une seule fois** ; `422` si SSRF | `webhook.created` |
850
+ | `GET webhooks/{id}` | Un endpoint (vue publique), `404` sinon | — |
851
+ | `GET webhooks/{id}/deliveries` | Historique RAM des livraisons de cet endpoint | — |
852
+ | `PATCH webhooks/{id}` | Met à jour ; nouvelle URL re-validée anti-SSRF | `webhook.updated` |
853
+ | `DELETE webhooks/{id}` | Supprime | `webhook.deleted` |
854
+ | `POST webhooks/{id}/rotate` | Rotation du secret | `webhook.rotated` |
855
+ | `POST webhooks/{id}/reveal` | Révèle le secret en clair | `webhook.revealed` |
856
+
857
+ Deux détails de conception qui se voient à l'usage :
858
+
859
+ - **`reveal` est un `POST`, pas un `GET`** (`WebhookAdminApi.ts:569`) : un secret n'a rien à faire
860
+ dans une URL, donc ni dans un journal d'accès, ni dans un `Referer`. Le `POST` impose en prime la
861
+ protection CSRF.
862
+ - **La lecture est défensive, jamais `503`** : webhooks désactivés → la console affiche un badge
863
+ honnête (`enabled: false`) et une liste vide, plutôt qu'une erreur. Les **mutations**, elles,
864
+ rendent bien `503` si le service n'est pas prêt.
865
+ - **Le listing est borné** : `limit` par défaut 50, plafond dur **200**
866
+ (`parseWebhookListQuery()`, `WebhookAdminApi.ts:147`) — un client ne peut pas demander « tout ».
867
+
868
+ ## 🧩 Extension — brancher son propre registre
869
+
870
+ Le registre de stores est un simple `Map` nom → fabrique (`registerWebhookStore()`,
871
+ `webhookStoreRegistry.ts:35`). Implémenter `IWebhookStore` (6 méthodes) suffit ; aucun couplage au
872
+ cœur.
873
+
874
+ ```typescript ignore
875
+ import { registerWebhookStore, type IWebhookStore } from "@nodefony/security";
876
+
877
+ registerWebhookStore("mon-backend", ({ container, config }) => {
878
+ return new MonWebhookStore(container) satisfies IWebhookStore;
879
+ });
880
+ // puis : use("@nodefony/security", { webhooks: { store: "mon-backend" } })
881
+ ```
882
+
883
+ L'enregistrement se fait depuis **ton** module, avec `import type` pour le contrat → zéro dépendance
884
+ runtime, zéro cycle. Pour valider ton implémentation, importe le banc de contrat de pagination
885
+ (`tests/support/webhookPaginationContract.ts`) et branche-le sur ton store : les invariants d'ordre,
886
+ de filtres et de bornes sont alors prouvés, pas supposés.
887
+
888
+ ## 📜 Normes appliquées
889
+
890
+ | Domaine | Norme / référence | Ancrage |
891
+ | ----------------------- | ------------------------------------- | ------------------------------------------------------------------ |
892
+ | HMAC | RFC 2104 (via `node:crypto`) | `signStandardWebhook()` (`webhookSignature.ts:47`) |
893
+ | Schéma de signature | Standard Webhooks v1 | `webhookSignatureHeaders()` (`webhookSignature.ts:60`) |
894
+ | Dérivation de clé | RFC 5869 (HKDF-SHA256) | `deriveWebhookKey()` (`webhookCipher.ts:30`) |
895
+ | Chiffrement au repos | AES-256-GCM (chiffrement authentifié) | `secretCipher.ts:77` |
896
+ | SSRF | OWASP A10:2021, CAPEC-664 | `assertPublicUrl()` (`ssrfGuard.ts:128`) |
897
+ | Plages non routables | RFC 1918, 6598, 3927, 7526 | `BLOCKED_V4` (`ssrfGuard.ts:22`), `BLOCKED_V6` (`ssrfGuard.ts:41`) |
898
+ | Réessai sur `429`/`408` | RFC 6585, RFC 9110 | `classifyDelivery()` (`WebhookDispatcher.ts:45`) |
899
+ | Comparaison de secret | temps constant | `timingSafeEqual` côté récepteur (`WebhookSinkController.ts:94`) |
900
+
901
+ ## ⚡ Performance & mémoire
902
+
903
+ Le principe est simple : **le coût est nul tant qu'aucun endpoint n'est enregistré**, et borné dès
904
+ qu'il y en a.
905
+
906
+ <!-- prettier-ignore -->
907
+ | Mécanisme | Borne | Ancrage |
908
+ | --- | --- | --- |
909
+ | Court-circuit hot-path | 0 allocation si `endpointCount() == 0` | `WebhookDispatcher.ts:137` |
910
+ | File d'attente | `maxQueue` (1000) puis **abandon** + log | `WebhookDispatcher.ts:149` |
911
+ | Sockets / FD simultanés | `maxConcurrent` (8) | `WebhookDispatcher.#pump()` (`WebhookDispatcher.ts:173`) |
912
+ | Durée d'une tentative | 10 s puis `req.destroy()` | `webhookDelivery.ts:136` |
913
+ | Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts:54` |
914
+ | Allocations paresseuses | file, `Set` de timers, historique : `null` tant qu'inutilisés | `WebhookDispatcher.ts:114` |
915
+
916
+ La preuve n'est pas déclarative : un banc d'attaque envoie **5000 événements vers un endpoint mort**
917
+ et vérifie que 4000 livraisons sont abandonnées (file plafonnée) et que le pic de connexions
918
+ simultanées ne dépasse jamais 8 (`webhookDispatch.attack.test.ts`).
919
+
920
+ Deux propriétés complètent le tableau : les timers de retry sont `unref()` (ils n'empêchent jamais
921
+ le process de sortir), et l'arrêt annule tout (`WebhookDispatcher.shutdown()`,
922
+ `WebhookDispatcher.ts:280`) — aucun listener ni timer orphelin.
923
+
924
+ ## 📡 Observabilité — Studio
925
+
926
+ L'écran **Webhooks** (`/nodefony/webhooks`, `Webhooks.tsx`) est la console de la brique. Il consomme
927
+ exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.ts:112`) :
928
+
929
+ - **table paginée côté serveur** — URL, abonnements, état, dernière livraison, compteur d'échecs ;
930
+ - **formulaire de création/édition** avec validation d'URL avant envoi (`WebhookFormModal.tsx`) ;
931
+ - **révélation de secret** en modale dédiée, à la création comme à la rotation
932
+ (`SecretRevealModal.tsx`) ;
933
+ - **panneau des livraisons récentes** — ce qui a été envoyé et ce que le destinataire a répondu
934
+ (`DeliveriesPanel.tsx`) ;
935
+ - **badge « où on écrit »** : `memory` ou `orm`, dérivé du nom de classe réel du store
936
+ (`webhookStoreDriver()`, `WebhookAdminApi.ts:101`) — un store tiers inconnu affiche `null` plutôt
937
+ qu'un driver inventé.
938
+
939
+ En développement, le module `test` fournit un **récepteur local** à demeure — le remplaçant
940
+ non-jetable de webhook.site. Il capture les en-têtes de signature, vérifie le HMAC si on lui passe le
941
+ secret, et sait **simuler des pannes** pour observer retries et auto-désactivation
942
+ (`WebhookSinkController.ts:99`) :
943
+
944
+ | Route | Ce qu'elle fait |
945
+ | ------------------------------------------------- | ------------------------------------------------------ |
946
+ | `POST /nodefony/test/webhooks/sink?secret=…` | Réception nominale (200) + vérification de signature |
947
+ | `POST /nodefony/test/webhooks/sink/status/{code}` | Répond le code demandé → simule un récepteur en erreur |
948
+ | `POST /nodefony/test/webhooks/sink/slow?ms=…` | Répond lentement → provoque le timeout de livraison |
949
+ | `GET /nodefony/test/webhooks/received` | Inspecte les livraisons reçues |
950
+
951
+ ## ⚠️ Pièges (symptôme → cause → correction)
952
+
953
+ | Symptôme | Cause (dans le code) | Correction |
954
+ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
955
+ | Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts:299`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
956
+ | Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles | Poser une `encryptionKey` stable ; tourner les secrets des endpoints |
957
+ | `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo) | Viser une URL publique en `https://` ; en dev, `denyPrivateIps: false` |
958
+ | Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts:191`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
959
+ | Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif | S'abonner à une action d'audit existante |
960
+ | Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré | Lire le corps **brut** ; signer `{id}.{timestamp}.{body}` |
961
+ | Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
962
+ | Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts:565`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
963
+ | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
964
+ | Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
965
+ | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
966
+ | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
967
+ | Multi-pod : un endpoint créé ne livre que depuis un pod | Le snapshot n'est chargé qu'au boot (`webhooks.ts:325`) ; les pods qui n'ont pas vu l'écriture gardent l'ancien | Redémarrage tournant après une mutation, ou router l'admin sur tous les pods |
968
+
969
+ ## 🧪 Tests & couverture
970
+
971
+ Six familles couvrent la brique — les compteurs exacts vivent dans la carte de l'aperçu, régénérée
972
+ depuis vitest, jamais figés ici :
973
+
974
+ - **unitaires** (`src/packages/@nodefony/security/tests/unit/`) — `webhookService` (registre, secret,
975
+ rotation, révélation, garde-fous, audit borné de l'auto-désactivation) ; `webhookDispatcher`
976
+ (fonctions pures `matchesSubscription`/`classifyDelivery`/`backoffMs`, filtrage hot-path, retries,
977
+ historique, bornes de perf, arrêt) ; `webhookSignature` (vecteur officiel Standard Webhooks,
978
+ altération du corps/id/timestamp) ; `webhookDelivery` (pin d'IP, non-suivi des 3xx, timeout,
979
+ politique de protocole, capture du corps de réponse) ; `webhookStore` (CRUD mémoire + copie
980
+ défensive) ; `webhookAdminApi` (les 8 endpoints, rôles, `400`/`404`/`422`/`503`, audit des
981
+ mutations) ; `webhookPagination` (harnais du banc de contrat sur le store mémoire).
982
+ - **attaque (red-team)** — `webhookSsrf.attack.test.ts` : matrice threat-first dérivée d'OWASP SSRF
983
+ et de CAPEC-664 (IPv4-mapped IPv6 sous 7 notations, confusion `userinfo`, IP encodée
984
+ dword/hex/octal, zone-id IPv6, schémas exotiques) **plus un contrôle positif** — sans lui, « tout
985
+ refuser » serait trivialement vert — et les attaques crypto (downgrade de version, blob tronqué,
986
+ confusion de domaine TOTP↔webhook). `webhookDispatch.attack.test.ts` attaque le **framework
987
+ lui-même** : DoS par burst vers un endpoint mort, fuite du secret dans le corps ou les en-têtes,
988
+ amplification par boucle d'audit, injection de métacaractères JSON dans un `actor`.
989
+ - **banc de contrat** — `tests/support/webhookPaginationContract.ts` : les invariants de
990
+ `listPage`/`countEndpoints` (ordre, tiebreaker, filtres, bornes), rejoués **à l'identique** par
991
+ tous les backends. Vit chez le propriétaire du contrat, jamais dupliqué.
992
+ - **intégration** — `drizzle/tests/integration/webhook-store-sqlite.test.ts` et
993
+ `mongoose/tests/integration/webhook-store.test.ts` : le contrat sur bases réelles.
994
+ - **e2e** — `webhook-store-postgres.e2e.test.ts` et `webhook-store-mysql.e2e.test.ts` : PostgreSQL et
995
+ MySQL/MariaDB **réels**, gatés par des variables d'infra. ⚠️ Sans elles, ces suites se **skippent**
996
+ — et un skip compte comme vert : lire le rapport de gates avant de conclure.
997
+ - **récepteur de bout en bout** — `WebhookSinkController` (module `test`) permet l'essai manuel
998
+ complet : livraison réelle, vérification de signature, simulation de panne.
999
+
1000
+ Ce qui **manque** aujourd'hui : aucun test de **charge** ni de **mémoire** dédié à la brique. Les
1001
+ bornes sont prouvées unitairement (5000 événements, file plafonnée, pic de concurrence) mais jamais
1002
+ sous charge réelle avec mesure de tas. Pour les exercer : skills `nodefony-load-test` (charge) et
1003
+ `nodefony-check-memory-health` (heap delta).
1004
+
1005
+ Couverture : `npm run coverage` dans `@nodefony/security`. Revue de sécurité de la brique → skill
1006
+ `nodefony-security-review` (mode red/blue-team).
1007
+
1008
+ ## 🔗 Pour aller plus loin
1009
+
1010
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
1011
+ - La **source des événements** livrés → [audit](audit.md) · Le pare-feu qui les produit → [firewall](firewall.md)
1012
+ - Secrets et jetons du module, mêmes principes de chiffrement au repos → [tokens](tokens.md)
1013
+ - Vocabulaire transverse de la sécurité → [lexique](lexique.md)
1014
+ </content>
1015
+
1016
+ </invoke>