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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +63 -61
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +30 -11
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  8. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  9. package/dist/nodefony/errors/SsrfError.js +9 -1
  10. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  11. package/dist/nodefony/errors/index.js +7 -5
  12. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  13. package/dist/nodefony/keycloak/scaffold.js +31 -14
  14. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  15. package/dist/nodefony/service/accessTokenVerifier.js +6 -6
  16. package/dist/nodefony/service/apiKeys.js +8 -4
  17. package/dist/nodefony/service/auditService.js +48 -3
  18. package/dist/nodefony/service/authFlow.js +9 -6
  19. package/dist/nodefony/service/authorization.js +1 -1
  20. package/dist/nodefony/service/firewall.js +281 -44
  21. package/dist/nodefony/service/keycloak.js +1 -1
  22. package/dist/nodefony/service/oauth2.js +8 -4
  23. package/dist/nodefony/service/securityHeaders.js +340 -47
  24. package/dist/nodefony/service/tokenService.js +16 -11
  25. package/dist/nodefony/service/totp.js +10 -5
  26. package/dist/nodefony/service/webAuthn.js +46 -7
  27. package/dist/nodefony/service/webhooks.js +11 -4
  28. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  29. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  30. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  31. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  32. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  33. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
  34. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
  35. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
  36. package/dist/nodefony/src/csp.js +265 -10
  37. package/dist/nodefony/src/cspReports.js +307 -0
  38. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  39. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  40. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  41. package/dist/nodefony/src/oauth/metadata.js +1 -1
  42. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  43. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  44. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  45. package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
  46. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  47. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  48. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  49. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  50. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  51. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  52. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  53. package/dist/nodefony/testing/index.js +26 -36
  54. package/dist/types/index.d.ts +5 -1
  55. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  56. package/dist/types/nodefony/config/config.d.ts +8 -0
  57. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  58. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  59. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  60. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  61. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  62. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  63. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  64. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  65. package/dist/types/nodefony/errors/index.d.ts +3 -0
  66. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  67. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  68. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  69. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  70. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  71. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  72. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  73. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  74. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  75. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  76. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  77. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  78. package/dist/types/nodefony/src/csp.d.ts +142 -0
  79. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  80. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  81. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  82. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  83. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  84. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
  85. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  86. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  87. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  88. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  89. package/dist/types/nodefony/testing/index.d.ts +6 -0
  90. package/docs/api-keys.md +3 -1
  91. package/docs/audit.md +112 -28
  92. package/docs/authenticators.md +6 -4
  93. package/docs/csrf.md +3 -2
  94. package/docs/external-jwt.md +5 -1
  95. package/docs/firewall.md +3 -2
  96. package/docs/headers.md +427 -71
  97. package/docs/keycloak.md +4 -4
  98. package/docs/login-page.md +4 -3
  99. package/docs/obtenir-un-jeton.md +17 -10
  100. package/docs/tokens.md +1 -1
  101. package/docs/totp.md +2 -2
  102. package/docs/webauthn.md +2 -2
  103. package/docs/webhooks.md +63 -18
  104. package/package.json +17 -17
