@nodefony/security 10.0.0-alpha.8 → 10.0.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -2
- package/dist/nodefony/service/tokenService.js +3 -2
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +3 -2
- package/dist/nodefony/service/webhooks.js +5 -3
- package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +62 -60
- package/docs/audit.md +77 -77
- package/docs/authenticators.md +55 -43
- package/docs/authorization.md +13 -13
- package/docs/cors.md +29 -29
- package/docs/csrf.md +26 -26
- package/docs/external-jwt.md +4 -4
- package/docs/firewall.md +29 -29
- package/docs/headers.md +26 -26
- package/docs/oauth2.md +60 -60
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +55 -55
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +81 -81
- package/docs/webhooks.md +43 -37
- package/package.json +15 -12
package/docs/authorization.md
CHANGED
|
@@ -363,14 +363,14 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
|
|
|
363
363
|
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:878`) |
|
|
364
364
|
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:407-412`) |
|
|
365
365
|
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:426`) |
|
|
366
|
-
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:
|
|
366
|
+
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1514`) |
|
|
367
367
|
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:743-747`) |
|
|
368
368
|
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:975`) |
|
|
369
|
-
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:
|
|
370
|
-
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:
|
|
369
|
+
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:926`) |
|
|
370
|
+
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1250`) |
|
|
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:672-677`). 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
376
|
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:672-702`).
|
|
@@ -378,7 +378,7 @@ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:672-702`).
|
|
|
378
378
|
> [!IMPORTANT]
|
|
379
379
|
> **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
|
|
380
380
|
> chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
|
|
381
|
-
> (`Resolver.ts:
|
|
381
|
+
> (`Resolver.ts:678-688`). Une route gardée doit être couverte par une zone — voir
|
|
382
382
|
> [firewall](./firewall.md).
|
|
383
383
|
|
|
384
384
|
## 🧑⚖️ Les voters intégrés — deux axes, un même jury
|
|
@@ -399,7 +399,7 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
|
|
|
399
399
|
default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
|
|
400
400
|
(`@IsGranted(["A","B"])`) possible.
|
|
401
401
|
- La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
|
|
402
|
-
(`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:
|
|
402
|
+
(`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:209`).
|
|
403
403
|
- Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
|
|
404
404
|
|
|
405
405
|
#### Deux échelles de rôles — plateforme et organisation
|
|
@@ -511,14 +511,14 @@ 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:335`) 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`).
|
|
518
518
|
- **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
|
|
519
519
|
et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
|
|
520
520
|
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
|
|
521
|
-
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:
|
|
521
|
+
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:290`) délègue à `Firewall.hasRole()`
|
|
522
522
|
(`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
|
|
523
523
|
|
|
524
524
|
## 📜 Normes appliquées
|
|
@@ -529,7 +529,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
529
529
|
| Modèle de rôles | RBAC **hiérarchique**, au sens des composantes _Core_ et _Hierarchical_ d'ANSI INCITS 359 — sans séparation des devoirs | `RoleHierarchyWalker.reachableRoles()` |
|
|
530
530
|
| Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
|
|
531
531
|
| Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
|
|
532
|
-
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:
|
|
532
|
+
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:678-688`) |
|
|
533
533
|
| Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
|
|
534
534
|
|
|
535
535
|
> Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
|
|
@@ -541,10 +541,10 @@ 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:358`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
545
545
|
requêtes (`SecurityRequirement`, `routerDecorators.ts:424`).
|
|
546
|
-
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:
|
|
547
|
-
instanciés **une seule fois** au boot (`authorization.ts:
|
|
546
|
+
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
|
|
547
|
+
instanciés **une seule fois** au boot (`authorization.ts:51`).
|
|
548
548
|
- **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
|
|
549
549
|
(`RoleHierarchyWalker.ts:23-30`).
|
|
550
550
|
- **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
|
|
@@ -560,7 +560,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
560
560
|
| Symptôme | Cause (dans le code) | Correction |
|
|
561
561
|
| -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
562
562
|
| Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
|
|
563
|
-
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:
|
|
563
|
+
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:678-688`) | Couvrir la route par une zone firewall |
|
|
564
564
|
| Un voter métier bloque tout | Il renvoie `DENY` au lieu d'`ABSTAIN` quand il ne s'applique pas | Renvoyer `ABSTAIN` hors de son domaine |
|
|
565
565
|
| Un `DENY` n'a pas bloqué | Attributs d'une clause = jurys **séparés** (OR) — un autre attribut a accordé | Porter l'interdit en clause AND (empiler les `@IsGranted`) |
|
|
566
566
|
| Clé API accède à une action non prévue | Type de jeton traité comme humain (allowlist) | Vérifier que le type n'est pas dans `NON_SCOPABLE_TOKEN_TYPES` |
|
package/docs/cors.md
CHANGED
|
@@ -103,13 +103,13 @@ Trois conséquences que beaucoup de développeurs découvrent trop tard :
|
|
|
103
103
|
`Cors` (`cors.ts:33`) est une classe **pure et synchrone** : elle ne touche ni au réseau, ni à la
|
|
104
104
|
requête — elle prend une origine et renvoie la table des en-têtes à poser, ou `null`. Elle est
|
|
105
105
|
instanciée **une seule fois au boot** par le firewall, si et seulement si la section est activée
|
|
106
|
-
(`firewall.ts:
|
|
106
|
+
(`firewall.ts:239-240`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
|
|
107
107
|
requête se réduit à une lecture de `Set` (voir Performance).
|
|
108
108
|
|
|
109
109
|
Trois invariants de sécurité, tenus par construction :
|
|
110
110
|
|
|
111
111
|
- **Jamais `*` avec credentials.** La combinaison est **rejetée au boot** par un `refine` Zod
|
|
112
|
-
(`config.ts:
|
|
112
|
+
(`config.ts:156-159`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
|
|
113
113
|
instanciée à la main avec cette combinaison, `Cors.#allowOrigin()` **reflète l'origine** au lieu
|
|
114
114
|
d'émettre `*` (`cors.ts:58`).
|
|
115
115
|
- **Reflet d'origine ⇒ `Vary: Origin`.** Dès que la valeur `Allow-Origin` n'est pas `*`, la politique
|
|
@@ -204,8 +204,8 @@ curl -si http://localhost:5151/api/articles -H 'Origin: https://evil.com'
|
|
|
204
204
|
|
|
205
205
|
## ⚙️ Configuration et mises en situation
|
|
206
206
|
|
|
207
|
-
La section `cors` de la config du module (`corsSchema`, `config.ts:
|
|
208
|
-
`config.ts:
|
|
207
|
+
La section `cors` de la config du module (`corsSchema`, `config.ts:129` ; branchée à la racine en
|
|
208
|
+
`config.ts:1137`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
|
|
209
209
|
|
|
210
210
|
<!-- prettier-ignore -->
|
|
211
211
|
| Option | Type | Défaut | Effet |
|
|
@@ -242,7 +242,7 @@ cors: {
|
|
|
242
242
|
| `Origin: https://app.example.com:8443` | rien — le **port** fait partie de l'origine, match exact |
|
|
243
243
|
| `Origin: https://sub.app.example.com` | rien — un sous-domaine n'est **pas** couvert par le parent |
|
|
244
244
|
| `Origin: http://app.example.com` | rien — le **scheme** fait partie de l'origine (downgrade refusé) |
|
|
245
|
-
| aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:
|
|
245
|
+
| aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:1013`) |
|
|
246
246
|
|
|
247
247
|
### Situation 2 — une API publique en lecture seule (le joker `*`)
|
|
248
248
|
|
|
@@ -273,7 +273,7 @@ cors: { origins: ["https://app.example.com", "https://admin.example.com"], crede
|
|
|
273
273
|
|
|
274
274
|
> [!WARNING]
|
|
275
275
|
> `origins: ["*"]` **avec** `credentials: true` est rejeté par le schéma Zod au démarrage
|
|
276
|
-
> (`config.ts:
|
|
276
|
+
> (`config.ts:156-159`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
|
|
277
277
|
> combinaison, si un serveur la contournait en reflétant chaque origine, laisserait **tout site du
|
|
278
278
|
> web** lire les réponses authentifiées de tes utilisateurs. Ne « corrige » jamais cette erreur en
|
|
279
279
|
> reflétant l'origine reçue sans la valider.
|
|
@@ -322,23 +322,23 @@ sequenceDiagram
|
|
|
322
322
|
R-->>B: 200 + Access-Control-*
|
|
323
323
|
```
|
|
324
324
|
|
|
325
|
-
`Firewall.handleCors()` (`firewall.ts:
|
|
326
|
-
(`http-kernel.ts:
|
|
325
|
+
`Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
+
(`http-kernel.ts:1310`), à la ligne `http-kernel.ts:1361` — **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.
|
|
330
330
|
|
|
331
|
-
Quatre sorties en no-op, dans cet ordre (`firewall.ts:
|
|
331
|
+
Quatre sorties en no-op, dans cet ordre (`firewall.ts:1007`) :
|
|
332
332
|
|
|
333
333
|
1. CORS désactivé ⇒ `#cors` est `null`, retour immédiat ;
|
|
334
|
-
2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:
|
|
334
|
+
2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:1013`) ;
|
|
335
335
|
3. la réponse n'expose pas `setHeader` ⇒ c'est un **WebSocket**, il n'y a pas d'en-tête HTTP à poser
|
|
336
|
-
(`firewall.ts:
|
|
336
|
+
(`firewall.ts:83`) ;
|
|
337
337
|
4. origine hors allowlist ⇒ la table est `null`, aucun en-tête n'est posé — mais un preflight reste
|
|
338
|
-
court-circuité en 204 (`firewall.ts:
|
|
338
|
+
court-circuité en 204 (`firewall.ts:1032`).
|
|
339
339
|
|
|
340
340
|
**La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
|
|
341
|
-
`Access-Control-Request-Method` (`firewall.ts:
|
|
341
|
+
`Access-Control-Request-Method` (`firewall.ts:1018-1020`). Un `OPTIONS` nu — celui d'un client qui interroge
|
|
342
342
|
les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
|
|
343
343
|
|
|
344
344
|
### Ce que chaque moment pose
|
|
@@ -354,10 +354,10 @@ 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:1361`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
|
|
358
358
|
tes routes.
|
|
359
359
|
|
|
360
|
-
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:
|
|
360
|
+
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
|
|
361
361
|
signifie « je suis un preflight, réponds et arrête-toi ».
|
|
362
362
|
|
|
363
363
|
## 🛡️ CORS, CSRF et en-têtes de sécurité — qui protège quoi
|
|
@@ -371,12 +371,12 @@ Trois briques voisines, souvent confondues. Une seule ligne chacune :
|
|
|
371
371
|
| **[En-têtes](./headers.md)** (CSP, COOP) | ce que la **page** a le droit de faire | dans le navigateur | XSS, injection, fenêtres croisées |
|
|
372
372
|
|
|
373
373
|
Les deux premières se parlent. Au boot, la liste des origines de confiance CSRF est l'**union** de
|
|
374
|
-
`csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:
|
|
374
|
+
`csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:605`) : ce que tu autorises explicitement en
|
|
375
375
|
CORS ne peut pas être, au même instant, traité comme une tentative CSRF.
|
|
376
376
|
|
|
377
377
|
L'inverse n'est pas vrai, et c'est délibéré : `csrf.trustedOrigins` déclare un **alias de domaine**
|
|
378
378
|
légitime de ton app (une façade multi-domaine) sans pour autant exposer tes réponses au JS de cette
|
|
379
|
-
origine (`config.ts:
|
|
379
|
+
origine (`config.ts:188`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
|
|
380
380
|
`csrf.trustedOrigins`.
|
|
381
381
|
|
|
382
382
|
## 🔌 Et le WebSocket ?
|
|
@@ -387,7 +387,7 @@ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un con
|
|
|
387
387
|
(`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
|
|
388
388
|
|
|
389
389
|
La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
|
|
390
|
-
(`http-kernel.ts:
|
|
390
|
+
(`http-kernel.ts:604`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
|
|
391
391
|
si elle est refusée. Sa doctrine :
|
|
392
392
|
|
|
393
393
|
- **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
|
|
@@ -405,22 +405,22 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
|
|
|
405
405
|
| Sujet | Norme | Comment le code s'y conforme |
|
|
406
406
|
| --------------------------------- | --------------------------- | --------------------------------------------------------------------------- |
|
|
407
407
|
| Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts:33`) — preflight vs requête réelle séparés |
|
|
408
|
-
| Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:
|
|
409
|
-
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:
|
|
408
|
+
| Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:1032`) |
|
|
409
|
+
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
|
|
410
410
|
| Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
|
|
411
411
|
| Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
|
|
412
|
-
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:
|
|
412
|
+
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:604`) |
|
|
413
413
|
|
|
414
414
|
## ⚡ Performance & mémoire
|
|
415
415
|
|
|
416
416
|
La politique est **précalculée au boot** : les listes `methods`, `allowedHeaders`, `exposedHeaders` et
|
|
417
|
-
`maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42`), jamais par requête. Il
|
|
417
|
+
`maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42-49`), jamais par requête. Il
|
|
418
418
|
ne reste à l'exécution qu'un `Set.has()` sur l'origine.
|
|
419
419
|
|
|
420
420
|
Le coût par requête est donc :
|
|
421
421
|
|
|
422
|
-
- **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:
|
|
423
|
-
- **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:
|
|
422
|
+
- **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:1013`) ;
|
|
423
|
+
- **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:83`) ;
|
|
424
424
|
- **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts:166`) ;
|
|
425
425
|
- **une petite table d'en-têtes** allouée uniquement pour une requête cross-origin autorisée. Une
|
|
426
426
|
origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts:74`).
|
|
@@ -429,11 +429,11 @@ Le coût par requête est donc :
|
|
|
429
429
|
|
|
430
430
|
La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
|
|
431
431
|
`Firewall.describe()` (`firewall.ts:549`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
|
-
(`firewall.ts:
|
|
433
|
-
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:
|
|
432
|
+
(`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
|
+
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
|
|
434
434
|
cette surface.
|
|
435
435
|
|
|
436
|
-
- **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:
|
|
436
|
+
- **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:328`), protégé
|
|
437
437
|
`ROLE_NODEFONY_ADMIN`.
|
|
438
438
|
- **Écran** : console **Firewall** → section _Défenses_ (`FirewallDefenses`,
|
|
439
439
|
`FirewallDefenses.tsx:114`), carte CORS à côté des cartes CSRF, en-têtes et throttle.
|
|
@@ -447,7 +447,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
447
447
|
|
|
448
448
|
| Symptôme | Cause | Correction |
|
|
449
449
|
| --------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
450
|
-
| Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:
|
|
450
|
+
| Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:156-159`) | Lister les origines, ou passer `credentials:false` |
|
|
451
451
|
| Requête bloquée alors que l'origine « est » dans la liste | Match **exact** : port, scheme ou sous-domaine divergent | Écrire l'origine complète `scheme://host:port`, une entrée par variante |
|
|
452
452
|
| `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur | Normal — reproduire avec un `Origin` explicite (`curl -H 'Origin: …'`) |
|
|
453
453
|
| Cookie non envoyé malgré `credentials:true` | Opt-in serveur seul : le client n'a pas `credentials: "include"` | Activer les deux côtés (et un cookie `SameSite=None; Secure` en cross-site) |
|
|
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
455
455
|
| Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
|
|
456
456
|
| Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
|
|
457
457
|
| `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
|
|
458
|
-
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:
|
|
458
|
+
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:604`) — vérifier `allowedOrigins` |
|
|
459
459
|
| Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
|
|
460
460
|
|
|
461
461
|
## 🧪 Tests & couverture
|
package/docs/csrf.md
CHANGED
|
@@ -91,9 +91,9 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
91
91
|
## La vision Nodefony
|
|
92
92
|
|
|
93
93
|
- **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
|
|
94
|
-
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:
|
|
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:1479`) 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 —
|
|
@@ -111,7 +111,7 @@ cookies d'une victime, ils sont hors vecteur (`csrf.ts:113`).
|
|
|
111
111
|
|
|
112
112
|
### Opt-in couche 2 : `@CsrfProtect` sur une mutation à haute valeur
|
|
113
113
|
|
|
114
|
-
Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:
|
|
114
|
+
Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:101-102`) :
|
|
115
115
|
|
|
116
116
|
```typescript
|
|
117
117
|
// nodefony/controllers/ProfileController.ts — complet, compile tel quel
|
|
@@ -148,13 +148,13 @@ export default ProfileController;
|
|
|
148
148
|
|
|
149
149
|
(Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
|
|
150
150
|
le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
|
|
151
|
-
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:
|
|
151
|
+
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1620-1626`.)
|
|
152
152
|
|
|
153
153
|
### Comment le front obtient — puis rejoue — le token
|
|
154
154
|
|
|
155
155
|
1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
|
|
156
156
|
(`firewall.ts:958-964`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
|
|
157
|
-
exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:
|
|
157
|
+
exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:482-497`).
|
|
158
158
|
2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
|
|
159
159
|
`x-csrf-token` sur chaque mutation.
|
|
160
160
|
|
|
@@ -199,7 +199,7 @@ curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
|
|
|
199
199
|
> [!IMPORTANT]
|
|
200
200
|
> En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
|
|
201
201
|
> absent, un secret **éphémère** est généré (dev) : un redémarrage invalide les tokens en cours, et
|
|
202
|
-
> chaque pod rejette les tokens des autres (`firewall.ts:
|
|
202
|
+
> chaque pod rejette les tokens des autres (`firewall.ts:225-234`). Générer et câbler :
|
|
203
203
|
> `npx nodefony security:secrets` (`security-secrets.ts:39`) → `NF_CSRF_SECRET` →
|
|
204
204
|
> `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
|
|
205
205
|
|
|
@@ -241,12 +241,12 @@ autorisation :
|
|
|
241
241
|
```
|
|
242
242
|
|
|
243
243
|
> [!WARNING]
|
|
244
|
-
> `@CsrfExempt` (`routerDecorators.ts:
|
|
244
|
+
> `@CsrfExempt` (`routerDecorators.ts:1113`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
|
|
245
245
|
> webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
|
|
246
246
|
|
|
247
247
|
Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
|
|
248
248
|
déclarer l'alias dans `csrf.trustedOrigins` (match exact d'origine), pas dans `cors.origins` — CORS
|
|
249
|
-
ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:
|
|
249
|
+
ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:188-193`).
|
|
250
250
|
|
|
251
251
|
## 🏗️ Architecture interne
|
|
252
252
|
|
|
@@ -257,7 +257,7 @@ sûres → coût nul sur le GET dominant (`csrf.ts:88`). Pour une mutation, dans
|
|
|
257
257
|
|
|
258
258
|
1. **Origine de confiance** — `csrf.trustedOrigins` ∪ whitelist CORS → passe même en cross-site : ce
|
|
259
259
|
que CORS autorise déjà explicitement **n'est pas** du CSRF (`csrf.ts:45`, union construite par le
|
|
260
|
-
firewall au boot, `firewall.ts:
|
|
260
|
+
firewall au boot, `firewall.ts:217-220`).
|
|
261
261
|
2. **Fetch Metadata** (`Sec-Fetch-Site`, infalsifiable par un script) : `same-origin`/`none` → OK ;
|
|
262
262
|
`same-site` → OK sauf `strictSameSite` (`csrf.ts:102-104`) ; `cross-site` → **403** ; valeur
|
|
263
263
|
inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts:98-109`).
|
|
@@ -269,9 +269,9 @@ Lectures durcies côté firewall :
|
|
|
269
269
|
|
|
270
270
|
- en-têtes lus en **première occurrence** — jamais un tableau d'en-têtes répétés (garde d'injection,
|
|
271
271
|
`headerValue()`, `firewall.ts:103`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
|
|
272
|
-
parse du contexte (`cookieValue()`, `firewall.ts:
|
|
272
|
+
parse du contexte (`cookieValue()`, `firewall.ts:116-129`) ;
|
|
273
273
|
- hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
|
|
274
|
-
(`firewall.ts:
|
|
274
|
+
(`firewall.ts:977-982`) ;
|
|
275
275
|
- le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
|
|
276
276
|
whitelist) ne fuite jamais au client (`CsrfError.ts:17-21`).
|
|
277
277
|
|
|
@@ -296,28 +296,28 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
296
296
|
1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
|
|
297
297
|
`@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
|
|
298
298
|
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
|
-
(`Resolver.ts:
|
|
300
|
-
(`Context.ts:
|
|
299
|
+
(`Resolver.ts:173-174`) — champs portés par le `Context` de base, HTTP comme WS
|
|
300
|
+
(`Context.ts:241-243`).
|
|
301
301
|
3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
|
|
302
302
|
requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
|
|
303
303
|
`@CsrfProtect`. Les routes `bypassFirewall` (callbacks OAuth) sont exemptées
|
|
304
304
|
(`firewall.ts:950-953`), les `@CsrfExempt` sortent après la barrière méthode sûre
|
|
305
|
-
(`firewall.ts:
|
|
305
|
+
(`firewall.ts:967`).
|
|
306
306
|
4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
|
|
307
|
-
avec le cookie de session (`HttpContext.ts:
|
|
307
|
+
avec le cookie de session (`HttpContext.ts:482-497`).
|
|
308
308
|
|
|
309
|
-
## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:
|
|
309
|
+
## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:161-204`)
|
|
310
310
|
|
|
311
311
|
<!-- prettier-ignore -->
|
|
312
312
|
| Option | Type · défaut | Effet |
|
|
313
313
|
| --- | --- | --- |
|
|
314
|
-
| `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:
|
|
315
|
-
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:
|
|
316
|
-
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:
|
|
317
|
-
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:
|
|
318
|
-
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:
|
|
319
|
-
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:
|
|
320
|
-
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:
|
|
314
|
+
| `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:163-168`). |
|
|
315
|
+
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
|
|
316
|
+
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
|
|
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:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:497`). |
|
|
319
|
+
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
|
|
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
|
|
|
322
322
|
## 📜 Normes appliquées
|
|
323
323
|
|
|
@@ -339,13 +339,13 @@ 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:162`).
|
|
343
343
|
|
|
344
344
|
## 📡 Observabilité — Studio
|
|
345
345
|
|
|
346
346
|
L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
|
|
347
347
|
`Firewall.tsx:313-314`). La projection est **sans secret par construction** :
|
|
348
|
-
`Firewall.#describeDefenses()` (`firewall.ts:
|
|
348
|
+
`Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
|
|
349
349
|
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:601`).
|
|
350
350
|
|
|
351
351
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
@@ -355,7 +355,7 @@ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:60
|
|
|
355
355
|
| Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré | Ajouter l'origine à `csrf.trustedOrigins` (ou CORS si lecture voulue) |
|
|
356
356
|
| Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts:113`) | Attendu ; sur `@CsrfProtect`, semer le token (GET) avant la mutation |
|
|
357
357
|
| `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts:778-783`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
|
|
358
|
-
| Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:
|
|
358
|
+
| Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:227-237`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
|
|
359
359
|
| `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts:102-104`) | Le désactiver si les sous-domaines sont de confiance |
|
|
360
360
|
| `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts:130-136`) | Limite documentée (banc red-team) ; Fetch Metadata prime sur nav. moderne |
|
|
361
361
|
| Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
|
package/docs/external-jwt.md
CHANGED
|
@@ -33,9 +33,9 @@ Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions r
|
|
|
33
33
|
sont elles qui font la différence entre « intégrer un annuaire » et « en faire l'unique autorité
|
|
34
34
|
d'accès » :
|
|
35
35
|
|
|
36
|
-
1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:
|
|
36
|
+
1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:666-671`). Un jeton dont l'`iss` n'y figure pas est
|
|
37
37
|
refusé **avant toute requête sortante** — l'application ne va pas interroger un émetteur inconnu.
|
|
38
|
-
2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:
|
|
38
|
+
2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:672-677`). Par défaut, il
|
|
39
39
|
doit correspondre à un compte local. Un annuaire d'entreprise vaut pour des milliers de personnes : les accepter
|
|
40
40
|
toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d'être
|
|
41
41
|
du pare-feu.
|
|
@@ -81,7 +81,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
|
|
|
81
81
|
Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
|
|
82
82
|
paire `(émetteur, sujet)`, jamais le sujet seul.
|
|
83
83
|
|
|
84
|
-
En mode `prefixed` (`config.ts:
|
|
84
|
+
En mode `prefixed` (`config.ts:656-660`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
|
|
85
85
|
pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
|
|
86
86
|
identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
|
|
87
87
|
l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
|
|
@@ -96,7 +96,7 @@ parce qu'il **est** cette application, ou parce que ses sujets sont déjà des i
|
|
|
96
96
|
Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
|
|
97
97
|
utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
|
|
98
98
|
- **`ephemeral`** — aucun compte local n'est exigé ni créé ; l'appelant vit le temps de la requête
|
|
99
|
-
avec les rôles d'`ephemeralRoles` (`config.ts:
|
|
99
|
+
avec les rôles d'`ephemeralRoles` (`config.ts:678`). C'est le mode de l'appelant **purement machine** : un agent, un
|
|
100
100
|
service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
|
|
101
101
|
— qui viennent du jeton, donc bornés par le serveur d'autorisation.
|
|
102
102
|
|
package/docs/firewall.md
CHANGED
|
@@ -52,10 +52,10 @@ flowchart TD
|
|
|
52
52
|
AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
`Firewall.isSecure()` (`firewall.ts:
|
|
56
|
-
`Firewall.matchPath()` (`firewall.ts:
|
|
55
|
+
`Firewall.isSecure()` (`firewall.ts:721`) rattache la requête à une **zone** via
|
|
56
|
+
`Firewall.matchPath()` (`firewall.ts:712`) ; `Firewall.handleSecurity()` (`firewall.ts:754`) décide.
|
|
57
57
|
Les zones sont triées par **spécificité** dans `#build()` — `list.sort` par longueur de motif :
|
|
58
|
-
le plus long gagne, pas le premier déclaré (`firewall.ts:
|
|
58
|
+
le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
|
|
59
59
|
|
|
60
60
|
## 📖 Lexique
|
|
61
61
|
|
|
@@ -74,7 +74,7 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:191`).
|
|
|
74
74
|
### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
|
|
75
75
|
|
|
76
76
|
Le scaffold déclare deux zones dans `nodefony.config.ts` — c'est la forme canonique (un **objet par
|
|
77
|
-
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:
|
|
77
|
+
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
|
|
78
78
|
|
|
79
79
|
```typescript
|
|
80
80
|
// nodefony.config.ts (extrait généré par `nodefony create app`)
|
|
@@ -203,9 +203,9 @@ Point commun de sécurité : **message d'échec uniforme** (`"Invalid token"` /
|
|
|
203
203
|
Credential = l'**identifiant** posé dans le blob de session (jamais un secret).
|
|
204
204
|
|
|
205
205
|
- **N'ouvre jamais la session lui-même** : il exige une session reprise portant un user
|
|
206
|
-
(`supports()`, `SessionAuthenticator.ts:
|
|
206
|
+
(`supports()`, `SessionAuthenticator.ts:75`). C'est `AuthFlow.login()` (BFF) qui ouvre et
|
|
207
207
|
régénère l'ID (anti-fixation).
|
|
208
|
-
- **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts:
|
|
208
|
+
- **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts:96`) → rôles frais,
|
|
209
209
|
révocation et verrouillage effectifs immédiatement.
|
|
210
210
|
- **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login
|
|
211
211
|
(pas de popup Basic).
|
|
@@ -226,7 +226,7 @@ Credential = `Authorization: Basic base64(identifiant:motdepasse)` — RFC 7617,
|
|
|
226
226
|
|
|
227
227
|
### `jwt` — Bearer JWT signé, durci RFC 8725
|
|
228
228
|
|
|
229
|
-
Credential = `Authorization: Bearer <jws>` de structure compacte `a.b.c` (`JwtAuthenticator.ts:
|
|
229
|
+
Credential = `Authorization: Bearer <jws>` de structure compacte `a.b.c` (`JwtAuthenticator.ts:21`).
|
|
230
230
|
Réservé API service↔service / agents (le web reste sur la session). Access token **EdDSA** signé par
|
|
231
231
|
le keystore du serveur. Défenses **dures**, prouvées en test (RFC 8725 JWT BCP), toutes dans
|
|
232
232
|
`JwtAuthenticator.authenticate()` :
|
|
@@ -234,15 +234,15 @@ le keystore du serveur. Défenses **dures**, prouvées en test (RFC 8725 JWT BCP
|
|
|
234
234
|
- **allowlist d'algorithmes** `["EdDSA"]` — l'algo n'est **jamais** choisi d'après l'en-tête du
|
|
235
235
|
token ; `alg=none` rejeté (`JwtAuthenticator.ts:120`).
|
|
236
236
|
- **clé par `kid` depuis le JWKS LOCAL** (`createLocalJWKSet`) — jamais `jku`/`jwk` de l'en-tête
|
|
237
|
-
(anti-injection de clé / SSRF, `JwtAuthenticator.ts:
|
|
237
|
+
(anti-injection de clé / SSRF, `JwtAuthenticator.ts:174`).
|
|
238
238
|
- **`aud` + `iss` obligatoires** + `typ:"at+jwt"` (un refresh présenté comme access est rejeté) +
|
|
239
|
-
exp/nbf (`JwtAuthenticator.ts:
|
|
239
|
+
exp/nbf (`JwtAuthenticator.ts:119-124`).
|
|
240
240
|
- **révocation** malgré l'auto-portage : denylist `jti` + `invalidBefore` par sujet
|
|
241
|
-
(`JwtAuthenticator.ts:
|
|
241
|
+
(`JwtAuthenticator.ts:138-148`).
|
|
242
242
|
- **sujet revérifié** à réception (`loadUserByIdentifier(sub)`) : compte disparu/inactif/verrouillé
|
|
243
|
-
= rejet (`JwtAuthenticator.ts:
|
|
243
|
+
= rejet (`JwtAuthenticator.ts:190-201`).
|
|
244
244
|
|
|
245
|
-
Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:
|
|
245
|
+
Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:178-188`).
|
|
246
246
|
|
|
247
247
|
> [!WARNING]
|
|
248
248
|
> Un JWT est **auto-porté** : sans état serveur il n'est **pas** révocable. C'est la denylist
|
|
@@ -251,20 +251,20 @@ Le token promu porte `scopes`, `jti`, `claims` (`JwtAuthenticator.ts:162-172`).
|
|
|
251
251
|
|
|
252
252
|
### `apikey` — clé d'API opaque (PAT), révocable
|
|
253
253
|
|
|
254
|
-
Credential = `Authorization: Bearer <prefix>_…` (`ApiKeyAuthenticator.ts:
|
|
254
|
+
Credential = `Authorization: Bearer <prefix>_…` (`ApiKeyAuthenticator.ts:75-79`). Contrairement au JWT,
|
|
255
255
|
c'est un **bearer opaque** : sa vérité vit côté serveur (`ITokenStore`) → **révocable immédiatement**.
|
|
256
256
|
|
|
257
257
|
Défenses de `ApiKeyAuthenticator.authenticate()` :
|
|
258
258
|
|
|
259
259
|
- **forme + CRC validés AVANT tout accès au store** — anti-DoS (`parseApiKey()`,
|
|
260
|
-
`ApiKeyAuthenticator.ts:
|
|
261
|
-
- lookup par **hash sha256** : le secret n'existe nulle part au repos (
|
|
260
|
+
`ApiKeyAuthenticator.ts:103`) ;
|
|
261
|
+
- lookup par **hash sha256** : le secret n'existe nulle part au repos (`ApiKeyAuthenticator.ts:109`) ;
|
|
262
262
|
- révocation (`revokedAt`), expiration (`expiresAt`), **ban en masse** du porteur
|
|
263
|
-
(`invalidBefore` vs `createdAt`,
|
|
264
|
-
- **sujet revérifié** à chaque requête — rôles frais (
|
|
265
|
-
- `lastUsedAt` écrit en **throttlé** — pas une écriture par requête (
|
|
263
|
+
(`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:111-124`) ;
|
|
264
|
+
- **sujet revérifié** à chaque requête — rôles frais (`ApiKeyAuthenticator.ts:127`) ;
|
|
265
|
+
- `lastUsedAt` écrit en **throttlé** — pas une écriture par requête (`ApiKeyAuthenticator.ts:136-143`).
|
|
266
266
|
|
|
267
|
-
Le token porte `scopes`, `apiKeyId`, `tenantId` (
|
|
267
|
+
Le token porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:147-149`). `jwt` et `apikey` **cohabitent** dans
|
|
268
268
|
une zone : ils se discriminent par la forme (JWT = `a.b.c`, PAT = `prefix_…`).
|
|
269
269
|
|
|
270
270
|
### `anonymous` — accepter l'anonymat, explicitement
|
|
@@ -280,7 +280,7 @@ Sans lui, zone protégée + aucune preuve = 401.
|
|
|
280
280
|
|
|
281
281
|
Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
|
|
282
282
|
porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
|
|
283
|
-
handshake des zones protégées `realtime` (`firewall.ts:
|
|
283
|
+
handshake des zones protégées `realtime` (`firewall.ts:300`).
|
|
284
284
|
|
|
285
285
|
- **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
|
|
286
286
|
l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
|
|
@@ -320,7 +320,7 @@ laisser chercher pourquoi `context.session` est nul.
|
|
|
320
320
|
## ⚙️ Ordre et modes (`mode: "first"` vs `"all"`)
|
|
321
321
|
|
|
322
322
|
La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
|
|
323
|
-
(`firewall.ts:
|
|
323
|
+
(`firewall.ts:1128`). Le `mode` dit comment la parcourir. Trois situations concrètes :
|
|
324
324
|
|
|
325
325
|
### Situation 1 — humains ET machines sur la même API (`first`, le mode courant)
|
|
326
326
|
|
|
@@ -373,7 +373,7 @@ danger: {
|
|
|
373
373
|
|
|
374
374
|
Le client doit présenter **les deux preuves** dans la même requête (cookie + `Authorization:
|
|
375
375
|
Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte l'identité
|
|
376
|
-
(`firewall.ts:
|
|
376
|
+
(`firewall.ts:1199`) — ici la preuve mot de passe, la plus fraîche.
|
|
377
377
|
|
|
378
378
|
> [!TIP]
|
|
379
379
|
> Un nom d'authenticator inconnu en config **fait échouer le boot** —
|
|
@@ -513,11 +513,11 @@ scopes, métier), un même jury, combinables.
|
|
|
513
513
|
|
|
514
514
|
## 🔌 HTTP et WebSocket — le même firewall
|
|
515
515
|
|
|
516
|
-
`Firewall.#wireRealtime()` (`firewall.ts:
|
|
517
|
-
(opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:
|
|
518
|
-
**et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:
|
|
516
|
+
`Firewall.#wireRealtime()` (`firewall.ts:279`) câble, pour toute zone protégée `realtime !== false`
|
|
517
|
+
(opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:300`)
|
|
518
|
+
**et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:348`). Même résolution de zone que HTTP.
|
|
519
519
|
Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
|
|
520
|
-
`firewall.ts:
|
|
520
|
+
`firewall.ts:1207`) : le **code de fermeture** suffit.
|
|
521
521
|
|
|
522
522
|
## 🛡️ En-têtes de sécurité, CSRF, CORS
|
|
523
523
|
|
|
@@ -525,7 +525,7 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
|
|
|
525
525
|
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1045`) :
|
|
526
526
|
alloué seulement si une directive en a besoin.
|
|
527
527
|
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
|
|
528
|
-
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:
|
|
528
|
+
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:971`), puis double-submit `x-csrf-token` ≡
|
|
529
529
|
cookie + HMAC (`firewall.ts:778`).
|
|
530
530
|
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
|
|
531
531
|
|
|
@@ -537,10 +537,10 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
|
|
|
537
537
|
| Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
|
|
538
538
|
| JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
|
|
539
539
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
|
|
540
|
-
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:
|
|
540
|
+
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:777-780`) |
|
|
541
541
|
| Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
|
|
542
542
|
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:948`) |
|
|
543
|
-
| Modèle | Zero Trust | `firewall.ts:
|
|
543
|
+
| Modèle | Zero Trust | `firewall.ts:818-833` (aucune preuve → 401) |
|
|
544
544
|
|
|
545
545
|
## ⚡ Performance & mémoire
|
|
546
546
|
|