@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.
- package/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
package/docs/webhooks.md
ADDED
|
@@ -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>
|