package/docs/keycloak.md CHANGED
@@ -265,10 +265,10 @@ lui, en a un de test — Mailpit (`--profile keycloak`, interface `http://localh
265
265
  processus dès le démarrage, commandes en ligne comprises (`addDefaultCaCertificate()`,
266
266
  `certificates.ts#addDefaultCaCertificate`). La variable `NODE_EXTRA_CA_CERTS` n'est plus nécessaire.
267
267
 
268
- Le realm généré porte aussi, comme celui du dépôt, un client **machine** `<app>-machine` (compte de
269
- service, `client_credentials`). Ses jetons portent l'audience de la zone `partner` que le gabarit
270
- `complete` ouvre (`https://localhost:5152/api/partner`) : `npx nodefony app:partner:call --issuer
271
- keycloak` obtient un jeton et appelle `GET /api/partner/whoami` dès le premier démarrage. Quand tu
268
+ Une application créée avec `--demo` reçoit aussi, comme le dépôt, un client **machine** `<app>-machine`
269
+ (compte de service, `client_credentials`). Ses jetons portent l'audience de la zone `demo-partner`
270
+ que la démo ouvre (`https://localhost:5152/demo/api/partner`) : `npx nodefony demo:partner:call
271
+ --issuer keycloak` obtient un jeton et appelle `GET /demo/api/partner/whoami` dès le premier démarrage. Quand tu
272
272
  ouvres une autre zone (section « Le jeton Keycloak sur ton API », plus bas),
273
273
  `npx nodefony security:keycloak:realm --write` ajoute le mapper qui inscrit sa `resource` dans les
274
274
  jetons.
@@ -118,8 +118,8 @@ redéfinissent pour les deux thèmes (voir « Pièges », plus bas) :
118
118
 
119
119
  ### Ce qu'on observe
120
120
 
121
- - `GET /login` rend la page, avec `Cache-Control: no-store` et une CSP
122
- `frame-ancestors 'none'` (`LoginPageController.ts#LoginPageController.constructor`) ;
121
+ - `GET /login` rend la page, avec `Cache-Control: no-store` et, par défaut, une CSP
122
+ `frame-ancestors 'none'` (réglée pour toute l’application par `http.securityHeaders.frameOptions`) ;
123
123
  - le titre de la carte vaut « Espace client », le panneau affiche votre accroche à la place de la
124
124
  vitrine Nodefony ;
125
125
  - votre feuille arrive en second `<link rel="stylesheet">`, après `login.css` : vos variables
@@ -303,7 +303,8 @@ nommant, au lieu d'être ignorée.
303
303
 
304
304
  ## 🔒 Sécurité
305
305
 
306
- - **Pas de cache partagé** (`no-store`) ni d'affichage dans un cadre (`frame-ancestors 'none'`) ;
306
+ - **Pas de cache partagé** (`no-store`) ; l’affichage dans un cadre suit `http.securityHeaders.frameOptions`,
307
+ refusé par défaut (`X-Frame-Options: DENY`, `frame-ancestors 'none'`) ;
307
308
  le script porte le nonce CSP de la requête.
308
309
  - **Mot de passe en clair refusé en production** : sur une requête qui n'arrive pas en HTTPS, la
309
310
  connexion répond 403 avant toute vérification, et la page affiche « doit passer par HTTPS » au
@@ -125,15 +125,15 @@ Puis `npm run build` — le runtime lit le `dist`, pas la source.
125
125
 
126
126
  ### Les options, et ce que chacune décide
127
127
 
128
- | Option | Défaut | Ce qu'elle change |
129
- | ---------------------- | -------------------------------------------- | -------------------------------------------------------- |
130
- | `[identifier]` | `admin` | le compte porteur du jeton |
131
- | `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
132
- | `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** audience |
133
- | `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
134
- | `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours** |
135
- | `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents |
136
- | `-j, --json` | — | sortie machine, sans invite |
128
+ | Option | Défaut | Ce qu'elle change |
129
+ | ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
130
+ | `[identifier]` | `admin` | le compte porteur du jeton |
131
+ | `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
132
+ | `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** porte — son jeton va dans l'en-tête de l'agent, jamais dans `NF_MCP_TOKEN` |
133
+ | `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
134
+ | `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours** |
135
+ | `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents — **refusé** avec un `--resource` qui vise une autre porte |
136
+ | `-j, --json` | — | sortie machine, sans invite |
137
137
 
138
138
  Le plafond de `--ttl` n'est pas décoratif : un jeton posé dans un fichier **est une clé**, et une
139
139
  clé se remplace (`security-mcp-token.ts#ttlSeconds`).
@@ -152,6 +152,13 @@ les agents dont la présence est **constatée** dans le projet — on ne crée p
152
152
  outil que personne n'utilise ici. La table des cibles vit au cœur
153
153
  (`src/nodefony/src/cli/agentTargets.ts#AGENT_TARGETS`) : Claude, Gemini, Vibe, Codex.
154
154
 
155
+ `NF_MCP_TOKEN` sert **la porte de l'outillage**, et elle seule. Un jeton émis pour une autre porte
156
+ (`--resource`) porte une autre audience : posé dans cette variable, il serait refusé par la porte de
157
+ l'outillage, dont les outils réservés disparaîtraient sans un mot. La commande refuse donc `--write`
158
+ dans ce cas, et n'affiche pas de ligne d'`export` (`security-mcp-token.ts#targetsOtherGate`) : ce
159
+ jeton va dans l'en-tête de l'agent qui parle à cette porte, par exemple
160
+ `claude mcp add --transport http <nom> <porte> --header "Authorization: Bearer <le jeton>"`.
161
+
155
162
  Deux garanties qui ne se négocient pas :
156
163
 
157
164
  - **jamais dans un fichier suivi par git** — un jeton commité est un jeton publié, et c'est la seule
@@ -246,7 +253,7 @@ audiences: ctx.isProd
246
253
  ? []
247
254
  : [
248
255
  // … les audiences du gabarit
249
- `${publicBaseUrl(ctx.env)}/api/mcp`,
256
+ `${publicBaseUrl(ctx.env)}/mcp`,
250
257
  ],
251
258
  ```
252
259
 
package/docs/tokens.md CHANGED
@@ -485,7 +485,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts#jwtSchema`) et `tok
485
485
  | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts#jwtSchema`) |
