@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.4
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/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +1 -2
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +10 -8
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +1 -2
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +1 -2
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +127 -124
- package/package.json +13 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/webhooks.md
CHANGED
|
@@ -58,8 +58,8 @@ flowchart LR
|
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Deux frontières décident de tout : **`WebhookDispatcher.onAuditEvent()`**
|
|
61
|
-
(`WebhookDispatcher.ts
|
|
62
|
-
**`WebhookDispatcher.#process()`** (`WebhookDispatcher.ts
|
|
61
|
+
(`WebhookDispatcher.ts#WebhookDispatcher.onAuditEvent`) qui filtre sans jamais bloquer l'émetteur, et
|
|
62
|
+
**`WebhookDispatcher.#process()`** (`WebhookDispatcher.ts#WebhookDispatcher.#process`) qui signe, livre et classe l'issue.
|
|
63
63
|
Le détail de chaque étape est plus bas, dans **Architecture interne**.
|
|
64
64
|
|
|
65
65
|
### À quoi ça sert, concrètement
|
|
@@ -146,25 +146,25 @@ propre serveur**.
|
|
|
146
146
|
Trois partis pris, tous vérifiables dans le code :
|
|
147
147
|
|
|
148
148
|
1. **La source est le journal d'audit, pas un bus métier.** `WebhookService.#attachDispatcher()`
|
|
149
|
-
(`webhooks.ts
|
|
149
|
+
(`webhooks.ts#WebhookService.#attachDispatcher`) s'abonne au service `auditService` et à lui seul. Si l'audit est absent, le
|
|
150
150
|
dispatcher n'existe pas — le CRUD d'endpoints reste disponible, mais **rien ne part**. C'est une
|
|
151
151
|
limite assumée : les webhooks notifient des **événements de sécurité**.
|
|
152
152
|
2. **Le secret est chiffré, jamais haché.** Contrairement à une clé d'API (qu'on vérifie donc qu'on
|
|
153
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
|
|
154
|
+
une clé dérivée HKDF propre au domaine webhook (`deriveWebhookKey()`, `webhookCipher.ts#deriveWebhookKey`).
|
|
155
155
|
3. **Le tiers ne peut pas nuire au framework.** File bornée, concurrence bornée, historique borné,
|
|
156
156
|
abandon annoncé : le dispatcher est écrit pour qu'un endpoint mort coûte un log, pas une panne
|
|
157
|
-
(`WebhookDispatcher.#enqueue()`, `WebhookDispatcher.ts
|
|
157
|
+
(`WebhookDispatcher.#enqueue()`, `WebhookDispatcher.ts#WebhookDispatcher.#enqueue`).
|
|
158
158
|
|
|
159
159
|
## 🚀 Démarrage rapide
|
|
160
160
|
|
|
161
161
|
### 1. Activer les webhooks et poser la clé de chiffrement
|
|
162
162
|
|
|
163
|
-
Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts
|
|
163
|
+
Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts#tokenExchangeSchema`).
|
|
164
164
|
La seule chose que tu dois vraiment fournir, c'est la **clé de chiffrement des secrets de signature** :
|
|
165
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
166
|
**désactivés** — un secret chiffré par une clé perdue au redémarrage serait illisible
|
|
167
|
-
(`WebhookService.#resolveKey()`, `webhooks.ts
|
|
167
|
+
(`WebhookService.#resolveKey()`, `webhooks.ts#WebhookService.#resolveKey`).
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
170
|
# Génère les clés du module security et guide le câblage en 3 fichiers.
|
|
@@ -203,14 +203,14 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
203
203
|
|
|
204
204
|
> [!WARNING]
|
|
205
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
|
|
206
|
+
> `allowPrivate`, sans résoudre le DNS ni comparer quoi que ce soit (`ssrfGuard.ts#IAssertPublicUrlOptions.allowPrivate`). C'est un
|
|
207
207
|
> réglage de poste de développement, à ne jamais laisser fuiter en production.
|
|
208
208
|
|
|
209
209
|
### 2. Enregistrer un abonnement (API d'administration)
|
|
210
210
|
|
|
211
211
|
Il n'y a **pas de déclaration d'endpoint en config** : un abonnement est une donnée, créée à
|
|
212
212
|
l'exécution via le data plane admin `/nodefony/security/api/webhooks`, gardé
|
|
213
|
-
`ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts
|
|
213
|
+
`ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts#webhookAdminEndpoints`).
|
|
214
214
|
|
|
215
215
|
```bash
|
|
216
216
|
# Session admin (le BFF de login pose le cookie)
|
|
@@ -244,14 +244,16 @@ C'est la moitié que **tu** écris, côté destinataire. Voici la version courte
|
|
|
244
244
|
vingtaine de lignes et fait le seul geste indispensable — **recalculer l'empreinte sur les octets
|
|
245
245
|
reçus**.
|
|
246
246
|
|
|
247
|
-
```typescript
|
|
247
|
+
```typescript ignore
|
|
248
248
|
// nodefony/controller/HookMiniController.ts — récepteur minimal
|
|
249
249
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
250
250
|
import { Buffer } from "node:buffer";
|
|
251
251
|
// prettier-ignore
|
|
252
252
|
import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
|
|
253
|
+
// NF_HOOK_SECRET se déclare dans env.ts (`envString`) : seul lecteur de l'environnement.
|
|
254
|
+
import { env } from "../../env";
|
|
253
255
|
|
|
254
|
-
const SECRET = (
|
|
256
|
+
const SECRET = (env.NF_HOOK_SECRET ?? "").replace(/^whsec_/, "");
|
|
255
257
|
|
|
256
258
|
@controller("/hooks")
|
|
257
259
|
class HookMiniController extends Controller {
|
|
@@ -300,15 +302,16 @@ signature parmi **plusieurs** (le temps d'une rotation de secret), et **dédupli
|
|
|
300
302
|
`webhook-id` avant d'agir — un réessai rejoue le même identifiant, et livrer deux fois une commande
|
|
301
303
|
n'est pas la même chose que la livrer une fois.
|
|
302
304
|
|
|
303
|
-
```typescript
|
|
305
|
+
```typescript ignore
|
|
304
306
|
// nodefony/controller/HookController.ts — récepteur complet, compile tel quel
|
|
305
307
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
306
308
|
import { Buffer } from "node:buffer";
|
|
307
309
|
// prettier-ignore
|
|
308
310
|
import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
|
|
311
|
+
import { env } from "../../env";
|
|
309
312
|
|
|
310
|
-
/** Secret `whsec_…` donné par l'émetteur —
|
|
311
|
-
const SECRET =
|
|
313
|
+
/** Secret `whsec_…` donné par l'émetteur — déclaré dans env.ts, jamais en dur. */
|
|
314
|
+
const SECRET = env.NF_HOOK_SECRET ?? "";
|
|
312
315
|
/** Fenêtre anti-rejeu (s) : un message plus vieux est refusé. */
|
|
313
316
|
const TOLERANCE_S = 300;
|
|
314
317
|
|
|
@@ -403,16 +406,16 @@ curl -sk -b /tmp/jar https://localhost:5152/nodefony/security/api/webhooks/wh_9X
|
|
|
403
406
|
|
|
404
407
|
Un `attempt` supérieur à 0 signale que la trace enregistrée est celle d'une **issue finale après
|
|
405
408
|
retries** : les tentatives intermédiaires ne sont pas tracées, seule l'issue passe par
|
|
406
|
-
`recordDelivery()` (`WebhookDispatcher.ts
|
|
409
|
+
`recordDelivery()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.recordDelivery`).
|
|
407
410
|
|
|
408
411
|
## Quels événements partent en webhook ?
|
|
409
412
|
|
|
410
413
|
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
|
|
412
|
-
fire-and-forget de `AuditService.record()` (`auditService.ts
|
|
414
|
+
dispatcher est branché sur `AuditService.subscribe()` (`auditService.ts#AuditService.subscribe`), appelé dans le
|
|
415
|
+
fire-and-forget de `AuditService.record()` (`auditService.ts#AuditService.record`). Aucun autre point d'émission
|
|
413
416
|
n'existe dans le code.
|
|
414
417
|
|
|
415
|
-
Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts
|
|
418
|
+
Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts#AuditCategory`) donnent la surface réelle :
|
|
416
419
|
|
|
417
420
|
| Catégorie | Ce qu'elle trace (exemples d'`action`) |
|
|
418
421
|
| ---------- | -------------------------------------------------------------------------- |
|
|
@@ -430,12 +433,12 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:24`) donne
|
|
|
430
433
|
|
|
431
434
|
> [!NOTE]
|
|
432
435
|
> Trois catégories sont **réservées** au contrat sans émetteur à ce jour : `oauth`, `csrf`, `cors`
|
|
433
|
-
> (`IAuditEvent.ts
|
|
436
|
+
> (`IAuditEvent.ts#AuditCategory`). S'y abonner est accepté, mais ne livre jamais rien.
|
|
434
437
|
|
|
435
438
|
### La syntaxe d'abonnement
|
|
436
439
|
|
|
437
440
|
Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubscription()`
|
|
438
|
-
(`WebhookDispatcher.ts
|
|
441
|
+
(`WebhookDispatcher.ts#matchesSubscription`) :
|
|
439
442
|
|
|
440
443
|
| Motif | Matche | Usage typique |
|
|
441
444
|
| ----------------- | ----------------------------------------------------- | ------------------------------- |
|
|
@@ -445,7 +448,7 @@ Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubs
|
|
|
445
448
|
|
|
446
449
|
> [!CAUTION]
|
|
447
450
|
> Les événements de catégorie `webhook` **ne déclenchent jamais de livraison**
|
|
448
|
-
> (`WebhookDispatcher.ts
|
|
451
|
+
> (`WebhookDispatcher.ts#WebhookDispatcher.onAuditEvent`), même avec un abonnement `"*"`. C'est une garde anti-amplification :
|
|
449
452
|
> sans elle, un échec de livraison auditerait `webhook.disabled`, qui déclencherait une livraison,
|
|
450
453
|
> qui échouerait… Un banc d'attaque le prouve (`webhookDispatch.attack.test.ts`).
|
|
451
454
|
|
|
@@ -463,8 +466,8 @@ base signée = {webhook-id}.{webhook-timestamp}.{corps JSON}
|
|
|
463
466
|
signature = "v1," + base64( HMAC-SHA256( secret, base ) )
|
|
464
467
|
```
|
|
465
468
|
|
|
466
|
-
`buildSignatureBase()` (`webhookSignature.ts
|
|
467
|
-
`signStandardWebhook()` (`webhookSignature.ts
|
|
469
|
+
`buildSignatureBase()` (`webhookSignature.ts#buildSignatureBase`) construit la base,
|
|
470
|
+
`signStandardWebhook()` (`webhookSignature.ts#signStandardWebhook`) produit la valeur d'en-tête. Trois conséquences
|
|
468
471
|
qui comptent :
|
|
469
472
|
|
|
470
473
|
- **L'identifiant du message est couvert** → un attaquant ne peut pas rejouer un corps valide sous
|
|
@@ -476,7 +479,7 @@ qui comptent :
|
|
|
476
479
|
|
|
477
480
|
### Les trois en-têtes posés
|
|
478
481
|
|
|
479
|
-
`webhookSignatureHeaders()` (`webhookSignature.ts
|
|
482
|
+
`webhookSignatureHeaders()` (`webhookSignature.ts#webhookSignatureHeaders`) retourne :
|
|
480
483
|
|
|
481
484
|
| En-tête | Contenu | Rôle côté récepteur |
|
|
482
485
|
| ------------------- | --------------------------- | ------------------------------------ |
|
|
@@ -484,8 +487,8 @@ qui comptent :
|
|
|
484
487
|
| `webhook-timestamp` | epoch **secondes** | fenêtre **anti-rejeu** |
|
|
485
488
|
| `webhook-signature` | `v1,<base64(HMAC-SHA256)>` | preuve de l'émetteur |
|
|
486
489
|
|
|
487
|
-
Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts
|
|
488
|
-
`parseWebhookSecret()` (`webhookSignature.ts
|
|
490
|
+
Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts#generateSecret`) ;
|
|
491
|
+
`parseWebhookSecret()` (`webhookSignature.ts#parseWebhookSecret`) décode la partie base64 — le préfixe n'entre pas
|
|
489
492
|
dans la clé HMAC, et un secret déjà sans préfixe est toléré.
|
|
490
493
|
|
|
491
494
|
### Le secret au repos — chiffré, pas haché
|
|
@@ -493,7 +496,7 @@ dans la clé HMAC, et un secret déjà sans préfixe est toléré.
|
|
|
493
496
|
Une clé d'API se **hache** (on la vérifie, on ne la relit jamais). Un secret de signature doit être
|
|
494
497
|
**relu à chaque livraison** pour recalculer le HMAC → il est chiffré en AES-256-GCM, avec une clé
|
|
495
498
|
dérivée par HKDF-SHA256 (RFC 5869) sur un contexte **propre au domaine webhook**
|
|
496
|
-
(`WEBHOOK_DERIVATION`, `webhookCipher.ts
|
|
499
|
+
(`WEBHOOK_DERIVATION`, `webhookCipher.ts#WEBHOOK_DERIVATION`).
|
|
497
500
|
|
|
498
501
|
Ce cloisonnement n'est pas cosmétique : un blob webhook ne se déchiffre **pas** avec la clé TOTP, et
|
|
499
502
|
réciproquement — un banc d'attaque vérifie cette confusion de domaine, ainsi que le refus des blobs
|
|
@@ -514,7 +517,7 @@ curl -sk -b /tmp/jar -X POST \
|
|
|
514
517
|
# {"endpoint":{…}, "secret":"whsec_NOUVEAU…"}
|
|
515
518
|
```
|
|
516
519
|
|
|
517
|
-
`WebhookService.rotateSecret()` (`webhooks.ts
|
|
520
|
+
`WebhookService.rotateSecret()` (`webhooks.ts#WebhookService.rotateSecret`) régénère et rechiffre. Comportement à connaître
|
|
518
521
|
**avant** de cliquer :
|
|
519
522
|
|
|
520
523
|
- l'ancien secret cesse d'être valide **immédiatement** — il n'y a pas de fenêtre de recouvrement
|
|
@@ -527,31 +530,31 @@ curl -sk -b /tmp/jar -X POST \
|
|
|
527
530
|
## 🛡️ Défenses SSRF — ce qu'une URL de destination ne peut pas être
|
|
528
531
|
|
|
529
532
|
C'est la partie la plus attaquée de la brique, et celle qui porte le plus de tests
|
|
530
|
-
(`webhookSsrf.attack.test.ts`). Le contrôle vit dans `assertPublicUrl()` (`ssrfGuard.ts
|
|
533
|
+
(`webhookSsrf.attack.test.ts`). Le contrôle vit dans `assertPublicUrl()` (`ssrfGuard.ts#assertPublicUrl`),
|
|
531
534
|
appliqué **deux fois** : à l'enregistrement, et **à nouveau juste avant chaque livraison**.
|
|
532
535
|
|
|
533
536
|
### Ce qui est refusé
|
|
534
537
|
|
|
535
|
-
| Ce que l'attaquant tente | Exemple | Défense
|
|
536
|
-
| --------------------------------------------------- | ------------------------------------------- |
|
|
537
|
-
| Schéma exotique (Gopher, Redis, `file`…) | `redis://1.1.1.1:6379/` | allowlist stricte `https:` (+ `http:` si `allowHttp`) — `ssrfGuard.ts
|
|
538
|
-
| 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
|
|
539
|
-
| IP littérale interne | `https://127.0.0.1/` | 15 plages IPv4 bloquées (`BLOCKED_V4`, `ssrfGuard.ts
|
|
540
|
-
| Métadonnées cloud (credentials IAM) | `http://169.254.169.254/` | plage `169.254.0.0/16` bloquée
|
|
541
|
-
| IPv6 interne, ULA, link-local, NAT64, 6to4 | `https://[fe80::1%25eth0]/` | 9 plages IPv6 bloquées (`BLOCKED_V6`, `ssrfGuard.ts
|
|
542
|
-
| 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
|
|
543
|
-
| 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
|
|
544
|
-
| 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
|
|
545
|
-
| **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`webhookDelivery.ts
|
|
546
|
-
| **Redirection** `302 → 169.254.169.254` | — | `node:http(s)` ne suit **jamais** les 3xx ; le 3xx est rendu tel quel
|
|
538
|
+
| Ce que l'attaquant tente | Exemple | Défense |
|
|
539
|
+
| --------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
540
|
+
| Schéma exotique (Gopher, Redis, `file`…) | `redis://1.1.1.1:6379/` | allowlist stricte `https:` (+ `http:` si `allowHttp`) — `ssrfGuard.ts#IAssertPublicUrlOptions.allowHttp` |
|
|
541
|
+
| 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#assertPublicUrl`) |
|
|
542
|
+
| IP littérale interne | `https://127.0.0.1/` | 15 plages IPv4 bloquées (`BLOCKED_V4`, `ssrfGuard.ts#BLOCKED_V4`) |
|
|
543
|
+
| Métadonnées cloud (credentials IAM) | `http://169.254.169.254/` | plage `169.254.0.0/16` bloquée |
|
|
544
|
+
| IPv6 interne, ULA, link-local, NAT64, 6to4 | `https://[fe80::1%25eth0]/` | 9 plages IPv6 bloquées (`BLOCKED_V6`, `ssrfGuard.ts#BLOCKED_V6`) |
|
|
545
|
+
| 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#isBlockedAddress`) |
|
|
546
|
+
| 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#isBlockedAddress`) |
|
|
547
|
+
| 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 |
|
|
548
|
+
| **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`webhookDelivery.ts#deliverWebhook`) |
|
|
549
|
+
| **Redirection** `302 → 169.254.169.254` | — | `node:http(s)` ne suit **jamais** les 3xx ; le 3xx est rendu tel quel |
|
|
547
550
|
|
|
548
551
|
### Les deux subtilités qui font la différence
|
|
549
552
|
|
|
550
553
|
**Le pin d'IP.** Valider puis se reconnecter, c'est laisser une fenêtre : le DNS peut répondre une IP
|
|
551
|
-
publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts
|
|
554
|
+
publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts#deliverWebhook`)
|
|
552
555
|
installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
|
|
553
556
|
d'hôte pour le SNI/TLS et l'en-tête `Host`. Le second contrôle SSRF avant livraison —
|
|
554
|
-
`resolveTarget()` (`WebhookDispatcher.ts
|
|
557
|
+
`resolveTarget()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.resolveTarget`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
|
|
555
558
|
**abandonnée sans retry** (une cible devenue interne ne redeviendra pas légitime au réessai).
|
|
556
559
|
|
|
557
560
|
**Le non-suivi des redirections.** `fetch()` suit les 3xx par défaut — ce qui annulerait tout le
|
|
@@ -601,10 +604,10 @@ sequenceDiagram
|
|
|
601
604
|
`onAuditEvent()` est appelé **dans** la boucle de notification de `AuditService.record()` — trois
|
|
602
605
|
gardes empêchent le journal d'audit de payer le prix des webhooks :
|
|
603
606
|
|
|
604
|
-
1. **Court-circuit à coût nul** — `endpointCount()` (`webhooks.ts
|
|
607
|
+
1. **Court-circuit à coût nul** — `endpointCount()` (`webhooks.ts#WebhookService.endpointCount`) lit la taille d'une `Map` :
|
|
605
608
|
zéro endpoint = retour immédiat, aucune allocation (le cas dominant).
|
|
606
609
|
2. **Travail lourd différé** — JSON, HMAC et réseau partent dans un `queueMicrotask` coalescé
|
|
607
|
-
(`#schedulePump()`, `WebhookDispatcher.ts
|
|
610
|
+
(`#schedulePump()`, `WebhookDispatcher.ts#WebhookDispatcher.#schedulePump`), jamais dans la pile de l'appelant.
|
|
608
611
|
3. **Zéro E/S pour router** — `getSnapshot()` lit un **cache mémoire**, jamais le store : aucune
|
|
609
612
|
requête n'est faite pour décider qui doit recevoir un événement. Le cache est chargé au boot
|
|
610
613
|
(`#reloadSnapshot()`), tenu à jour par chaque écriture CRUD **du même pod**, et **rechargé quand
|
|
@@ -644,7 +647,7 @@ rafale d'événements ne déclenche **qu'une** relecture, et qu'un store en pann
|
|
|
644
647
|
|
|
645
648
|
### Politique de retry — ce qui est réessayé, et pendant combien de temps
|
|
646
649
|
|
|
647
|
-
`classifyDelivery()` (`WebhookDispatcher.ts
|
|
650
|
+
`classifyDelivery()` (`WebhookDispatcher.ts#classifyDelivery`) tranche en trois catégories :
|
|
648
651
|
|
|
649
652
|
| Issue de la tentative | Verdict | Pourquoi |
|
|
650
653
|
| ---------------------------------------- | ------------------- | --------------------------------------------------------- |
|
|
@@ -654,8 +657,8 @@ rafale d'événements ne déclenche **qu'une** relecture, et qu'un store en pann
|
|
|
654
657
|
| `3xx` | **échec définitif** | redirection non suivie = configuration cliente à corriger |
|
|
655
658
|
| `4xx` (hors 408/429) | **échec définitif** | erreur cliente : réessayer ne changera rien |
|
|
656
659
|
|
|
657
|
-
Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`WebhookDispatcher.ts
|
|
658
|
-
`5 s × 2^tentative`, plafonné à 5 min (`MAX_BACKOFF_MS`, `WebhookDispatcher.ts
|
|
660
|
+
Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`WebhookDispatcher.ts#backoffMs`) :
|
|
661
|
+
`5 s × 2^tentative`, plafonné à 5 min (`MAX_BACKOFF_MS`, `WebhookDispatcher.ts#MAX_BACKOFF_MS`).
|
|
659
662
|
|
|
660
663
|
| Tentative | 0 | 1 | 2 | 3 | 4 | 5 | 6+ |
|
|
661
664
|
| --------- | --- | ---- | ---- | ---- | ---- | ----- | ----------- |
|
|
@@ -663,7 +666,7 @@ Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`Webh
|
|
|
663
666
|
|
|
664
667
|
Avec le défaut `maxRetries: 5`, une livraison est tentée **6 fois** sur environ 4 min 15 avant
|
|
665
668
|
abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
|
|
666
|
-
`WebhookDispatcher.ts
|
|
669
|
+
`WebhookDispatcher.ts#WebhookDispatcher.#scheduleRetry`) : un pic de retries ne peut pas contourner le plafond mémoire.
|
|
667
670
|
|
|
668
671
|
> [!NOTE]
|
|
669
672
|
> Le backoff est **déterministe, sans jitter**. Sur un seul pod c'est sans conséquence ; sur N pods
|
|
@@ -672,7 +675,7 @@ abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
|
|
|
672
675
|
|
|
673
676
|
### Auto-désactivation d'un endpoint mort
|
|
674
677
|
|
|
675
|
-
Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts
|
|
678
|
+
Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts#WebhookService.markDelivery`) : succès →
|
|
676
679
|
`failureCount = 0` ; échec → incrément. Au-delà de `autoDisableThreshold` (défaut **20**), l'endpoint
|
|
677
680
|
est **désactivé** et un unique événement d'audit `webhook.disabled` est émis — **un par endpoint qui
|
|
678
681
|
meurt**, jamais un par échec (le volume resterait ingérable). Mettre le seuil à `0` désactive
|
|
@@ -685,7 +688,7 @@ Le scénario vécu, du début à la fin :
|
|
|
685
688
|
1. **Tentative 1** → `ECONNREFUSED`. Classé `retry` ; rien n'est encore écrit en base.
|
|
686
689
|
2. **Tentatives 2 à 6** sur ~4 min. Toujours rien de persisté (seule l'issue finale l'est).
|
|
687
690
|
3. **Abandon.** `markDelivery` écrit `lastDeliveryStatus: null`, `lastDeliveryError`, et incrémente
|
|
688
|
-
`failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts
|
|
691
|
+
`failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts#WebhookService.#recordDelivery`).
|
|
689
692
|
4. **L'événement est PERDU.** Il n'y a pas de file persistée : un webhook est **best-effort**. Rien
|
|
690
693
|
ne sera rejoué quand le destinataire reviendra.
|
|
691
694
|
5. **Après 20 échecs consécutifs**, l'endpoint passe `enabled: false` et cesse de consommer des
|
|
@@ -698,14 +701,14 @@ Le scénario vécu, du début à la fin :
|
|
|
698
701
|
|
|
699
702
|
### Arrêt propre
|
|
700
703
|
|
|
701
|
-
`WebhookService.#shutdown()` (`webhooks.ts
|
|
702
|
-
`WebhookDispatcher.shutdown()` (`WebhookDispatcher.ts
|
|
704
|
+
`WebhookService.#shutdown()` (`webhooks.ts#WebhookService.#shutdown`) se désabonne de l'audit puis appelle
|
|
705
|
+
`WebhookDispatcher.shutdown()` (`WebhookDispatcher.ts#WebhookDispatcher.shutdown`) : admission stoppée, **tous les timers de
|
|
703
706
|
retry annulés**, file relâchée. Aucun timer orphelin ne retient le process — et les timers de retry
|
|
704
|
-
sont `unref()` (`webhooks.ts
|
|
707
|
+
sont `unref()` (`webhooks.ts#WebhookService.#attachDispatcher`), donc ils n'empêchent jamais Node de sortir.
|
|
705
708
|
|
|
706
709
|
## ⚙️ Configuration
|
|
707
710
|
|
|
708
|
-
Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts
|
|
711
|
+
Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts#webhooksSchema`), lue via
|
|
709
712
|
`use("@nodefony/security", { webhooks: … })`.
|
|
710
713
|
|
|
711
714
|
| Option | Type | Défaut | Effet |
|
|
@@ -717,7 +720,7 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
|
|
|
717
720
|
| `timestampToleranceS` | `int > 0` | `300` | Fenêtre anti-rejeu **recommandée au récepteur** (voir la note ci-dessous). |
|
|
718
721
|
| `maxRetries` | `int ≥ 0` | `5` | Réessais après la 1ʳᵉ tentative → 6 envois au total. |
|
|
719
722
|
| `autoDisableThreshold` | `int ≥ 0` | `20` | Échecs consécutifs avant désactivation automatique. `0` = jamais. |
|
|
720
|
-
| `deliveryTimeoutMs` | `int > 0` | `10000` | Délai dur d'une tentative ; au-delà, `req.destroy()` (`webhookDelivery.ts
|
|
723
|
+
| `deliveryTimeoutMs` | `int > 0` | `10000` | Délai dur d'une tentative ; au-delà, `req.destroy()` (`webhookDelivery.ts#deliverWebhook`). |
|
|
721
724
|
| `maxConcurrent` | `int > 0` | `8` | Livraisons simultanées : borne les sockets/FD qu'un endpoint lent peut immobiliser. |
|
|
722
725
|
| `maxQueue` | `int > 0` | `1000` | File d'attente ; au-delà, **abandon** annoncé par log (best-effort, mémoire bornée). |
|
|
723
726
|
| `denyPrivateIps` | `boolean` | `true` | Contrôle SSRF. `false` **saute entièrement** la résolution et le contrôle d'IP (dev only). |
|
|
@@ -725,14 +728,14 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
|
|
|
725
728
|
|
|
726
729
|
> [!NOTE]
|
|
727
730
|
> `timestampToleranceS` est **transporté** dans la politique de livraison
|
|
728
|
-
> (`getDeliveryPolicy()`, `webhooks.ts
|
|
731
|
+
> (`getDeliveryPolicy()`, `webhooks.ts#WebhookService.getDeliveryPolicy`) mais l'émetteur ne l'applique jamais : la fenêtre
|
|
729
732
|
> anti-rejeu est par nature un contrôle du **récepteur**. Traite cette valeur comme la tolérance que
|
|
730
733
|
> tu documentes à tes destinataires — c'est celle du récepteur qui protège.
|
|
731
734
|
|
|
732
735
|
## Entité de persistance — le registre d'endpoints
|
|
733
736
|
|
|
734
737
|
Un endpoint est une **configuration durable**, pas un cache : il survit aux redémarrages et se
|
|
735
|
-
partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.ts
|
|
738
|
+
partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.ts#IWebhookEndpoint`), plat et
|
|
736
739
|
« tout `| null` ».
|
|
737
740
|
|
|
738
741
|
<!-- prettier-ignore -->
|
|
@@ -754,29 +757,29 @@ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.t
|
|
|
754
757
|
| `metadata` | extras applicatifs (jamais de secret) | `text mode:json` · `jsonb` · `json` | `Object` |
|
|
755
758
|
|
|
756
759
|
Côté SQL, la table est décrite **une seule fois** en spec logique
|
|
757
|
-
(`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts
|
|
760
|
+
(`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts#WEBHOOK_ENDPOINT_TABLE_SPEC`) puis déclinée
|
|
758
761
|
par dialecte via le `colKit`. Côté documentaire, le schéma force `_id: String` — l'identifiant
|
|
759
762
|
`wh_…` **est** la clé primaire, pas un `ObjectId` généré
|
|
760
|
-
(`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts
|
|
763
|
+
(`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts#webhookEndpointSchema`).
|
|
761
764
|
|
|
762
765
|
Ce qui **n'est pas** persisté : l'historique des livraisons. Il vit en RAM, borné à 20 entrées par
|
|
763
|
-
endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts
|
|
764
|
-
de réponse à 2 Ko (`RESPONSE_BODY_CAP`, `webhookDelivery.ts
|
|
766
|
+
endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts#MAX_DELIVERIES_PER_ENDPOINT`), corps de requête tronqué à 8 Ko, corps
|
|
767
|
+
de réponse à 2 Ko (`RESPONSE_BODY_CAP`, `webhookDelivery.ts#RESPONSE_BODY_CAP`) — et il est **par pod**. C'est de
|
|
765
768
|
l'observabilité éphémère de mise au point, pas un journal d'audit.
|
|
766
769
|
|
|
767
770
|
## Backends pris en charge — trois enregistrés, un exclu volontairement
|
|
768
771
|
|
|
769
772
|
| Backend | Enregistrement | Durable | Partagé multi-pod | Usage |
|
|
770
773
|
| ---------- | ------------------------------------------------------------ | :-----: | :---------------: | ------------------------------ |
|
|
771
|
-
| `memory` | intégré, à l'import (`webhookStoreRegistry.ts
|
|
774
|
+
| `memory` | intégré, à l'import (`webhookStoreRegistry.ts`) | ❌ | ❌ | dev, tests |
|
|
772
775
|
| `drizzle` | auto-register de l'adapter (SQLite/PostgreSQL/MySQL/MariaDB) | ✅ | ✅ | production SQL |
|
|
773
776
|
| `mongoose` | auto-register de l'adapter | ✅ | ✅ | production documentaire |
|
|
774
777
|
| `redis` | **volontairement absent** | — | — | un registre n'est pas un cache |
|
|
775
778
|
|
|
776
779
|
Redis n'est pas une omission : un endpoint est de la **configuration durable**, sa place n'est pas
|
|
777
|
-
dans un magasin volatil (`IWebhookStore.ts
|
|
780
|
+
dans un magasin volatil (`IWebhookStore.ts#IWebhookStore`).
|
|
778
781
|
|
|
779
|
-
La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts
|
|
782
|
+
La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts#WebhookService.#resolveStore`)
|
|
780
783
|
privilégie un adapter déjà posé au container, puis résout `auto` d'après l'infra déclarée, et
|
|
781
784
|
**enregistre sa décision** (visible dans Studio). Deux garde-fous :
|
|
782
785
|
|
|
@@ -786,32 +789,32 @@ privilégie un adapter déjà posé au container, puis résout `auto` d'après l
|
|
|
786
789
|
|
|
787
790
|
### Pagination du registre
|
|
788
791
|
|
|
789
|
-
`WebhookService.listPage()` (`webhooks.ts
|
|
792
|
+
`WebhookService.listPage()` (`webhooks.ts#WebhookService.listPage`) délègue au store — la console n'a **jamais** tout le
|
|
790
793
|
registre en RAM. Ce contrat est vérifié par un **banc unique** rejoué sur tous les backends
|
|
791
794
|
(`webhookPaginationContract.ts`) : mêmes 12 endpoints de seed, mêmes assertions.
|
|
792
795
|
|
|
793
796
|
- Ordre par défaut : `createdAt` DESC, départagé par `id` ASC — sans ce tiebreaker, deux endpoints
|
|
794
797
|
créés dans la même milliseconde pourraient changer de page et l'un ne jamais apparaître.
|
|
795
798
|
- Tri demandable, mais **sur un vocabulaire déclaré** : `createdAt`, `updatedAt`, `url`, `enabled`,
|
|
796
|
-
`failureCount`, `id` (`WEBHOOK_SORTABLE_FIELDS`, `webhookSort.ts
|
|
799
|
+
`failureCount`, `id` (`WEBHOOK_SORTABLE_FIELDS`, `webhookSort.ts#WEBHOOK_SORTABLE_FIELDS`). Un champ hors liste est
|
|
797
800
|
refusé, pas ignoré — et le store publie ce qu'il sait trier (`ISortableSource.sortableFields`,
|
|
798
|
-
`IWebhookStore.ts
|
|
801
|
+
`IWebhookStore.ts#IWebhookStore.save`), plutôt que de laisser le front le deviner. Les colonnes **nullables** en
|
|
799
802
|
sont volontairement absentes : PostgreSQL range les `NULL` en tête d'un `DESC` là où
|
|
800
803
|
SQLite/MySQL/mémoire les rangent en queue — un tri dont l'ordre dépend de la base configurée ne
|
|
801
804
|
vaut pas mieux qu'un tri absent.
|
|
802
805
|
- Filtres appliqués **au store**, jamais après un chargement complet : `enabled`, `event`
|
|
803
806
|
(appartenance au tableau JSON — « qui écoute `user.created` ? »), `failing` (au moins un échec
|
|
804
|
-
consécutif courant — « qu'est-ce qui casse ? », `IWebhookStore.ts
|
|
807
|
+
consécutif courant — « qu'est-ce qui casse ? », `IWebhookStore.ts#IWebhookListQuery.failing`), `q` (sous-chaîne
|
|
805
808
|
insensible à la casse sur `url` **ou** `description`).
|
|
806
809
|
- Mode unique **offset** : tous les backends d'endpoints savent le faire, aucune capacité n'est donc
|
|
807
|
-
à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts
|
|
808
|
-
`DrizzleWebhookStore.ts
|
|
810
|
+
à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts#MemoryWebhookStore.listPage` ;
|
|
811
|
+
`DrizzleWebhookStore.ts#DrizzleWebhookStore.listPage` ; `MongooseWebhookStore.ts#MongooseWebhookStore.listPage`).
|
|
809
812
|
- **Les compteurs suivent la recherche.** `GET webhooks/stats` déclare `search`
|
|
810
|
-
(`WebhookAdminApi.ts
|
|
813
|
+
(`WebhookAdminApi.ts#webhookAdminEndpoints`) et descend le même `q` jusqu'au store : un terme sans correspondance
|
|
811
814
|
vide les cartes autant que le tableau. Sans cela, la console afficherait « 12 endpoints » au-dessus
|
|
812
815
|
d'une liste filtrée à 2 — un chiffre qui ne répond plus à la question posée à l'écran.
|
|
813
816
|
|
|
814
|
-
`IWebhookStore.listAll()` (`IWebhookStore.ts
|
|
817
|
+
`IWebhookStore.listAll()` (`IWebhookStore.ts#IWebhookStore.listAll`) existe toujours, mais il est **réservé au snapshot
|
|
815
818
|
du dispatcher** : celui-ci doit connaître tous les abonnements pour ne rater aucune livraison. C'est
|
|
816
819
|
un cold-path (boot + après écriture CRUD), jamais un chemin d'affichage.
|
|
817
820
|
|
|
@@ -822,19 +825,19 @@ un cold-path (boot + après écriture CRUD), jamais un chemin d'affichage.
|
|
|
822
825
|
Résolu depuis le container (`container.get("webhooks")`), toutes les méthodes sont asynchrones sauf
|
|
823
826
|
mention.
|
|
824
827
|
|
|
825
|
-
| Méthode | Rôle | Ancrage
|
|
826
|
-
| ----------------------------- | ------------------------------------------------------------------ |
|
|
827
|
-
| `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts
|
|
828
|
-
| `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts
|
|
829
|
-
| `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts
|
|
830
|
-
| `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts
|
|
831
|
-
| `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts
|
|
832
|
-
| `setEnabled(id, bool)` | Révocation douce | `webhooks.ts
|
|
833
|
-
| `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts
|
|
834
|
-
| `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts
|
|
835
|
-
| `delete(id)` | Supprime ; `false` si absent | `webhooks.ts
|
|
836
|
-
| `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts
|
|
837
|
-
| `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts
|
|
828
|
+
| Méthode | Rôle | Ancrage |
|
|
829
|
+
| ----------------------------- | ------------------------------------------------------------------ | -------------------------------------------- |
|
|
830
|
+
| `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts#WebhookService.register` |
|
|
831
|
+
| `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts#WebhookService.listPage` |
|
|
832
|
+
| `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts#WebhookService.countEndpoints` |
|
|
833
|
+
| `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts#WebhookService.getEndpoint` |
|
|
834
|
+
| `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts#IWebhookRegisterInput.metadata` |
|
|
835
|
+
| `setEnabled(id, bool)` | Révocation douce | `webhooks.ts#WebhookService.setEnabled` |
|
|
836
|
+
| `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts#WebhookService.rotateSecret` |
|
|
837
|
+
| `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts#WebhookService.revealSecret` |
|
|
838
|
+
| `delete(id)` | Supprime ; `false` si absent | `webhooks.ts#WebhookService.delete` |
|
|
839
|
+
| `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts#WebhookService.listDeliveries` |
|
|
840
|
+
| `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts#WebhookService.isReady` |
|
|
838
841
|
|
|
839
842
|
Types et briques réutilisables exportés par `@nodefony/security` : `IWebhookEndpoint`,
|
|
840
843
|
`WebhookEndpointSummary`, `IWebhookStore`, `IWebhookListQuery`, `MemoryWebhookStore`,
|
|
@@ -861,19 +864,19 @@ porte d'idempotence sur les mutations.
|
|
|
861
864
|
|
|
862
865
|
Deux détails de conception qui se voient à l'usage :
|
|
863
866
|
|
|
864
|
-
- **`reveal` est un `POST`, pas un `GET`** (`WebhookAdminApi.ts
|
|
867
|
+
- **`reveal` est un `POST`, pas un `GET`** (`WebhookAdminApi.ts#webhookAdminEndpoints`) : un secret n'a rien à faire
|
|
865
868
|
dans une URL, donc ni dans un journal d'accès, ni dans un `Referer`. Le `POST` impose en prime la
|
|
866
869
|
protection CSRF.
|
|
867
870
|
- **La lecture est défensive, jamais `503`** : webhooks désactivés → la console affiche un badge
|
|
868
871
|
honnête (`enabled: false`) et une liste vide, plutôt qu'une erreur. Les **mutations**, elles,
|
|
869
872
|
rendent bien `503` si le service n'est pas prêt.
|
|
870
873
|
- **Le listing est borné** : `limit` par défaut 50, plafond dur **200**
|
|
871
|
-
(`parseWebhookListQuery()`, `WebhookAdminApi.ts
|
|
874
|
+
(`parseWebhookListQuery()`, `WebhookAdminApi.ts#parseWebhookListQuery`) — un client ne peut pas demander « tout ».
|
|
872
875
|
|
|
873
876
|
## 🧩 Extension — brancher son propre registre
|
|
874
877
|
|
|
875
878
|
Le registre de stores est un simple `Map` nom → fabrique (`registerWebhookStore()`,
|
|
876
|
-
`webhookStoreRegistry.ts
|
|
879
|
+
`webhookStoreRegistry.ts#registerWebhookStore`). Implémenter `IWebhookStore` (6 méthodes) suffit ; aucun couplage au
|
|
877
880
|
cœur.
|
|
878
881
|
|
|
879
882
|
```typescript ignore
|
|
@@ -892,16 +895,16 @@ de filtres et de bornes sont alors prouvés, pas supposés.
|
|
|
892
895
|
|
|
893
896
|
## 📜 Normes appliquées
|
|
894
897
|
|
|
895
|
-
| Domaine | Norme / référence | Ancrage
|
|
896
|
-
| ----------------------- | ------------------------------------- |
|
|
897
|
-
| HMAC | RFC 2104 (via `node:crypto`) | `signStandardWebhook()` (`webhookSignature.ts
|
|
898
|
-
| Schéma de signature | Standard Webhooks v1 | `webhookSignatureHeaders()` (`webhookSignature.ts
|
|
899
|
-
| Dérivation de clé | RFC 5869 (HKDF-SHA256) | `deriveWebhookKey()` (`webhookCipher.ts
|
|
900
|
-
| Chiffrement au repos | AES-256-GCM (chiffrement authentifié) | `secretCipher.ts
|
|
901
|
-
| SSRF | OWASP A10:2021, CAPEC-664 | `assertPublicUrl()` (`ssrfGuard.ts
|
|
902
|
-
| Plages non routables | RFC 1918, 6598, 3927, 7526 | `BLOCKED_V4` (`ssrfGuard.ts
|
|
903
|
-
| Réessai sur `429`/`408` | RFC 6585, RFC 9110 | `classifyDelivery()` (`WebhookDispatcher.ts
|
|
904
|
-
| Comparaison de secret | temps constant | `timingSafeEqual` côté récepteur (`WebhookSinkController.ts
|
|
898
|
+
| Domaine | Norme / référence | Ancrage |
|
|
899
|
+
| ----------------------- | ------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
900
|
+
| HMAC | RFC 2104 (via `node:crypto`) | `signStandardWebhook()` (`webhookSignature.ts#signStandardWebhook`) |
|
|
901
|
+
| Schéma de signature | Standard Webhooks v1 | `webhookSignatureHeaders()` (`webhookSignature.ts#webhookSignatureHeaders`) |
|
|
902
|
+
| Dérivation de clé | RFC 5869 (HKDF-SHA256) | `deriveWebhookKey()` (`webhookCipher.ts#deriveWebhookKey`) |
|
|
903
|
+
| Chiffrement au repos | AES-256-GCM (chiffrement authentifié) | `secretCipher.ts#encryptSecret` |
|
|
904
|
+
| SSRF | OWASP A10:2021, CAPEC-664 | `assertPublicUrl()` (`ssrfGuard.ts#assertPublicUrl`) |
|
|
905
|
+
| Plages non routables | RFC 1918, 6598, 3927, 7526 | `BLOCKED_V4` (`ssrfGuard.ts#BLOCKED_V4`), `BLOCKED_V6` (`ssrfGuard.ts#BLOCKED_V6`) |
|
|
906
|
+
| Réessai sur `429`/`408` | RFC 6585, RFC 9110 | `classifyDelivery()` (`WebhookDispatcher.ts#classifyDelivery`) |
|
|
907
|
+
| Comparaison de secret | temps constant | `timingSafeEqual` côté récepteur (`WebhookSinkController.ts#verifySignature`) |
|
|
905
908
|
|
|
906
909
|
## ⚡ Performance & mémoire
|
|
907
910
|
|
|
@@ -911,12 +914,12 @@ qu'il y en a.
|
|
|
911
914
|
<!-- prettier-ignore -->
|
|
912
915
|
| Mécanisme | Borne | Ancrage |
|
|
913
916
|
| --- | --- | --- |
|
|
914
|
-
| Court-circuit hot-path | 0 allocation si `endpointCount() == 0` | `WebhookDispatcher.ts
|
|
915
|
-
| File d'attente | `maxQueue` (1000) puis **abandon** + log | `WebhookDispatcher.ts
|
|
916
|
-
| Sockets / FD simultanés | `maxConcurrent` (8) | `WebhookDispatcher.#pump()` (`WebhookDispatcher.ts
|
|
917
|
-
| Durée d'une tentative | 10 s puis `req.destroy()` | `webhookDelivery.ts
|
|
918
|
-
| Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts
|
|
919
|
-
| Allocations paresseuses | file, `Set` de timers, historique : `null` tant qu'inutilisés | `WebhookDispatcher.ts
|
|
917
|
+
| Court-circuit hot-path | 0 allocation si `endpointCount() == 0` | `WebhookDispatcher.ts#IWebhookDispatcherDeps.endpointCount` |
|
|
918
|
+
| File d'attente | `maxQueue` (1000) puis **abandon** + log | `WebhookDispatcher.ts#WebhookDispatcher.#enqueue` |
|
|
919
|
+
| Sockets / FD simultanés | `maxConcurrent` (8) | `WebhookDispatcher.#pump()` (`WebhookDispatcher.ts#WebhookDispatcher.#pump`) |
|
|
920
|
+
| Durée d'une tentative | 10 s puis `req.destroy()` | `webhookDelivery.ts#deliverWebhook` |
|
|
921
|
+
| Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts#MAX_RECORDED_BODY` · `webhookDelivery.ts#RESPONSE_BODY_CAP` |
|
|
922
|
+
| Allocations paresseuses | file, `Set` de timers, historique : `null` tant qu'inutilisés | `WebhookDispatcher.ts#WebhookDispatcher.#queue` |
|
|
920
923
|
|
|
921
924
|
La preuve n'est pas déclarative : un banc d'attaque envoie **5000 événements vers un endpoint mort**
|
|
922
925
|
et vérifie que 4000 livraisons sont abandonnées (file plafonnée) et que le pic de connexions
|
|
@@ -924,12 +927,12 @@ simultanées ne dépasse jamais 8 (`webhookDispatch.attack.test.ts`).
|
|
|
924
927
|
|
|
925
928
|
Deux propriétés complètent le tableau : les timers de retry sont `unref()` (ils n'empêchent jamais
|
|
926
929
|
le process de sortir), et l'arrêt annule tout (`WebhookDispatcher.shutdown()`,
|
|
927
|
-
`WebhookDispatcher.ts
|
|
930
|
+
`WebhookDispatcher.ts#WebhookDispatcher.shutdown`) — aucun listener ni timer orphelin.
|
|
928
931
|
|
|
929
932
|
## 📡 Observabilité — Studio
|
|
930
933
|
|
|
931
934
|
L'écran **Webhooks** (`/nodefony/webhooks`, `Webhooks.tsx`) est la console de la brique. Il consomme
|
|
932
|
-
exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.ts
|
|
935
|
+
exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.ts#WEBHOOKS_ENDPOINT`) :
|
|
933
936
|
|
|
934
937
|
- **table paginée côté serveur** — URL, abonnements, état, dernière livraison, compteur d'échecs ;
|
|
935
938
|
- **formulaire de création/édition** avec validation d'URL avant envoi (`WebhookFormModal.tsx`) ;
|
|
@@ -938,13 +941,13 @@ exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.
|
|
|
938
941
|
- **panneau des livraisons récentes** — ce qui a été envoyé et ce que le destinataire a répondu
|
|
939
942
|
(`DeliveriesPanel.tsx`) ;
|
|
940
943
|
- **badge « où on écrit »** : `memory` ou `orm`, dérivé du nom de classe réel du store
|
|
941
|
-
(`webhookStoreDriver()`, `WebhookAdminApi.ts
|
|
944
|
+
(`webhookStoreDriver()`, `WebhookAdminApi.ts#webhookStoreDriver`) — un store tiers inconnu affiche `null` plutôt
|
|
942
945
|
qu'un driver inventé.
|
|
943
946
|
|
|
944
947
|
En développement, le module `test` fournit un **récepteur local** à demeure — le remplaçant
|
|
945
948
|
non-jetable de webhook.site. Il capture les en-têtes de signature, vérifie le HMAC si on lui passe le
|
|
946
949
|
secret, et sait **simuler des pannes** pour observer retries et auto-désactivation
|
|
947
|
-
(`WebhookSinkController.ts
|
|
950
|
+
(`WebhookSinkController.ts#WebhookSinkController`) :
|
|
948
951
|
|
|
949
952
|
| Route | Ce qu'elle fait |
|
|
950
953
|
| ------------------------------------------------- | ------------------------------------------------------ |
|
|
@@ -956,21 +959,21 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
|
|
|
956
959
|
|
|
957
960
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
958
961
|
|
|
959
|
-
| Symptôme | Cause (dans le code)
|
|
960
|
-
| ------------------------------------------------------------------------------ |
|
|
961
|
-
| Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts
|
|
962
|
-
| Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles
|
|
963
|
-
| `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo)
|
|
964
|
-
| Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts
|
|
965
|
-
| Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif
|
|
966
|
-
| Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré
|
|
967
|
-
| Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id`
|
|
968
|
-
| Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts
|
|
969
|
-
| Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé
|
|
970
|
-
| Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx
|
|
971
|
-
| Le secret a été perdu | Il n'est montré qu'à la création/rotation
|
|
972
|
-
| Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod
|
|
973
|
-
| Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts
|
|
962
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
963
|
+
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
|
964
|
+
| Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts#WebhookService.#resolveStore`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
|
|
965
|
+
| 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 |
|
|
966
|
+
| `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` |
|
|
967
|
+
| Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts#WebhookService.#attachDispatcher`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
|
|
968
|
+
| 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 |
|
|
969
|
+
| 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}` |
|
|
970
|
+
| 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 |
|
|
971
|
+
| Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts#WebhookService.rotateSecret`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
|
|
972
|
+
| Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
|
|
973
|
+
| 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 |
|
|
974
|
+
| Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
|
|
975
|
+
| Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
|
|
976
|
+
| Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts#WebhookService.#touchSnapshot`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
|
|
974
977
|
|
|
975
978
|
## 🧪 Tests & couverture
|
|
976
979
|
|