@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.2
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 +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/docs/api-keys.md +1 -1
- package/docs/audit.md +9 -7
- package/docs/authenticators.md +2 -2
- package/docs/authorization.md +6 -6
- package/docs/cors.md +3 -3
- package/docs/csrf.md +3 -3
- package/docs/external-jwt.md +2 -1
- package/docs/firewall.md +21 -21
- package/docs/headers.md +16 -16
- package/docs/index.md +3 -3
- package/docs/lexique.md +15 -15
- package/docs/oauth2.md +21 -18
- package/docs/tokens.md +5 -4
- package/docs/totp.md +15 -14
- package/docs/webauthn.md +38 -41
- package/docs/webhooks.md +18 -18
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Consomme [`@nodefony/user`](https://www.npmjs.com/package/@nodefony/user).
|
|
|
25
25
|
(cookie opaque BFF, révocable, `HttpOnly; Secure; SameSite`) ; les **API/agents** portent leur
|
|
26
26
|
preuve à chaque requête (**JWT** signé / clé API). Jamais « full stateless » : la session reste
|
|
27
27
|
la fondation web (révocation immédiate), le JWT est réservé au sans-état machine-à-machine.
|
|
28
|
-
- **Config type-safe** : schéma **Zod** (
|
|
28
|
+
- **Config type-safe** : schéma **Zod** (19 sections, la plupart désactivables par `enabled`), **introspectable** (Studio
|
|
29
29
|
génère son formulaire d'édition). L'app configure via `use("@nodefony/security", { … })`.
|
|
30
30
|
- **En-têtes natifs** (sans la lib `helmet`) — 0 dépendance, nonce CSP par requête.
|
|
31
31
|
|
|
@@ -67,7 +67,8 @@ export default defineConfig((ctx) => ({
|
|
|
67
67
|
|
|
68
68
|
> Une zone se déclare idéalement **par module** (override `module-security`, dans la config du
|
|
69
69
|
> module) pour vivre au plus près de ses routes. Authenticators fournis : `anonymous`, `session`,
|
|
70
|
-
> `userpassword`, `jwt`, `
|
|
70
|
+
> `userpassword`, `jwt`, `external-jwt`, `apikey`. WebAuthn, OAuth2 et TOTP n'en sont pas : ils
|
|
71
|
+
> ouvrent une session BFF, que l'authenticator `session` identifie ensuite. La chaîne est validée
|
|
71
72
|
> au boot (`mode: "first"` par défaut = le premier qui reconnaît authentifie ; `"all"` = tous
|
|
72
73
|
> requis, ex. mTLS + JWT). Config invalide → firewall **fail-closed** (tout rejeté).
|
|
73
74
|
|
|
@@ -117,7 +118,7 @@ csrf: {
|
|
|
117
118
|
> ouvrir la lecture CORS des réponses au JavaScript tiers. Une origine listée dans `cors.origins` est
|
|
118
119
|
> toutefois aussi acceptée (ce que CORS autorise explicitement n'est pas du CSRF).
|
|
119
120
|
|
|
120
|
-
Le token synchronizer renforcé
|
|
121
|
+
Le token synchronizer renforcé s'active par route avec `@CsrfProtect` ; `@CsrfExempt` en exclut une route.
|
|
121
122
|
|
|
122
123
|
### CORS — l'inverse du CSRF
|
|
123
124
|
|
|
@@ -141,8 +142,9 @@ const schema = securityConfigJsonSchema(); // JSON Schema → formulaire d'édit
|
|
|
141
142
|
## Journal d'audit (événements de sécurité)
|
|
142
143
|
|
|
143
144
|
Le journal trace les **transitions d'état** de sécurité (login, refus, jeton émis/révoqué, verrou
|
|
144
|
-
WS) — distinct du log de trafic (1 PDU/requête). Émission **explicite** par point sensible
|
|
145
|
-
|
|
145
|
+
WS) — distinct du log de trafic (1 PDU/requête). Émission **explicite** par point sensible, sur les
|
|
146
|
+
chemins froids (login, logout, émission ou révocation de jeton, refus) ; jamais par requête nominale :
|
|
147
|
+
le trafic courant ne passe pas par l'audit (cold-path)
|
|
146
148
|
→ aucun coût ajouté au hot-path nominal. Activé par défaut (`audit.enabled`, OWASP A09), coût nul si
|
|
147
149
|
désactivé. Lecture : `GET /nodefony/security/api/audit/events` (RBAC `ROLE_NODEFONY_ADMIN`).
|
|
148
150
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
|
|
|
77
77
|
import SecurityToken from "./nodefony/command/security-token.js";
|
|
78
78
|
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
79
79
|
import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
80
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
81
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
80
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
|
|
81
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
|
|
82
82
|
import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
|
|
83
83
|
import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
|
|
84
84
|
import "./nodefony/errors/index.js";
|
|
@@ -20,6 +20,8 @@ const NONCE_PLACEHOLDER = "{{nonce}}";
|
|
|
20
20
|
var SecurityHeaders = class {
|
|
21
21
|
#headers;
|
|
22
22
|
#cspParts;
|
|
23
|
+
#cspPrefix;
|
|
24
|
+
#cspSuffix;
|
|
23
25
|
#cspRaw;
|
|
24
26
|
constructor(o) {
|
|
25
27
|
const h = {};
|
|
@@ -39,6 +41,13 @@ var SecurityHeaders = class {
|
|
|
39
41
|
if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
|
|
40
42
|
this.#headers = Object.freeze(h);
|
|
41
43
|
this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
|
|
44
|
+
if (cspParts?.length === 2) {
|
|
45
|
+
this.#cspPrefix = cspParts[0] ?? "";
|
|
46
|
+
this.#cspSuffix = cspParts[1] ?? "";
|
|
47
|
+
} else {
|
|
48
|
+
this.#cspPrefix = null;
|
|
49
|
+
this.#cspSuffix = "";
|
|
50
|
+
}
|
|
42
51
|
this.#cspRaw = o.csp;
|
|
43
52
|
}
|
|
44
53
|
/** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
|
|
@@ -51,13 +60,15 @@ var SecurityHeaders = class {
|
|
|
51
60
|
}
|
|
52
61
|
/**
|
|
53
62
|
* Recompose le CSP en injectant le `nonce` de la requête aux emplacements
|
|
54
|
-
* `{{nonce}}
|
|
63
|
+
* `{{nonce}}` : une concaténation s'il n'y en a qu'un, un `join` sinon (segments
|
|
64
|
+
* pré-split au boot). Le nonce est base64 (jamais
|
|
55
65
|
* `'`/`;`/espace) → aucune évasion possible du token CSP.
|
|
56
66
|
*
|
|
57
67
|
* @param nonce - nonce base64 de la requête (`Context.cspNonce`).
|
|
58
68
|
* @returns la valeur `Content-Security-Policy` à poser sur la réponse.
|
|
59
69
|
*/
|
|
60
70
|
cspFor(nonce) {
|
|
71
|
+
if (this.#cspPrefix !== null) return this.#cspPrefix + nonce + this.#cspSuffix;
|
|
61
72
|
return this.#cspParts.join(nonce);
|
|
62
73
|
}
|
|
63
74
|
/**
|
|
@@ -43,7 +43,8 @@ export declare class SecurityHeaders {
|
|
|
43
43
|
get hasNonce(): boolean;
|
|
44
44
|
/**
|
|
45
45
|
* Recompose le CSP en injectant le `nonce` de la requête aux emplacements
|
|
46
|
-
* `{{nonce}}
|
|
46
|
+
* `{{nonce}}` : une concaténation s'il n'y en a qu'un, un `join` sinon (segments
|
|
47
|
+
* pré-split au boot). Le nonce est base64 (jamais
|
|
47
48
|
* `'`/`;`/espace) → aucune évasion possible du token CSP.
|
|
48
49
|
*
|
|
49
50
|
* @param nonce - nonce base64 de la requête (`Context.cspNonce`).
|
package/docs/api-keys.md
CHANGED
|
@@ -137,7 +137,7 @@ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
|
|
|
137
137
|
|
|
138
138
|
### 1. Déclarer la zone machine et les règles d'émission
|
|
139
139
|
|
|
140
|
-
Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
|
|
140
|
+
Dans une app générée par `nodefony create app`, tout se déclare dans la config du module — `nodefony/config/security.ts`, importé par `nodefony.config.ts`. Une zone
|
|
141
141
|
dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
|
|
142
142
|
|
|
143
143
|
```typescript
|
package/docs/audit.md
CHANGED
|
@@ -135,7 +135,8 @@ lourds s'enregistrent depuis **leur** module. Un
|
|
|
135
135
|
## 🚀 Démarrage rapide
|
|
136
136
|
|
|
137
137
|
Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
|
|
138
|
-
défaut, `config.ts:729`) sur
|
|
138
|
+
défaut, `config.ts:729`) sur le store que résout `auto` : la base SQLite locale de l'application
|
|
139
|
+
générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
|
|
139
140
|
relire.
|
|
140
141
|
|
|
141
142
|
### 1. Choisir où le journal est écrit
|
|
@@ -265,7 +266,7 @@ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
|
|
|
265
266
|
| `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
|
|
266
267
|
| `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
|
|
267
268
|
| `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
|
|
268
|
-
| `limit` | taille de page (défaut 100, **plafonné à
|
|
269
|
+
| `limit` | taille de page (défaut 100, **plafonné à 200**) | `limit=200` |
|
|
269
270
|
| `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
|
|
270
271
|
|
|
271
272
|
```bash
|
|
@@ -496,7 +497,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la
|
|
|
496
497
|
| Option | Type | Défaut | Effet |
|
|
497
498
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
498
499
|
| `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
|
|
499
|
-
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`
|
|
500
|
+
| `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
|
|
500
501
|
| `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
|
|
501
502
|
| `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
502
503
|
| `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
|
|
@@ -514,7 +515,8 @@ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:
|
|
|
514
515
|
|
|
515
516
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
516
517
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
517
|
-
3. sinon,
|
|
518
|
+
3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
|
|
519
|
+
4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
|
|
518
520
|
|
|
519
521
|
La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
|
|
520
522
|
de Studio : configuré, résolu, disponible, motif, emplacement physique.
|
|
@@ -702,10 +704,10 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
|
|
|
702
704
|
| Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
|
|
703
705
|
| Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
|
|
704
706
|
| Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
|
|
705
|
-
| `limit=5000` ne rend que
|
|
707
|
+
| `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts:77`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
|
|
706
708
|
| La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
|
|
707
|
-
|
|
|
708
|
-
| Le paramètre `q`
|
|
709
|
+
| `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
|
|
710
|
+
| Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
|
|
709
711
|
| `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
|
|
710
712
|
| Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
|
|
711
713
|
| Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
|
package/docs/authenticators.md
CHANGED
|
@@ -185,7 +185,7 @@ curl -si http://localhost:5151/api/v1/whoami | grep -E "^(HTTP|WWW)"
|
|
|
185
185
|
|
|
186
186
|
# 2) Web — login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
|
|
187
187
|
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
188
|
-
-d '{"username":"admin","password":"
|
|
188
|
+
-d '{"username":"admin","password":"nodefony-dev-42"}' \
|
|
189
189
|
http://localhost:5151/nodefony/security/api/auth/login | head -1
|
|
190
190
|
# HTTP/1.1 200 OK
|
|
191
191
|
|
|
@@ -349,7 +349,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
349
349
|
|
|
350
350
|
> [!NOTE]
|
|
351
351
|
> **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
|
|
352
|
-
> frames lisent un cache O(1)) → une révocation prend effet
|
|
352
|
+
> frames lisent un cache O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque `api.request`), pas à la frame
|
|
353
353
|
> suivante. C'est l'état de l'art (Socket.IO/Phoenix figent aussi au handshake) ; la révocation
|
|
354
354
|
> immédiate forte passe par le JWT + un canal « token révoqué ».
|
|
355
355
|
|
package/docs/authorization.md
CHANGED
|
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
331
331
|
|
|
332
332
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
333
333
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
334
|
-
(`Resolver.ts:
|
|
334
|
+
(`Resolver.ts:878-885`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
335
335
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
336
336
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
337
337
|
|
|
@@ -364,16 +364,16 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
|
|
|
364
364
|
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
|
|
365
365
|
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
|
|
366
366
|
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
|
|
367
|
-
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:
|
|
367
|
+
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:923`) |
|
|
368
368
|
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
|
|
369
369
|
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
|
|
370
370
|
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
|
|
371
371
|
|
|
372
372
|
La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
|
|
373
|
-
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:
|
|
373
|
+
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:489-491`). Le
|
|
374
374
|
même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
|
|
375
375
|
transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
|
|
376
|
-
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:
|
|
376
|
+
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:853`).
|
|
377
377
|
|
|
378
378
|
> [!IMPORTANT]
|
|
379
379
|
> **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
|
|
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
511
511
|
|
|
512
512
|
## 🔌 HTTP et WebSocket — une garde, N transports
|
|
513
513
|
|
|
514
|
-
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:
|
|
514
|
+
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:446`) est le point unique
|
|
515
515
|
d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
|
|
516
516
|
`@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
|
|
517
517
|
`ws-isgranted-jwt`).
|
|
@@ -541,7 +541,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
541
541
|
## ⚡ Performance & mémoire
|
|
542
542
|
|
|
543
543
|
- **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
|
|
544
|
-
alloc (`Resolver.ts:
|
|
544
|
+
alloc (`Resolver.ts:486`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
545
545
|
requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
|
|
546
546
|
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
|
|
547
547
|
instanciés **une seule fois** au boot (`authorization.ts:70`).
|
package/docs/cors.md
CHANGED
|
@@ -134,7 +134,7 @@ chaque appel du front.
|
|
|
134
134
|
### 1. La config — dans `nodefony.config.ts`
|
|
135
135
|
|
|
136
136
|
```typescript
|
|
137
|
-
// nodefony
|
|
137
|
+
// nodefony/config/security.ts (extrait généré par `nodefony create app`, puis complété)
|
|
138
138
|
use("@nodefony/security", {
|
|
139
139
|
cors: {
|
|
140
140
|
// Allowlist EXACTE : scheme + host + port. Un sous-domaine n'est PAS inclus.
|
|
@@ -323,7 +323,7 @@ sequenceDiagram
|
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
`Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
-
(`http-kernel.ts:
|
|
326
|
+
(`http-kernel.ts:1456`), à la ligne `http-kernel.ts:1456` — **avant le routing**. La raison est
|
|
327
327
|
concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
|
|
328
328
|
router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
|
|
329
329
|
credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
|
|
@@ -354,7 +354,7 @@ Deux nuances utiles :
|
|
|
354
354
|
`Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
|
|
355
355
|
point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
|
|
356
356
|
- **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
|
|
357
|
-
(`http-kernel.ts:
|
|
357
|
+
(`http-kernel.ts:1630`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
|
|
358
358
|
tes routes.
|
|
359
359
|
|
|
360
360
|
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
|
package/docs/csrf.md
CHANGED
|
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
93
93
|
- **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
|
|
94
94
|
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:163-168`).
|
|
95
95
|
- **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
|
|
96
|
-
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:
|
|
96
|
+
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1747`) arrive **après** le
|
|
97
97
|
resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
|
|
98
98
|
attaquant ne coûte ni lecture de session ni authentification).
|
|
99
99
|
- **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
|
|
@@ -315,7 +315,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
315
315
|
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
|
|
316
316
|
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
|
|
317
317
|
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
|
|
318
|
-
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:
|
|
318
|
+
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:542`). |
|
|
319
319
|
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
|
|
320
320
|
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
|
|
321
321
|
|
|
@@ -339,7 +339,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
339
339
|
- **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
|
|
340
340
|
allouée « au cas où » (`firewall.ts:164`).
|
|
341
341
|
- Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
|
|
342
|
-
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:
|
|
342
|
+
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:205`).
|
|
343
343
|
|
|
344
344
|
## 📡 Observabilité — Studio
|
|
345
345
|
|
package/docs/external-jwt.md
CHANGED
|
@@ -123,11 +123,12 @@ concernent d'après la liste d'émetteurs, puis délègue la vérification au se
|
|
|
123
123
|
La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
|
|
124
124
|
|
|
125
125
|
```ts
|
|
126
|
-
|
|
126
|
+
areas: {
|
|
127
127
|
api: {
|
|
128
128
|
pattern: "^/api",
|
|
129
129
|
stateless: true,
|
|
130
130
|
authenticators: ["external-jwt"],
|
|
131
|
+
resource: "https://api.example.com",
|
|
131
132
|
},
|
|
132
133
|
}
|
|
133
134
|
```
|
package/docs/firewall.md
CHANGED
|
@@ -73,11 +73,11 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
|
|
|
73
73
|
|
|
74
74
|
### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
|
|
75
75
|
|
|
76
|
-
Le scaffold déclare
|
|
76
|
+
Le scaffold déclare trois zones (`main`, `secure`, `machine`) dans `nodefony/config/security.ts`, importé par `nodefony.config.ts` ; en voici deux — c'est la forme canonique (un **objet par
|
|
77
77
|
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
|
|
78
78
|
|
|
79
79
|
```typescript
|
|
80
|
-
// nodefony
|
|
80
|
+
// nodefony/config/security.ts (extrait généré par `nodefony create app`)
|
|
81
81
|
use("@nodefony/security", {
|
|
82
82
|
areas: {
|
|
83
83
|
// Zone de TES routes : `session` PUIS `anonymous` → identifié si cookie,
|
|
@@ -151,7 +151,7 @@ curl -si http://localhost:5151/api/secure/account/me | head -1
|
|
|
151
151
|
|
|
152
152
|
# 2) Login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
|
|
153
153
|
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
154
|
-
-d '{"username":"admin","password":"
|
|
154
|
+
-d '{"username":"admin","password":"nodefony-dev-42"}' \
|
|
155
155
|
http://localhost:5151/nodefony/security/api/auth/login | head -1
|
|
156
156
|
# HTTP/1.1 200 OK
|
|
157
157
|
|
|
@@ -463,12 +463,12 @@ inchangé : la zone n'exige qu'une identité.
|
|
|
463
463
|
C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
|
|
464
464
|
explicite du code — jamais un contournement :
|
|
465
465
|
|
|
466
|
-
| Ce qui décide à la place
|
|
467
|
-
|
|
|
468
|
-
| Une **garde
|
|
469
|
-
| Une route **`
|
|
470
|
-
| Un **`bypassFirewall`**
|
|
471
|
-
| L'**ouverture d'une connexion WebSocket**
|
|
466
|
+
| Ce qui décide à la place | Pourquoi |
|
|
467
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
468
|
+
| Une **garde de rôle** (`@IsGranted`) | L'action a déjà dit quel rôle elle exige (un `@RequireScope` seul ne dispense pas du rôle de zone). Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
|
|
469
|
+
| Une route **`areaRoleExempt`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
|
|
470
|
+
| Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
|
|
471
|
+
| L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
|
|
472
472
|
|
|
473
473
|
Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
|
|
474
474
|
une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
|
|
@@ -477,7 +477,7 @@ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rô
|
|
|
477
477
|
puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
|
|
478
478
|
destinataire.
|
|
479
479
|
|
|
480
|
-
> ⚠️ **`
|
|
480
|
+
> ⚠️ **`areaRoleExempt` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
|
|
481
481
|
> qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
|
|
482
482
|
> que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
|
|
483
483
|
> Dans le doute, une garde d'action explicite est toujours préférable.
|
|
@@ -505,7 +505,7 @@ registerVoterFactory(
|
|
|
505
505
|
// l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.
|
|
506
506
|
```
|
|
507
507
|
|
|
508
|
-
Le voter est **découvert automatiquement** par
|
|
508
|
+
Le voter est **découvert automatiquement** par le service `authorization` — aucun changement dans le
|
|
509
509
|
cœur (`registerVoterFactory()`, `voterRegistry.ts:40`). Pourquoi un registre et pas un scan DI des
|
|
510
510
|
`@injectable` : les interfaces TS sont **effacées à la compilation** — rien à scanner ; le registre
|
|
511
511
|
**est** le marqueur explicite (TSDoc du registre, `voterRegistry.ts:6-16`). Trois axes (rôles,
|
|
@@ -553,16 +553,16 @@ ne paie quasiment rien.
|
|
|
553
553
|
|
|
554
554
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
555
555
|
|
|
556
|
-
| Symptôme | Cause (dans le code) | Correction
|
|
557
|
-
| ---------------------------------------- | ------------------------------------------------------- |
|
|
558
|
-
| Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }`
|
|
559
|
-
| Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator
|
|
560
|
-
| 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu
|
|
561
|
-
| Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]`
|
|
562
|
-
| API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`)
|
|
563
|
-
| JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore`
|
|
564
|
-
| WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet
|
|
565
|
-
| 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque
|
|
556
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
557
|
+
| ---------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
558
|
+
| Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
|
|
559
|
+
| Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
|
|
560
|
+
| 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
|
|
561
|
+
| Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
|
|
562
|
+
| API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
|
|
563
|
+
| JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
|
|
564
|
+
| WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet en une fenêtre (tick du hub + avant chaque `api.request`, fermeture 4001) |
|
|
565
|
+
| 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
|
|
566
566
|
|
|
567
567
|
## 📡 Observabilité — Studio
|
|
568
568
|
|
package/docs/headers.md
CHANGED
|
@@ -122,10 +122,10 @@ fait l'inverse : **tout ce qui est constant est calculé une fois au démarrage*
|
|
|
122
122
|
statique) et la **gèle** avec `Object.freeze` (`securityHeaders.ts:77`). Par requête, le firewall
|
|
123
123
|
se contente de la parcourir et de la poser : zéro concaténation, zéro objet créé.
|
|
124
124
|
- Côté transport, même principe : `HttpKernel.computeSecurityHeaderCaches()`
|
|
125
|
-
(`http-kernel.ts:
|
|
126
|
-
boot ; `onHttpRequest` (`http-kernel.ts:
|
|
125
|
+
(`http-kernel.ts:358`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
|
|
126
|
+
boot ; `onHttpRequest` (`http-kernel.ts:1009`) ne fait plus que trois `setHeader`.
|
|
127
127
|
- Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
|
|
128
|
-
(`Context.ts:253`) ne
|
|
128
|
+
(`Context.ts:253`) ne tire ses 128 bits (16 octets CSPRNG, en base64) d'un pool amorti (`randomFillSync`, 4 Ko pour 256 nonces) qu'à la première lecture,
|
|
129
129
|
puis mémoïse. Une réponse qui n'a aucun script inline à signer ne paie aucun appel crypto.
|
|
130
130
|
|
|
131
131
|
Le second parti pris est la **séparation d'autorité** décrite plus haut : un seul émetteur par
|
|
@@ -275,7 +275,7 @@ privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les ancie
|
|
|
275
275
|
victime est capté par ton interface.
|
|
276
276
|
|
|
277
277
|
Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions`
|
|
278
|
-
(`http-kernel.ts:
|
|
278
|
+
(`http-kernel.ts:1049`) — et configuré côté `@nodefony/http` avec `frameOptions`
|
|
279
279
|
(`http/nodefony/config/config.ts:136`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
|
|
280
280
|
s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
|
|
281
281
|
pour un HTML statique servi directement depuis `public/`.
|
|
@@ -287,7 +287,7 @@ pour un HTML statique servi directement depuis `public/`.
|
|
|
287
287
|
avec tes cookies.
|
|
288
288
|
|
|
289
289
|
Valeur unique reconnue : `nosniff`, posée depuis le cache `secContentTypeOptions`
|
|
290
|
-
(`http-kernel.ts:
|
|
290
|
+
(`http-kernel.ts:1046`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
|
|
291
291
|
vient précisément des fichiers servis hors pipeline applicatif — un banc live le prouve sur une 404
|
|
292
292
|
(`security-headers.test.ts:38`).
|
|
293
293
|
|
|
@@ -297,10 +297,10 @@ vient précisément des fichiers servis hors pipeline applicatif — un banc liv
|
|
|
297
297
|
installer un intercepteur.
|
|
298
298
|
|
|
299
299
|
La chaîne est assemblée au boot par `HttpKernel.computeSecurityHeaderCaches()`
|
|
300
|
-
(`http-kernel.ts:
|
|
300
|
+
(`http-kernel.ts:358`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
|
|
301
301
|
|
|
302
302
|
Elle n'est posée que **sur une réponse HTTPS ou HTTP/2** — le cache `secHsts` est conditionné au type
|
|
303
|
-
de serveur (`http-kernel.ts:
|
|
303
|
+
de serveur (`http-kernel.ts:1052`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
|
|
304
304
|
soit ignoré : l'émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines
|
|
305
305
|
inclus.
|
|
306
306
|
|
|
@@ -397,7 +397,7 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:206`).
|
|
|
397
397
|
### Socle transport — `use("@nodefony/http", { securityHeaders })`
|
|
398
398
|
|
|
399
399
|
Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:123`). Ces trois réglages sont
|
|
400
|
-
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:
|
|
400
|
+
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:408`)
|
|
401
401
|
recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
|
|
402
402
|
|
|
403
403
|
| Option | Type | Défaut | Effet |
|
|
@@ -446,13 +446,13 @@ Le chemin complet, sans surprise :
|
|
|
446
446
|
`<script nonce="…">` portent forcément la même valeur. C'est le motif employé par le contrôleur
|
|
447
447
|
de Studio (`StudioController.ts:62`).
|
|
448
448
|
|
|
449
|
-
`SecurityHeaders.hasNonce` (`securityHeaders.ts:
|
|
449
|
+
`SecurityHeaders.hasNonce` (`securityHeaders.ts:100`) est le drapeau qui décide du régime : à `false`,
|
|
450
450
|
pas une seule opération crypto. Il n'y a **aucun setter** pour `cspNonce` : un jeton serveur doit
|
|
451
451
|
rester imprévisible, jamais pilotable par le client — contrairement au `requestId`, qui, lui, accepte
|
|
452
452
|
une corrélation entrante.
|
|
453
453
|
|
|
454
454
|
**Placement dans le pipeline** : `applySecurityHeaders` est appelé **après le resolve** et **avant**
|
|
455
|
-
le repli statique et le `writeHead` (`http-kernel.ts:
|
|
455
|
+
le repli statique et le `writeHead` (`http-kernel.ts:1624`). Cet ordre n'est pas cosmétique : il
|
|
456
456
|
faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
|
|
457
457
|
être avant l'écriture des en-têtes pour pouvoir en poser.
|
|
458
458
|
|
|
@@ -478,7 +478,7 @@ les tests fiables.
|
|
|
478
478
|
**Coût** : le merge d'un module est payé **une fois**, au (dés)enregistrement
|
|
479
479
|
(`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1101`), jamais par requête. Le merge d'une route
|
|
480
480
|
`@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
|
|
481
|
-
`securityHeaders.ts:
|
|
481
|
+
`securityHeaders.ts:131`) ; le cas courant reste le simple `join`.
|
|
482
482
|
|
|
483
483
|
## 🧩 Extension — déclarer un fragment CSP depuis son module
|
|
484
484
|
|
|
@@ -523,8 +523,8 @@ en dev soit plus large qu'en production, où ce fragment n'existe pas.
|
|
|
523
523
|
| Champ structuré booléen | RFC 8941 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
|
|
524
524
|
| Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:267`) |
|
|
525
525
|
| Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
|
|
526
|
-
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:
|
|
527
|
-
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:
|
|
526
|
+
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:1046`) |
|
|
527
|
+
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:358`) |
|
|
528
528
|
|
|
529
529
|
## ⚡ Performance & mémoire
|
|
530
530
|
|
|
@@ -533,13 +533,13 @@ Le coût est concentré au boot, par construction :
|
|
|
533
533
|
- **En-têtes constants** : une seule table, gelée (`securityHeaders.ts:77`). Par requête, une boucle
|
|
534
534
|
`for…in` sur un objet de 1 à 6 entrées et autant de `setHeader`. Aucune allocation.
|
|
535
535
|
- **CSP statique** : rien de plus — la chaîne est dans la table.
|
|
536
|
-
- **CSP à nonce** :
|
|
537
|
-
n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:
|
|
536
|
+
- **CSP à nonce** : un tirage dans le pool plus une concaténation `préfixe + nonce + suffixe` par requête. C'est le seul coût variable, et il
|
|
537
|
+
n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:100`)
|
|
538
538
|
court-circuite entièrement ce chemin sinon. La paresse de `Context.cspNonce` (`Context.ts:253`)
|
|
539
539
|
protège en plus les chemins internes qui n'atteignent jamais le firewall.
|
|
540
540
|
- **Merge CSP** : jamais dans le chemin chaud. Le fragment d'un module est fusionné à
|
|
541
541
|
l'enregistrement (`firewall.ts:1086`) ; celui d'une route ne coûte que sur les routes `@Csp`.
|
|
542
|
-
- **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:
|
|
542
|
+
- **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:1047-1053`), avec
|
|
543
543
|
un test `!== null` qui annule le coût des en-têtes désactivés.
|
|
544
544
|
|
|
545
545
|
Le module n'attache aucun écouteur d'événement et ne conserve aucun état par requête : il n'entre pas
|
package/docs/index.md
CHANGED
|
@@ -43,7 +43,7 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
|
|
|
43
43
|
**Je découvre la sécurité Nodefony** — comprendre le modèle avant de configurer quoi que ce soit.
|
|
44
44
|
|
|
45
45
|
1. [Firewall](firewall.md) — zones, Zero Trust, la chaîne de décision. **Tout part d'ici.**
|
|
46
|
-
2. [Authenticators](authenticators.md) — les
|
|
46
|
+
2. [Authenticators](authenticators.md) — les sept façons de prouver **qui** appelle.
|
|
47
47
|
3. [Autorisation](authorization.md) — rôles, scopes, voters : **ce qu'il a le droit** de faire.
|
|
48
48
|
4. [Jetons](tokens.md) — ce qui matérialise une identité prouvée, et comment on la révoque.
|
|
49
49
|
|
|
@@ -98,7 +98,7 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
|
|
|
98
98
|
"desc": "Le pare-feu applicatif : trois questions pour chaque requête — est-ce une zone protégée ? qui es-tu ? as-tu le droit ? Le chemin chaud (détecter) est séparé du chemin froid (décider), donc une route publique ne paie rien.",
|
|
99
99
|
"meta": "à lire en premier — toutes les autres briques s'y branchent" },
|
|
100
100
|
{ "icon": "🪪", "title": "authenticators", "href": "authenticators.md",
|
|
101
|
-
"desc": "Prouver qui appelle :
|
|
101
|
+
"desc": "Prouver qui appelle : sept stratégies au même contrat — session, userpassword, jwt, external-jwt, apikey, anonymous, firewall-realtime — qui se composent dans l'ordre au sein d'une zone.",
|
|
102
102
|
"meta": "commence par « Ordre et modes » : les pièges de configuration sont là" },
|
|
103
103
|
{ "icon": "⚖️", "title": "authorization", "href": "authorization.md",
|
|
104
104
|
"desc": "Décider des droits : rôles hiérarchisés, scopes, et voters métier qui portent le vrai pouvoir applicatif. Le jury vote, la stratégie tranche.",
|
|
@@ -167,7 +167,7 @@ un mot de passe parce qu'il est perdu ou compromis.
|
|
|
167
167
|
## ⚙️ Configuration
|
|
168
168
|
|
|
169
169
|
Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod
|
|
170
|
-
au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `
|
|
170
|
+
au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `rateLimit`
|
|
171
171
|
(backoff NIST), puis un bloc par brique (`jwt`, `apiKeys`, `totp`, `webhooks`, `audit`, `passkeys`,
|
|
172
172
|
`oauth2`). **Chaque page de brique détaille son bloc**, avec une table dérivée du schéma.
|
|
173
173
|
|
package/docs/lexique.md
CHANGED
|
@@ -126,21 +126,21 @@ source: "src/packages/@nodefony/security/docs/lexique.md"
|
|
|
126
126
|
> L'**autorisation** (authz) répond « as-tu le DROIT de faire ça ? » (les voters, `@IsGranted`).
|
|
127
127
|
> On peut être authentifié ET refusé (un `user` connecté sur une route `ROLE_ADMIN` → **403**, pas 401).
|
|
128
128
|
|
|
129
|
-
| Sigle / terme
|
|
130
|
-
|
|
|
131
|
-
| **RBAC**
|
|
132
|
-
| **ABAC**
|
|
133
|
-
| **Voter**
|
|
134
|
-
| **GRANT / DENY / ABSTAIN**
|
|
135
|
-
| **`
|
|
136
|
-
| **Stratégie affirmative**
|
|
137
|
-
| **Veto (DENY)**
|
|
138
|
-
| **Default DENY**
|
|
139
|
-
| **Fail-closed (voter)**
|
|
140
|
-
| **`voterRegistry`**
|
|
141
|
-
| **`RoleVoter`**
|
|
142
|
-
| **Attribute**
|
|
143
|
-
| **Subject**
|
|
129
|
+
| Sigle / terme | Développé | En clair |
|
|
130
|
+
| ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
131
|
+
| **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
|
|
132
|
+
| **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
|
|
133
|
+
| **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
|
|
134
|
+
| **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
|
|
135
|
+
| **`Authorization.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
|
|
136
|
+
| **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
|
|
137
|
+
| **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
|
|
138
|
+
| **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
|
|
139
|
+
| **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
|
|
140
|
+
| **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
|
|
141
|
+
| **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
|
|
142
|
+
| **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
|
|
143
|
+
| **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
|
|
144
144
|
|
|
145
145
|
### Décorateurs sécurité (panoplie J7)
|
|
146
146
|
|
package/docs/oauth2.md
CHANGED
|
@@ -144,12 +144,13 @@ d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewal
|
|
|
144
144
|
c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
|
|
145
145
|
|
|
146
146
|
**Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
|
|
147
|
-
dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
|
|
147
|
+
dans le module (`OAuth2Client`, `oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
|
|
148
148
|
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
|
|
149
149
|
mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
|
|
150
|
-
processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes
|
|
151
|
-
que
|
|
152
|
-
|
|
150
|
+
processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes sont montées dès
|
|
151
|
+
que `@nodefony/security` est chargé (`framework/index.ts:460`) : sans social login configuré,
|
|
152
|
+
`…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
|
|
153
|
+
rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
|
|
153
154
|
|
|
154
155
|
Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
|
|
155
156
|
inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
|
|
@@ -215,8 +216,8 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
215
216
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
216
217
|
|
|
217
218
|
`mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
|
|
218
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`)
|
|
219
|
-
est
|
|
219
|
+
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`) dès que le service `oauth2` est
|
|
220
|
+
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
|
|
220
221
|
|
|
221
222
|
| Route | Rôle |
|
|
222
223
|
| ---------------------------- | --------------------------------------------------------------------- |
|
|
@@ -488,14 +489,14 @@ que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
|
488
489
|
Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
|
|
489
490
|
(`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
|
|
490
491
|
|
|
491
|
-
| Option | Type | Défaut | Effet
|
|
492
|
-
| ----------------- | -------------------- | --------------- |
|
|
493
|
-
| `enabled` | booléen | `true` | Coupe le social login
|
|
494
|
-
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`).
|
|
495
|
-
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`).
|
|
496
|
-
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès.
|
|
497
|
-
| `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail).
|
|
498
|
-
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`).
|
|
492
|
+
| Option | Type | Défaut | Effet |
|
|
493
|
+
| ----------------- | -------------------- | --------------- | ----------------------------------------------------------- |
|
|
494
|
+
| `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
|
|
495
|
+
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
|
|
496
|
+
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
|
|
497
|
+
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
|
|
498
|
+
| `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
|
|
499
|
+
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
|
|
499
500
|
|
|
500
501
|
Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
|
|
501
502
|
|
|
@@ -608,15 +609,17 @@ session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l
|
|
|
608
609
|
provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
609
610
|
|
|
610
611
|
> [!NOTE]
|
|
611
|
-
> L'événement d'audit du login social porte la raison
|
|
612
|
-
> (`
|
|
613
|
-
>
|
|
612
|
+
> L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
|
|
613
|
+
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:194`), comme WebAuthn passe `webauthn`.
|
|
614
|
+
> `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
|
|
615
|
+
> (`authFlow.ts:215`).
|
|
614
616
|
|
|
615
617
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
616
618
|
|
|
617
619
|
| Symptôme | Cause (dans le code) | Correction |
|
|
618
620
|
| ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
619
|
-
| `404` sur `…/oauth2/…` |
|
|
621
|
+
| `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
|
|
622
|
+
| `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
|
|
620
623
|
| WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
|
|
621
624
|
| `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:14`) | Vérifier le nom exact **et** la présence des secrets |
|
|
622
625
|
| Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
|
package/docs/tokens.md
CHANGED
|
@@ -25,7 +25,7 @@ tags:
|
|
|
25
25
|
]
|
|
26
26
|
version: "doc"
|
|
27
27
|
status: stable
|
|
28
|
-
updated: 2026-
|
|
28
|
+
updated: 2026-10-02
|
|
29
29
|
source: "src/packages/@nodefony/security/docs/tokens.md"
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -142,8 +142,9 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
|
|
|
142
142
|
- `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
|
|
143
143
|
|
|
144
144
|
> [!IMPORTANT]
|
|
145
|
-
> Ces routes n'existent **que si**
|
|
146
|
-
> (`
|
|
145
|
+
> Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
|
|
146
|
+
> (`framework/index.ts:415`). Module chargé mais JWT désactivé : elles existent et répondent
|
|
147
|
+
> `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
|
|
147
148
|
> elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
|
|
148
149
|
> authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
|
|
149
150
|
|
|
@@ -479,7 +480,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
|
|
|
479
480
|
|
|
480
481
|
| Symptôme | Cause (dans le code) | Correction |
|
|
481
482
|
| --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
|
|
482
|
-
| 404 sur `/nodefony/security/api/token` |
|
|
483
|
+
| 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
|
|
483
484
|
| 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
|
|
484
485
|
| Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
|
|
485
486
|
| JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | Provisionner `keySetJson` hors-bande (même clé partout) |
|
package/docs/totp.md
CHANGED
|
@@ -190,8 +190,9 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
190
190
|
|
|
191
191
|
### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
|
|
192
192
|
|
|
193
|
-
`mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service
|
|
194
|
-
le service `totp`
|
|
193
|
+
`mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service dès que
|
|
194
|
+
`@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
|
|
195
|
+
service non initialisé : elles répondent `503 2FA unavailable` :
|
|
195
196
|
|
|
196
197
|
| Route | Corps | Réponse |
|
|
197
198
|
| ------------------------------------------ | ---------- | ---------------------------------------------- |
|
|
@@ -211,7 +212,7 @@ Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
|
|
|
211
212
|
> [!WARNING]
|
|
212
213
|
> Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
|
|
213
214
|
> zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
|
|
214
|
-
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:
|
|
215
|
+
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:142`) — jamais un
|
|
215
216
|
> paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
|
|
216
217
|
|
|
217
218
|
### 4. Ce qu'on observe
|
|
@@ -455,17 +456,17 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
|
|
|
455
456
|
La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
|
|
456
457
|
échoue **au démarrage**, pas au premier login :
|
|
457
458
|
|
|
458
|
-
| Option | Type | Défaut | Effet
|
|
459
|
-
| --------------- | ---------------------------- | ------ |
|
|
460
|
-
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes
|
|
461
|
-
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app.
|
|
462
|
-
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`).
|
|
463
|
-
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum).
|
|
464
|
-
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes.
|
|
465
|
-
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`).
|
|
466
|
-
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`).
|
|
467
|
-
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`).
|
|
468
|
-
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`).
|
|
459
|
+
| Option | Type | Défaut | Effet |
|
|
460
|
+
| --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
|
|
461
|
+
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
|
|
462
|
+
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
|
|
463
|
+
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
|
|
464
|
+
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
|
|
465
|
+
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
|
|
466
|
+
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
|
|
467
|
+
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
|
|
468
|
+
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
|
|
469
|
+
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
|
|
469
470
|
|
|
470
471
|
### Situation 1 — un utilisateur active la 2FA sur son compte
|
|
471
472
|
|
package/docs/webauthn.md
CHANGED
|
@@ -217,7 +217,7 @@ export async function enrollPasskey(): Promise<string> {
|
|
|
217
217
|
return out.credentialId;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/** Se connecter sans mot de passe
|
|
220
|
+
/** Se connecter sans mot de passe : un anonyme reçoit toujours un défi découvrable. */
|
|
221
221
|
export async function loginWithPasskey(
|
|
222
222
|
username?: string,
|
|
223
223
|
): Promise<{ username: string }> {
|
|
@@ -404,15 +404,16 @@ sans champ à remplir.
|
|
|
404
404
|
passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
-
|
|
408
|
-
le navigateur propose les comptes qu'il connaît.
|
|
409
|
-
|
|
407
|
+
Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
|
|
408
|
+
`allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
|
|
409
|
+
jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
|
|
410
|
+
`WebAuthnController.ts:171`).
|
|
410
411
|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
413
|
-
| `{}`
|
|
414
|
-
| `{"username":"alice"}`
|
|
415
|
-
|
|
|
412
|
+
| Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
|
|
413
|
+
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
|
|
414
|
+
| anonyme, `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
|
|
415
|
+
| anonyme, `{"username":"alice"}` | identique : le `username` est ignoré | aucun ciblage, rien à apprendre sur `alice` |
|
|
416
|
+
| session authentifiée (ré-auth, step-up) | défi + `allowCredentials` des clés **de ce porteur** | ciblage de ses propres passkeys |
|
|
416
417
|
|
|
417
418
|
## 🛡️ `rpId` et origines — l'ancre anti-phishing
|
|
418
419
|
|
|
@@ -439,14 +440,16 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
|
|
|
439
440
|
|
|
440
441
|
## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
|
|
441
442
|
|
|
442
|
-
`login/options` est **accessible à un anonyme** (`bypassFirewall`)
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
`login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
|
|
444
|
+
pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
|
|
445
|
+
reçoit toujours un défi découvrable, et seule une session authentifiée cible ses propres passkeys
|
|
446
|
+
(`WebAuthnController.loginOptions()`, `WebAuthnController.ts:171`). Deux surfaces restent à
|
|
447
|
+
regarder en face.
|
|
445
448
|
|
|
446
449
|
### Amplification : bornée par le plafond, pas par une pagination
|
|
447
450
|
|
|
448
|
-
|
|
449
|
-
`allowCredentials` (`webAuthn.ts:
|
|
451
|
+
Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
452
|
+
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:389`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
450
453
|
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
451
454
|
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
|
|
452
455
|
|
|
@@ -460,22 +463,17 @@ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `c
|
|
|
460
463
|
(3 comptages, 0 chargement) ;
|
|
461
464
|
- retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
|
|
462
465
|
|
|
463
|
-
### Énumération :
|
|
466
|
+
### Énumération : rien ne distingue deux comptes
|
|
464
467
|
|
|
465
|
-
Un
|
|
466
|
-
|
|
467
|
-
|
|
468
|
+
Un anonyme reçoit **toujours le même défi découvrable**, sans `allowCredentials`, qu'il poste ou non
|
|
469
|
+
un `username` : aucun statut, aucune liste, aucune latence ne dit si un compte existe ou porte une
|
|
470
|
+
passkey, et l'appel ne touche même pas le store. C'est le remède que la spécification W3C WebAuthn
|
|
471
|
+
propose contre la fuite des identifiants de credentials ; `webauthnLoginOptionsPrivacy.test.ts` le
|
|
472
|
+
verrouille.
|
|
468
473
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
> [!WARNING]
|
|
474
|
-
> C'est le comportement standard d'un `login/options` avec identifiant, et la parade est
|
|
475
|
-
> architecturale : **ne transmets pas `username`**. Avec `residentKey: "required"` et un client qui
|
|
476
|
-
> poste `{}`, `allowCredentials` est omis, aucun état de compte ne transparaît, et l'appel ne touche
|
|
477
|
-
> même pas le store. Si ton UX exige la saisie d'un identifiant, place un rate-limit devant
|
|
478
|
-
> `login/options` — le firewall ne le protège pas, c'est une route en `bypassFirewall`.
|
|
474
|
+
> [!NOTE]
|
|
475
|
+
> `login/options` reste une route en `bypassFirewall` : le firewall ne la protège pas. Un rate-limit
|
|
476
|
+
> en amont reste de bonne hygiène, pour borner le coût des défis émis.
|
|
479
477
|
|
|
480
478
|
Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
481
479
|
|
|
@@ -680,19 +678,18 @@ requête. Le code en tire trois conséquences.
|
|
|
680
678
|
|
|
681
679
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
682
680
|
|
|
683
|
-
| Symptôme | Cause (dans le code) | Correction
|
|
684
|
-
| ---------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
685
|
-
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser`
|
|
686
|
-
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`)
|
|
687
|
-
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable
|
|
688
|
-
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>`
|
|
689
|
-
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`)
|
|
690
|
-
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil
|
|
691
|
-
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait
|
|
692
|
-
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré
|
|
693
|
-
|
|
|
694
|
-
|
|
|
695
|
-
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
681
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
682
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
683
|
+
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
|
|
684
|
+
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`) |
|
|
685
|
+
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
|
|
686
|
+
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>` |
|
|
687
|
+
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`) |
|
|
688
|
+
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
|
|
689
|
+
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait |
|
|
690
|
+
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
|
|
691
|
+
| `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1` | Paginer par `nextCursor`, ne pas afficher de total |
|
|
692
|
+
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
696
693
|
|
|
697
694
|
## 🧪 Tests & couverture
|
|
698
695
|
|
package/docs/webhooks.md
CHANGED
|
@@ -251,7 +251,7 @@ import { Buffer } from "node:buffer";
|
|
|
251
251
|
// prettier-ignore
|
|
252
252
|
import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
|
|
253
253
|
|
|
254
|
-
const SECRET = (process.env.
|
|
254
|
+
const SECRET = (process.env.NF_HOOK_SECRET ?? "").replace(/^whsec_/, "");
|
|
255
255
|
|
|
256
256
|
@controller("/hooks")
|
|
257
257
|
class HookMiniController extends Controller {
|
|
@@ -308,7 +308,7 @@ import { Buffer } from "node:buffer";
|
|
|
308
308
|
import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
|
|
309
309
|
|
|
310
310
|
/** Secret `whsec_…` donné par l'émetteur — via l'environnement, jamais en dur. */
|
|
311
|
-
const SECRET = process.env.
|
|
311
|
+
const SECRET = process.env.NF_HOOK_SECRET ?? "";
|
|
312
312
|
/** Fenêtre anti-rejeu (s) : un message plus vieux est refusé. */
|
|
313
313
|
const TOLERANCE_S = 300;
|
|
314
314
|
|
|
@@ -956,21 +956,21 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
|
|
|
956
956
|
|
|
957
957
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
958
958
|
|
|
959
|
-
| Symptôme
|
|
960
|
-
|
|
|
961
|
-
| Boot : « webhooks désactivés » en production
|
|
962
|
-
| Après redémarrage, les signatures ne valident plus
|
|
963
|
-
| `422` à la création d'un endpoint
|
|
964
|
-
| Rien n'arrive alors que l'endpoint est actif
|
|
965
|
-
| Un événement « métier » n'arrive jamais
|
|
966
|
-
| Signature invalide côté récepteur
|
|
967
|
-
| Le récepteur voit deux fois le même événement
|
|
968
|
-
| Endpoint passé `enabled: false` tout seul
|
|
969
|
-
| Livraisons « abandonnées » dans les logs
|
|
970
|
-
| Un `302` vers l'interne n'est pas suivi
|
|
971
|
-
| Le secret a été perdu
|
|
972
|
-
| Après un redémarrage, plus aucun endpoint
|
|
973
|
-
| Multi-pod : un endpoint créé ne livre
|
|
959
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
960
|
+
| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
961
|
+
| Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts:299`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
|
|
962
|
+
| 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 |
|
|
963
|
+
| `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` |
|
|
964
|
+
| Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts:191`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
|
|
965
|
+
| 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 |
|
|
966
|
+
| 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}` |
|
|
967
|
+
| 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 |
|
|
968
|
+
| Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts:565`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
|
|
969
|
+
| Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
|
|
970
|
+
| 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 |
|
|
971
|
+
| Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
|
|
972
|
+
| Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
|
|
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:406`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
|
|
974
974
|
|
|
975
975
|
## 🧪 Tests & couverture
|
|
976
976
|
|
|
@@ -983,7 +983,7 @@ depuis vitest, jamais figés ici :
|
|
|
983
983
|
historique, bornes de perf, arrêt) ; `webhookSignature` (vecteur officiel Standard Webhooks,
|
|
984
984
|
altération du corps/id/timestamp) ; `webhookDelivery` (pin d'IP, non-suivi des 3xx, timeout,
|
|
985
985
|
politique de protocole, capture du corps de réponse) ; `webhookStore` (CRUD mémoire + copie
|
|
986
|
-
défensive) ; `webhookAdminApi` (les
|
|
986
|
+
défensive) ; `webhookAdminApi` (les 9 endpoints, rôles, `400`/`404`/`422`/`503`, audit des
|
|
987
987
|
mutations) ; `webhookPagination` (harnais du banc de contrat sur le store mémoire).
|
|
988
988
|
- **attaque (red-team)** — `webhookSsrf.attack.test.ts` : matrice threat-first dérivée d'OWASP SSRF
|
|
989
989
|
et de CAPEC-664 (IPv4-mapped IPv6 sous 7 notations, confusion `userinfo`, IP encodée
|
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.2",
|
|
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",
|
|
@@ -54,22 +54,22 @@
|
|
|
54
54
|
"zod": "^4.6.5"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
58
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
59
|
-
"@nodefony/user": "^10.0.0-beta.
|
|
57
|
+
"@nodefony/framework": "^10.0.0-beta.2",
|
|
58
|
+
"@nodefony/http": "^10.0.0-beta.2",
|
|
59
|
+
"@nodefony/user": "^10.0.0-beta.2",
|
|
60
60
|
"@types/node": "26.6.3",
|
|
61
|
-
"@vitest/coverage-v8": "5.0.
|
|
62
|
-
"nodefony": "^10.0.0-beta.
|
|
63
|
-
"vitest": "5.0.
|
|
61
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
62
|
+
"nodefony": "^10.0.0-beta.2",
|
|
63
|
+
"vitest": "5.0.3"
|
|
64
64
|
},
|
|
65
65
|
"license": "Apache-2.0",
|
|
66
66
|
"readmeFilename": "README.md",
|
|
67
67
|
"contributors": [],
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@nodefony/framework": "^10.0.0-beta.
|
|
70
|
-
"@nodefony/http": "^10.0.0-beta.
|
|
71
|
-
"@nodefony/user": "^10.0.0-beta.
|
|
72
|
-
"nodefony": "^10.0.0-beta.
|
|
69
|
+
"@nodefony/framework": "^10.0.0-beta.2",
|
|
70
|
+
"@nodefony/http": "^10.0.0-beta.2",
|
|
71
|
+
"@nodefony/user": "^10.0.0-beta.2",
|
|
72
|
+
"nodefony": "^10.0.0-beta.2"
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
|
75
75
|
"dist",
|