486
486
  | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts#jwtSchema`) |
487
487
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
488
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts#jwtSchema`) |
488
+ | `audiences` | string[] | `[]` | Liste blanche des `aud` ÉMETTABLES (RFC 8707), la 1ʳᵉ = défaut ; vide = `[issuer]`. Une zone `jwt` n’accepte que SA `resource`, sinon l’audience par défaut (`JwtAuthenticator.ts#JwtAuthenticator.authenticate`) |
489
489
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
490
490
  | `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts#jwtSchema`) |
491
491
  | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts#jwtSchema`) |
package/docs/totp.md CHANGED
@@ -581,7 +581,7 @@ TOTP et les passkeys (`cascadeUserRevocation`, `userRevocationCascade.ts#cascade
581
581
  recréé sous le même identifiant hériterait du second facteur de l'ancien titulaire. Un compte
582
582
  seulement désactivé ou verrouillé garde ses facteurs.
583
583
 
584
- Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts#TOTP_SECRET_TABLE_SPEC`), déclinée par
584
+ Spécification logique de la table (`TOTP_SECRET_TABLE`, `totpSecretEntity.ts#TOTP_SECRET_TABLE`), déclinée par
585
585
  dialecte via le colKit :
586
586
 
587
587
  | Colonne | Rôle | SQLite | PostgreSQL | MySQL / MariaDB |
@@ -600,7 +600,7 @@ dialecte via le colKit :
600
600
  Deux pièges de lecture, signalés dans l'entité elle-même :
601
601
 
602
602
  - `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
603
- partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
603
+ partout, quand les vraies dates sont en `int64` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
604
604
  - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
605
605
  (SQLite, lui, a des INTEGER 64 bits).
606
606
 
package/docs/webauthn.md CHANGED
@@ -504,8 +504,8 @@ clé privée n'existe que dans l'authenticator.
504
504
  | `backupState` | BS flag — la passkey **est** sauvegardée | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
505
505
  | `uvInitialized` | Une vérification humaine a déjà eu lieu | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
506
506
  | `nickname` | Surnom d'appareil, optionnel | `text` nullable | `String` (null) | champ absent si vide |
507
- | `createdAt` | Enrôlement (epoch ms) | `epochMs` notNull | `Number` requis | champ |
508
- | `lastUsedAt` | Dernière authentification réussie, ou `null` | `epochMs` nullable | `Number` (null) | champ absent si null |
507
+ | `createdAt` | Enrôlement (epoch ms) | `int64` notNull | `Number` requis | champ |
508
+ | `lastUsedAt` | Dernière authentification réussie, ou `null` | `int64` nullable | `Number` (null) | champ absent si null |
509
509
 
510
510
  Spécification SQL : `webAuthnCredentialEntity.ts#webAuthnCredentialSchema`, index sur `userId`
