@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.
- package/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +63 -61
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +30 -11
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +7 -5
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +6 -6
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +9 -6
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +281 -44
- package/dist/nodefony/service/keycloak.js +1 -1
- package/dist/nodefony/service/oauth2.js +8 -4
- package/dist/nodefony/service/securityHeaders.js +340 -47
- package/dist/nodefony/service/tokenService.js +16 -11
- package/dist/nodefony/service/totp.js +10 -5
- package/dist/nodefony/service/webAuthn.js +46 -7
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
- package/dist/nodefony/src/oauth/metadata.js +1 -1
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
- package/dist/nodefony/src/totp/totpOperations.js +1 -1
- package/dist/nodefony/src/voter/RoleVoter.js +1 -1
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +5 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
- package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/firewall.d.ts +28 -7
- package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/service/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/csp.d.ts +142 -0
- package/dist/types/nodefony/src/cspReports.d.ts +73 -0
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/dist/types/nodefony/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- package/docs/audit.md +112 -28
- package/docs/authenticators.md +6 -4
- package/docs/csrf.md +3 -2
- package/docs/external-jwt.md +5 -1
- package/docs/firewall.md +3 -2
- package/docs/headers.md +427 -71
- package/docs/keycloak.md +4 -4
- package/docs/login-page.md +4 -3
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +63 -18
- 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
|
-
|
|
269
|
-
service, `client_credentials`). Ses jetons portent l'audience de la zone `partner`
|
|
270
|
-
|
|
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.
|
package/docs/login-page.md
CHANGED
|
@@ -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'` (`
|
|
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`)
|
|
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
|
package/docs/obtenir-un-jeton.md
CHANGED
|
@@ -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**
|
|
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)}/
|
|
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`
|
|
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 (`
|
|
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 `
|
|
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) | `
|
|
508
|
-
| `lastUsedAt` | Dernière authentification réussie, ou `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
|
|
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
|
|
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
|
|
441
|
-
|
|
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 |
|
|
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 (`
|
|
549
|
-
| **Redirection** `302 → 169.254.169.254` | — |
|
|
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.
|
|
562
|
-
|
|
563
|
-
|
|
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` |
|
|
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
|
-
(`
|
|
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** :
|
|
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.
|
|
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": "
|
|
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
|
|
59
|
-
"jose": "6.2.
|
|
60
|
-
"tslib": "2.
|
|
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.
|
|
65
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
66
|
-
"@nodefony/user": "^10.0.0-beta.
|
|
67
|
-
"@types/node": "26.6.
|
|
68
|
-
"@vitest/coverage-v8": "5.0
|
|
69
|
-
"nodefony": "^10.0.0-beta.
|
|
70
|
-
"vitest": "5.0
|
|
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.
|
|
77
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
78
|
-
"@nodefony/user": "^10.0.0-beta.
|
|
79
|
-
"nodefony": "^10.0.0-beta.
|
|
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.
|
|
89
|
+
"homepage": "https://nodefony.net/",
|
|
90
90
|
"bugs": {
|
|
91
91
|
"url": "https://github.com/nodefony/nodefony-core/issues"
|
|
92
92
|
}
|