511
511
  (`drizzle/nodefony/entity/webAuthnCredentialEntity.ts#WebAuthnCredentialRow.userId`). Schéma documentaire :
package/docs/webhooks.md CHANGED
@@ -89,6 +89,7 @@ part (les autres événements ne sont pas souscrits) :
89
89
  ```json
90
90
  {
91
91
  "id": "msg_7Yb1kQ2pR8sT",
92
+ "timestamp": "2026-10-29T09:14:03.000Z",
92
93
  "type": "login.failure",
93
94
  "data": {
94
95
  "actor": "alice",
@@ -98,6 +99,13 @@ part (les autres événements ne sont pas souscrits) :
98
99
  }
99
100
  ```
100
101
 
102
+ > [!NOTE]
103
+ > **`timestamp` date la TENTATIVE, pas l'événement** : un réessai deux heures plus tard porte une
104
+ > heure plus tardive. L'heure de l'événement vit dans `data` (`ts` pour l'audit). Et `id` est
105
+ > exactement l'en-tête `webhook-id`, stable d'un réessai à l'autre : c'est la clé de déduplication.
106
+ > Le corps se type par `IWebhookPayload` (exporté par `@nodefony/security`,
107
+ > `IWebhookPayload.ts#IWebhookPayload`).
108
+
101
109
  > [!NOTE]
102
110
  > Ce qui peut partir est **ce que le journal d'audit de sécurité enregistre** : connexions, refus
103
111
  > d'accès, jetons, sessions, passkeys. Tes propres événements applicatifs — « commande payée »,
@@ -250,6 +258,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
250
258
  import { Buffer } from "node:buffer";
251
259
  // prettier-ignore
252
260
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
261
+ import type { IWebhookPayload } from "@nodefony/security";
253
262
  // NF_HOOK_SECRET se déclare dans env.ts (`envString`) : seul lecteur de l'environnement.
254
263
  import { env } from "../../env";
255
264
 
@@ -276,7 +285,7 @@ class HookMiniController extends Controller {
276
285
  if (got.length !== want.length || !timingSafeEqual(got, want)) {
277
286
  return this.renderJson({ error: "bad signature" }, 401);
278
287
  }
279
- this.log(`reçu : ${(JSON.parse(raw) as { type: string }).type}`, "INFO");
288
+ this.log(`reçu : ${(JSON.parse(raw) as IWebhookPayload).type}`, "INFO");
280
289
  return this.renderJson({ ok: true });
281
290
  }
282
291
  }
@@ -308,6 +317,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
308
317
  import { Buffer } from "node:buffer";
309
318
  // prettier-ignore
310
319
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
320
+ import type { IWebhookPayload } from "@nodefony/security";
311
321
  import { env } from "../../env";
312
322
 
313
323
  /** Secret `whsec_…` donné par l'émetteur — déclaré dans env.ts, jamais en dur. */
@@ -369,7 +379,7 @@ class HookController extends Controller {
369
379
 
370
380
  // 4. Dédupliquer par `webhook-id` AVANT d'agir (un retry rejoue le MÊME id),
371
381
  // puis répondre 2xx vite : le travail long part en tâche de fond.
372
- const event = JSON.parse(raw) as { id: string; type: string };
382
+ const event = JSON.parse(raw) as IWebhookPayload;
373
383
  this.log(`webhook ${event.id} — ${event.type}`, "INFO");
374
384
  return this.renderJson({ ok: true });
375
385
  }
@@ -431,20 +441,47 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts#AuditCateg
431
441
  | `webhook` | Vie des webhooks eux-mêmes (`webhook.created`, `webhook.disabled`) |
432
442
  | `config` | Mutation de config runtime depuis Studio |
433
443
 
444
+ > [!IMPORTANT]
445
+ > **Les noms de l'audit sont RÉSERVÉS.** Une émission applicative d'événements, quand elle viendra,
446
+ > refusera tout type qui appartient à l'audit (`login.*`, `token.*` et les autres familles
447
+ > ci-dessus) : sans cette réserve, un code applicatif pourrait fabriquer un `login.success` signé
448
+ > par le serveur, qu'un centre de sécurité abonné ne distinguerait pas d'un vrai
449
+ > ([ADR-0023](../../../../../docs/adr/0023-webhooks-evenements-et-abonnements.md), W3). Ne nommez
450
+ > pas vos futurs événements comme ceux de l'audit.
451
+
434
452
  > [!NOTE]
435
453
  > Trois catégories sont **réservées** au contrat sans émetteur à ce jour : `oauth`, `csrf`, `cors`
436
454
  > (`IAuditEvent.ts#AuditCategory`). S'y abonner est accepté, mais ne livre jamais rien.
437
455
 
438
456
  ### La syntaxe d'abonnement
439
457
 
440
- Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubscription()`
441
- (`WebhookDispatcher.ts#matchesSubscription`) :
458
+ Le champ `events` d'un endpoint prend des **motifs**, dans la même grammaire que les canaux de la
459
+ socket : `*` vaut n'importe quelle suite de caractères, tout le reste est littéral, trois `*` au
460
+ plus. Un motif plus large est **refusé** à l'enregistrement, avec une erreur qui le nomme. La règle
461
+ vit une seule fois, au cœur (`channelPattern.ts#compileChannelPattern`), et
462
+ `matchesSubscription()` (`WebhookDispatcher.ts#matchesSubscription`) l'appelle :
442
463
 
443
- | Motif | Matche | Usage typique |
444
- | ----------------- | ----------------------------------------------------- | ------------------------------- |
445
- | `"*"` | **toutes** les actions | un SIEM qui veut tout |
446
- | `"login.success"` | l'action exacte, et elle seule | une alerte ciblée |
447
- | `"login.*"` | toute action **préfixée** `login.` (`login.failure`…) | suivre une famille d'événements |
464
+ | Motif | Désigne | Usage typique |
465
+ | ----------------- | ----------------------------------------------------- | --------------------------------- |
466
+ | `"*"` | **toutes** les actions | un SIEM qui veut tout |
467
+ | `"login.success"` | l'action exacte, et elle seule | une alerte ciblée |
468
+ | `"login.*"` | toute action **préfixée** `login.` (`login.failure`…) | suivre une famille d'événements |
469
+ | `"commande:*"` | `commande:42`, `commande:créée`… | un espace de noms applicatif |
470
+ | `"*:créée"` | toute action qui finit par `:créée` | un même geste sur tous les objets |
471
+
472
+ > [!IMPORTANT]
473
+ > **`"*"` veut dire tout, y compris ce qui viendra.** Aujourd'hui, seuls les événements d'audit
474
+ > sont livrés ; le jour où une application pourra émettre les siens, un abonnement `"*"` les
475
+ > recevra aussi. Qui ne veut que la sécurité liste ses familles (`login.*`, `token.*`…). Cette
476
+ > grammaire ne changera pas de sens dans la série 10
477
+ > ([ADR-0023](../../../../../docs/adr/0023-webhooks-evenements-et-abonnements.md), W2).
478
+ >
479
+ > **Le périmètre.** Le périmètre d'un événement est le locataire — l'organisation cliente — auquel
480
+ > il est rattaché. Un abonnement sans périmètre — le cas de tous aujourd'hui — a le périmètre de
481
+ > la **plateforme** : il reçoit tout, y compris les événements qu'une version ultérieure rattachera
482
+ > à un locataire (ADR-0023, W2). Le multi-tenant n'existant pas encore, `WebhookService.register`
483
+ > **refuse** un `tenantId` rempli (`webhooks.ts#WebhookService.register`) : l'accepter le ferait
484
+ > passer pour un cloisonnement qu'il n'est pas.
448
485
 
449
486
  > [!CAUTION]
450
487
  > Les événements de catégorie `webhook` **ne déclenchent jamais de livraison**
@@ -545,22 +582,30 @@ appliqué **deux fois** : à l'enregistrement, et **à nouveau juste avant chaqu
545
582
  | 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
583
  | 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
584
  | 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 |
585
+ | **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`fetchPublic.ts#fetchPublic`) |
586
+ | **Redirection** `302 → 169.254.169.254` | — | la livraison ne suit **aucune** redirection (`maxRedirects` à 0) ; le 3xx est rendu tel quel |
587
+ | **Goutte-à-goutte** : un octet toutes les 3 s | — | délai **total** de la livraison, pas seulement d'inactivité (`fetchPublic.ts#IFetchPublicOptions.timeoutMs`) |
588
+ | **Corps de réponse infini** | — | lecture coupée à 2 Kio et connexion détruite (`fetchPublic.ts#IFetchPublicOptions.maxBytes`) |
550
589
 
551
590
  ### Les deux subtilités qui font la différence
552
591
 
553
592
  **Le pin d'IP.** Valider puis se reconnecter, c'est laisser une fenêtre : le DNS peut répondre une IP
554
593
  publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts#deliverWebhook`)
555
- installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
594
+ appelle `fetchPublic()` (`fetchPublic.ts#fetchPublic`), qui installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
556
595
  d'hôte pour le SNI/TLS et l'en-tête `Host`. Le second contrôle SSRF avant livraison —
557
596
  `resolveTarget()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.resolveTarget`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
558
597
  **abandonnée sans retry** (une cible devenue interne ne redeviendra pas légitime au réessai).
559
598
 
560
599
  **Le non-suivi des redirections.** `fetch()` suit les 3xx par défaut — ce qui annulerait tout le
561
- travail précédent. Le choix de `node:http(s)` natif n'est donc pas seulement une économie de
562
- dépendance : c'est une **propriété de sécurité**, couverte par un test dédié
563
- (`webhookDelivery.test.ts`, « 302 vers 169.254.169.254 → rendu tel quel, JAMAIS suivi »).
600
+ travail précédent. `fetchPublic()` n'en suit aucune tant que `maxRedirects` reste à 0 (la livraison ne le change
601
+ jamais), et quand un appelant en autorise, chaque cible est revérifiée comme la première. Couvert par
602
+ `webhookDelivery.test.ts` (« 302 vers 169.254.169.254 → rendu tel quel, JAMAIS suivi ») et
603
+ `fetchPublic.attack.test.ts`.
604
+
605
+ **La même brique pour tout appel sortant.** `fetchPublic()` est exportée : tout code qui lit une adresse venue d'un
606
+ humain (aperçu de lien, agent, RAG) l'appelle au lieu de `fetch()` — IP vérifiée puis épinglée à chaque saut,
607
+ délai total, corps borné et non décodé. Refus de cible : `SsrfError` (422) ; site muet ou trop lent :
608
+ `FetchPublicError` (`reason`).
564
609
 
565
610
  > [!CAUTION]
566
611
  > `assertPublicUrl()` fait un `Fail-closed` sur l'inconnu : une adresse syntaxiquement invalide est
@@ -747,7 +792,7 @@ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.t
747
792
  | `events` | actions souscrites | `text mode:json` · `jsonb` · `json` | `[String]` |
748
793
  | `enabled` | actif ? | `integer mode:bool` · `boolean` · `boolean` | `Boolean` |
749
794
  | `description` | libellé console | `text` | `String` |
750
- | `tenantId` | slot multi-tenant (réservé) | `text` | `String` |
795
+ | `tenantId` | périmètre d’un locataire (réservé, toujours `null`) | `text` | `String` |
751
796
  | `createdBy` | admin créateur (traçabilité) | `text` | `String` |
752
797
  | `createdAt` / `updatedAt` | epoch **ms** | `integer` · `bigint` · `bigint` | `Number` |
753
798
  | `lastDeliveryAt` | dernière tentative (epoch ms) | `integer` · `bigint` · `bigint` | `Number` |
@@ -757,7 +802,7 @@ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.t
757
802
  | `metadata` | extras applicatifs (jamais de secret) | `text mode:json` · `jsonb` · `json` | `Object` |
758
803
 
759
804
  Côté SQL, la table est décrite **une seule fois** en spec logique
760
- (`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts#WEBHOOK_ENDPOINT_TABLE_SPEC`) puis déclinée
805
+ (`WEBHOOK_ENDPOINT_TABLE`, `drizzle/nodefony/entity/webhookEndpointEntity.ts#WEBHOOK_ENDPOINT_TABLE`) puis déclinée
761
806
  par dialecte via le `colKit`. Côté documentaire, le schéma force `_id: String` — l'identifiant
762
807
  `wh_…` **est** la clé primaire, pas un `ObjectId` généré
763
808
  (`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts#webhookEndpointSchema`).
@@ -979,7 +1024,7 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
979
1024
  | 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 |
980
1025
  | 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}` |
981
1026
  | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
982
- | 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 |
1027
+ | Un `302` vers l'interne n'est pas suivi | **Voulu** : la livraison ne suit aucune redirection | Rien à corriger — configurer l'URL finale côté destinataire |
983
1028
  | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
984
1029
  | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
985
1030
  | 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-beta.5",
3
+ "version": "10.0.0-beta.7",
4
4
  "description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "private": false,
37
37
  "engines": {
38
- "node": ">=24.14.0"
38
+ "node": "^24.16.0 || >=26.0.0"
39
39
  },
40
40
  "keywords": [
41
41
  "nodefony",
@@ -55,28 +55,28 @@
55
55
  "directory": "src/packages/@nodefony/security"
56
56
  },
57
57
  "dependencies": {
58
- "@simplewebauthn/server": "14.0.3",
59
- "jose": "6.2.13",
60
- "tslib": "2.8.1",
58
+ "@simplewebauthn/server": "14.1.0",
59
+ "jose": "6.2.17",
60
+ "tslib": "2.9.1",
61
61
  "zod": "^4.6.7"
62
62
  },
63
63
  "devDependencies": {
64
- "@nodefony/framework": "^10.0.0-beta.5",
65
- "@nodefony/http": "^10.0.0-beta.5",
66
- "@nodefony/user": "^10.0.0-beta.5",
67
- "@types/node": "26.6.5",
68
- "@vitest/coverage-v8": "5.0.5",
69
- "nodefony": "^10.0.0-beta.5",
70
- "vitest": "5.0.5"
64
+ "@nodefony/framework": "^10.0.0-beta.7",
65
+ "@nodefony/http": "^10.0.0-beta.7",
66
+ "@nodefony/user": "^10.0.0-beta.7",
67
+ "@types/node": "26.6.6",
68
+ "@vitest/coverage-v8": "5.1.0",
69
+ "nodefony": "^10.0.0-beta.7",
70
+ "vitest": "5.1.0"
71
71
  },
72
72
  "license": "Apache-2.0",
73
73
  "readmeFilename": "README.md",
74
74
  "contributors": [],
75
75
  "peerDependencies": {
76
- "@nodefony/framework": "^10.0.0-beta.5",
77
- "@nodefony/http": "^10.0.0-beta.5",
78
- "@nodefony/user": "^10.0.0-beta.5",
79
- "nodefony": "^10.0.0-beta.5"
76
+ "@nodefony/framework": "^10.0.0-beta.7",
77
+ "@nodefony/http": "^10.0.0-beta.7",
78
+ "@nodefony/user": "^10.0.0-beta.7",
79
+ "nodefony": "^10.0.0-beta.7"
80
80
  },
81
81
  "files": [
82
82
  "dist",
@@ -86,7 +86,7 @@
86
86
  "publishConfig": {
87
87
  "access": "public"
88
88
  },
89
- "homepage": "https://nodefony.github.io/nodefony-core/",
89
+ "homepage": "https://nodefony.net/",
90
90
  "bugs": {
91
91
  "url": "https://github.com/nodefony/nodefony-core/issues"
92
92
  }