@nodefony/security 10.0.0-alpha.3 → 10.0.0-alpha.5
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/LICENSE +201 -543
- package/README.md +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +4 -2
- package/dist/nodefony/command/security-secrets.js +7 -3
- package/dist/nodefony/command/security-token.js +5 -5
- package/dist/nodefony/command/security-user-add.js +22 -6
- package/dist/nodefony/command/security-user-password.js +111 -0
- package/dist/nodefony/config/config.js +3 -1
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/oauth2.js +79 -2
- package/dist/nodefony/service/tokenService.js +3 -3
- package/dist/nodefony/service/totp.js +3 -3
- package/dist/nodefony/service/webAuthn.js +3 -3
- package/dist/nodefony/service/webhooks.js +3 -3
- package/dist/nodefony/src/token/JwtKeystore.js +37 -1
- package/dist/types/nodefony/command/security-user-add.d.ts +16 -0
- package/dist/types/nodefony/command/security-user-password.d.ts +31 -0
- package/dist/types/nodefony/config/config.d.ts +2 -0
- package/dist/types/nodefony/service/oauth2.d.ts +41 -1
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +20 -0
- package/docs/audit.md +5 -5
- package/docs/authenticators.md +10 -10
- package/docs/authorization.md +2 -2
- package/docs/cors.md +4 -4
- package/docs/csrf.md +5 -5
- package/docs/firewall.md +7 -7
- package/docs/headers.md +7 -7
- package/docs/index.md +24 -0
- package/docs/oauth2.md +54 -32
- package/docs/tokens.md +10 -9
- package/docs/totp.md +1 -1
- package/docs/webauthn.md +3 -3
- package/docs/webhooks.md +1 -1
- package/package.json +12 -12
|
@@ -238,6 +238,8 @@ export declare const securityConfigSchema: z.ZodObject<{
|
|
|
238
238
|
successRedirect: z.ZodOptional<z.ZodString>;
|
|
239
239
|
failureRedirect: z.ZodOptional<z.ZodString>;
|
|
240
240
|
defaultRoles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
241
|
+
label: z.ZodOptional<z.ZodString>;
|
|
242
|
+
hidden: z.ZodDefault<z.ZodBoolean>;
|
|
241
243
|
}, z.core.$strict>>>;
|
|
242
244
|
}, z.core.$strict>>;
|
|
243
245
|
apiKeys: z.ZodDefault<z.ZodObject<{
|
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
import { Service, Module } from "nodefony";
|
|
2
|
+
/**
|
|
3
|
+
* Libellé affichable d'un fournisseur, quand sa configuration n'en donne pas.
|
|
4
|
+
*
|
|
5
|
+
* Un écran de connexion ne doit JAMAIS montrer un identifiant technique brut :
|
|
6
|
+
* `mon-idp-interne` sur un bouton ne dit rien à qui doit cliquer. À défaut de
|
|
7
|
+
* marque connue, le nom de la clé de configuration est ce qui s'en rapproche le
|
|
8
|
+
* plus — mais rendu lisible : séparateurs en espaces, initiales en capitales,
|
|
9
|
+
* sigles préservés.
|
|
10
|
+
*
|
|
11
|
+
* Fonction PURE, donc éprouvable sans boot ni réseau.
|
|
12
|
+
*
|
|
13
|
+
* @param name - nom du fournisseur, tel qu'il est écrit dans la configuration
|
|
14
|
+
* @returns le libellé à afficher sur le bouton
|
|
15
|
+
*/
|
|
16
|
+
export declare function oauthDisplayLabel(name: string): string;
|
|
17
|
+
/** Un fournisseur tel que l'écran de connexion doit le présenter. */
|
|
18
|
+
export interface IOAuthDisplayProvider {
|
|
19
|
+
/** Nom technique — celui que l'URL `/authorize` attend. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Libellé du bouton : celui de la config, sinon dérivé du nom. */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
}
|
|
2
24
|
/** Données à porter en session entre `authorize` et `callback` (anti-replay). */
|
|
3
25
|
export interface IOAuthAuthorization {
|
|
4
26
|
/** URL d'autorisation vers laquelle rediriger l'utilisateur. */
|
|
@@ -33,8 +55,26 @@ declare class OAuth2Service extends Service {
|
|
|
33
55
|
constructor(module: Module);
|
|
34
56
|
/** `true` si le social login est opérationnel (activé + boot OK). */
|
|
35
57
|
isEnabled(): boolean;
|
|
36
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Noms des fournisseurs OPÉRATIONNELS — configurés ET connus du registre.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 C'est la **garde d'autorisation** : `/authorize` refuse en 404 tout nom
|
|
62
|
+
* absent de cette liste. Elle répond donc à « ce flux peut-il s'ouvrir ? »,
|
|
63
|
+
* jamais à « ce bouton doit-il s'afficher ? » — pour l'écran, voir
|
|
64
|
+
* {@link listDisplayProviders}. Confondre les deux ferait d'un masquage une
|
|
65
|
+
* désactivation, et couperait les bancs qui exercent une fixture masquée.
|
|
66
|
+
*/
|
|
37
67
|
listProviders(): string[];
|
|
68
|
+
/**
|
|
69
|
+
* Fournisseurs à MONTRER sur l'écran de connexion, libellés compris.
|
|
70
|
+
*
|
|
71
|
+
* Rend TOUT fournisseur opérationnel — y compris ceux dont le framework ne
|
|
72
|
+
* connaît pas la marque, qui sont précisément ceux qu'une application
|
|
73
|
+
* enregistre elle-même. Le seul retrait possible est explicite et se lit dans
|
|
74
|
+
* la configuration du fournisseur (`hidden: true`), à côté de la raison qui
|
|
75
|
+
* l'a motivé ; il ne désactive rien.
|
|
76
|
+
*/
|
|
77
|
+
listDisplayProviders(): IOAuthDisplayProvider[];
|
|
38
78
|
/**
|
|
39
79
|
* Redirections post-login (succès / échec) — lues par le controller.
|
|
40
80
|
* Surcharge PAR FOURNISSEUR si fournie, sinon valeur globale, sinon défaut.
|
|
@@ -9,6 +9,26 @@ interface KeystoreSource {
|
|
|
9
9
|
/** Dossier de persistance `keyset.json` — source `fichier` (opt-in dev/VPS). */
|
|
10
10
|
readonly dir?: string;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Le trousseau va-t-il partir dans l'image de conteneur ?
|
|
14
|
+
*
|
|
15
|
+
* ⭐ **Pourquoi cet avertissement existe.** Le trousseau est une clé privée
|
|
16
|
+
* Ed25519. Elle ne sort pas de l'image aujourd'hui pour UNE seule raison : le
|
|
17
|
+
* gabarit a choisi `var/keys`, et le `Dockerfile` généré efface `var/`. Rien
|
|
18
|
+
* n'attache cette sécurité à la configuration — un utilisateur qui écrit
|
|
19
|
+
* `keystore: { dir: "nodefony/config/keys" }`, chemin parfaitement raisonnable,
|
|
20
|
+
* publie sa clé privée sans qu'aucun signal n'existe. C'est exactement le
|
|
21
|
+
* chemin par lequel une clé TLS est déjà partie dans une image publiée.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ **Les chemins ABSOLUS ne sont pas jugés**, et c'est délibéré : `/etc/…` ou
|
|
24
|
+
* un point de montage sont des choix d'exploitation qui sortent du contexte de
|
|
25
|
+
* construction, et prétendre les évaluer ferait crier ce contrôle sur la
|
|
26
|
+
* pratique la plus saine. Un contrôle qui crie faux apprend à passer outre.
|
|
27
|
+
*
|
|
28
|
+
* @param dir - la valeur de `jwt.keystore.dir`, telle que configurée.
|
|
29
|
+
* @returns le message à journaliser, ou `null` quand le dossier est nettoyé.
|
|
30
|
+
*/
|
|
31
|
+
export declare function keystoreLeaksIntoImage(dir: string): string | null;
|
|
12
32
|
/**
|
|
13
33
|
* Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
|
|
14
34
|
*
|
package/docs/audit.md
CHANGED
|
@@ -383,7 +383,7 @@ Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recor
|
|
|
383
383
|
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:794`) ;
|
|
384
384
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
385
385
|
(`firewall.ts:811`) ;
|
|
386
|
-
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:
|
|
386
|
+
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
|
|
387
387
|
|
|
388
388
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
389
389
|
`login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
|
|
@@ -458,7 +458,7 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
458
458
|
|
|
459
459
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
460
460
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
461
|
-
(`firewall.ts:
|
|
461
|
+
(`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
|
|
462
462
|
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:324`).
|
|
463
463
|
|
|
464
464
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
@@ -484,7 +484,7 @@ jamais faire tomber ce qu'on supervise.
|
|
|
484
484
|
## ⚙️ Configuration
|
|
485
485
|
|
|
486
486
|
Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la racine sous la clé `audit`
|
|
487
|
-
(`config.ts:
|
|
487
|
+
(`config.ts:1152`).
|
|
488
488
|
|
|
489
489
|
| Option | Type | Défaut | Effet |
|
|
490
490
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
@@ -503,7 +503,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
|
|
|
503
503
|
### Comment `store: "auto"` décide
|
|
504
504
|
|
|
505
505
|
Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
|
|
506
|
-
enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:
|
|
506
|
+
enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:289`).
|
|
507
507
|
|
|
508
508
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
509
509
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
@@ -618,7 +618,7 @@ pour les migrations de production ; en dev et en test, le DDL dérivé les ignor
|
|
|
618
618
|
filtrage, jamais de sémantique.
|
|
619
619
|
|
|
620
620
|
L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
|
|
621
|
-
(`registerStores.ts:
|
|
621
|
+
(`registerStores.ts:276`, entité via `registerAuditEntities()`, `auditEventEntity.ts:141`). Côté
|
|
622
622
|
implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts:64`) résout son handle de base **à chaque
|
|
623
623
|
appel**, pas à la construction : l'ordre de démarrage n'est pas garanti, et l'ORM se déconnecte au
|
|
624
624
|
`onTerminate` avant le drain des serveurs.
|
package/docs/authenticators.md
CHANGED
|
@@ -49,7 +49,7 @@ flowchart TD
|
|
|
49
49
|
S --> CTRL["→ autorisation → contrôleur"]
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
C'est `Firewall.#authenticate()` (`firewall.ts:
|
|
52
|
+
C'est `Firewall.#authenticate()` (`firewall.ts:1128`) qui déroule ce cycle pour chaque maillon de la
|
|
53
53
|
zone, dans l'ordre déclaré. Le succès pose l'identité dans l'ALS ; l'échec remonte au firewall qui
|
|
54
54
|
pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la réponse.
|
|
55
55
|
|
|
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
107
107
|
Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
|
|
108
|
-
(`firewall.ts:
|
|
108
|
+
(`firewall.ts:429`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
111
|
- Les **cinq builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `apikey`)
|
|
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
|
|
|
116
116
|
- La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
|
|
117
117
|
`tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
|
|
118
118
|
- Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
|
|
119
|
-
(`firewall.ts:
|
|
119
|
+
(`firewall.ts:582`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
|
|
120
120
|
faute de frappe.
|
|
121
121
|
|
|
122
122
|
## 🚀 Démarrage rapide
|
|
@@ -261,10 +261,10 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
|
261
261
|
d'un utilisateur (`SessionAuthenticator.ts:43-46`) — le pipeline http démarre la session _avant_
|
|
262
262
|
le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
|
|
263
263
|
- **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
|
|
264
|
-
(`SessionAuthenticator.ts:
|
|
264
|
+
(`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
|
|
265
265
|
partagés avec `AuthFlow.me()` : `isLocked()`/`isActive()` → rejet (`sessionIdentity.ts:40`).
|
|
266
266
|
- `onSuccess()` pose l'identifiant sur le contexte — la persistance de session lie le blob au
|
|
267
|
-
principal courant (`SessionAuthenticator.ts:
|
|
267
|
+
principal courant (`SessionAuthenticator.ts:110-116`).
|
|
268
268
|
- **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login,
|
|
269
269
|
jamais une popup Basic (`SessionAuthenticator.ts:25-27`).
|
|
270
270
|
|
|
@@ -353,12 +353,12 @@ Deux preuves différentes, mêmes routes — c'est la config du Démarrage rapid
|
|
|
353
353
|
de lecture :
|
|
354
354
|
|
|
355
355
|
- un maillon dont `supports()` est faux est simplement **sauté** en mode `first`
|
|
356
|
-
(`firewall.ts:
|
|
356
|
+
(`firewall.ts:1114`) ;
|
|
357
357
|
- un credential **présenté mais invalide échoue immédiatement** — l'échec d'`authenticate()`
|
|
358
|
-
remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:
|
|
358
|
+
remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1128`). Une clé
|
|
359
359
|
API révoquée donne un 401 direct, même si un autre maillon aurait pu réussir.
|
|
360
360
|
- aucune preuve présentée sur toute la chaîne → `handleSecurity()` lève l'`AuthenticationError`
|
|
361
|
-
Zero Trust (`firewall.ts:
|
|
361
|
+
Zero Trust (`firewall.ts:754`).
|
|
362
362
|
|
|
363
363
|
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
364
364
|
|
|
@@ -397,7 +397,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
|
|
|
397
397
|
Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
|
|
398
398
|
invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
|
|
399
399
|
client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
|
|
400
|
-
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:
|
|
400
|
+
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1207`).
|
|
401
401
|
|
|
402
402
|
## 🧩 Ajouter un authenticator maison
|
|
403
403
|
|
|
@@ -423,7 +423,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
423
423
|
<!-- prettier-ignore -->
|
|
424
424
|
| Domaine | Norme | Ancrage |
|
|
425
425
|
| --- | --- | --- |
|
|
426
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
426
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
|
|
427
427
|
| Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
|
|
428
428
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
429
429
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
package/docs/authorization.md
CHANGED
|
@@ -302,7 +302,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
302
302
|
|
|
303
303
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
304
304
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
305
|
-
(`Resolver.ts:
|
|
305
|
+
(`Resolver.ts:587-607`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
306
306
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
307
307
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
308
308
|
|
|
@@ -428,7 +428,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
428
428
|
et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
|
|
429
429
|
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
|
|
430
430
|
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:276`) délègue à `Firewall.hasRole()`
|
|
431
|
-
(`firewall.ts:
|
|
431
|
+
(`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
|
|
432
432
|
|
|
433
433
|
## 📜 Normes appliquées
|
|
434
434
|
|
package/docs/cors.md
CHANGED
|
@@ -323,7 +323,7 @@ sequenceDiagram
|
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
`Firewall.handleCors()` (`firewall.ts:991`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
-
(`http-kernel.ts:
|
|
326
|
+
(`http-kernel.ts:1301`), à la ligne `http-kernel.ts:1301` — **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.
|
|
@@ -338,7 +338,7 @@ Quatre sorties en no-op, dans cet ordre (`firewall.ts:797`) :
|
|
|
338
338
|
court-circuité en 204 (`firewall.ts:822`).
|
|
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:999`). 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
|
|
@@ -384,7 +384,7 @@ origine (`config.ts:180`). Ajouter une origine à `cors.origins` est **plus** pe
|
|
|
384
384
|
**Les navigateurs n'appliquent pas CORS aux WebSockets.** Une page tierce peut ouvrir un
|
|
385
385
|
`new WebSocket("wss://api.example.com/…")` et le handshake partira **avec le cookie de session de la
|
|
386
386
|
victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un contexte WS
|
|
387
|
-
(`firewall.ts:
|
|
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
390
|
(`http-kernel.ts:599`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
|
|
@@ -428,7 +428,7 @@ Le coût par requête est donc :
|
|
|
428
428
|
## 📡 Observabilité — Studio
|
|
429
429
|
|
|
430
430
|
La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
|
|
431
|
-
`Firewall.describe()` (`firewall.ts:
|
|
431
|
+
`Firewall.describe()` (`firewall.ts:549`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
432
|
(`firewall.ts:575`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
433
|
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:594`) — aucun secret ne transite par
|
|
434
434
|
cette surface.
|
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:151-156`).
|
|
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:1470`) 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 —
|
|
@@ -153,7 +153,7 @@ le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les ac
|
|
|
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
|
-
(`firewall.ts:
|
|
156
|
+
(`firewall.ts:958-964`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
|
|
157
157
|
exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:419-432`).
|
|
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.
|
|
@@ -298,10 +298,10 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
298
298
|
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
299
|
(`Resolver.ts:152-153`) — champs portés par le `Context` de base, HTTP comme WS
|
|
300
300
|
(`Context.ts:181-183`).
|
|
301
|
-
3. `Firewall.enforceCsrf()` (`firewall.ts:
|
|
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
|
-
(`firewall.ts:
|
|
304
|
+
(`firewall.ts:950-953`), les `@CsrfExempt` sortent après la barrière méthode sûre
|
|
305
305
|
(`firewall.ts:951`).
|
|
306
306
|
4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
|
|
307
307
|
avec le cookie de session (`HttpContext.ts:419-432`).
|
|
@@ -346,7 +346,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
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
348
|
`Firewall.#describeDefenses()` (`firewall.ts:575`) publie la config résolue, et `synchronizerToken`
|
|
349
|
-
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:
|
|
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)
|
|
352
352
|
|
package/docs/firewall.md
CHANGED
|
@@ -342,7 +342,7 @@ Ce qui se passe, requête par requête :
|
|
|
342
342
|
| --- | --- | --- |
|
|
343
343
|
| le cookie de session | `session` | identifié, `apikey` jamais consulté |
|
|
344
344
|
| `Authorization: Bearer nf_…` | `apikey` | identifié (session ne matche pas, on passe) |
|
|
345
|
-
| une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:
|
|
345
|
+
| une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1128`) |
|
|
346
346
|
| rien | aucun | **401** (Zero Trust) |
|
|
347
347
|
|
|
348
348
|
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
@@ -377,7 +377,7 @@ Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte
|
|
|
377
377
|
|
|
378
378
|
> [!TIP]
|
|
379
379
|
> Un nom d'authenticator inconnu en config **fait échouer le boot** —
|
|
380
|
-
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:
|
|
380
|
+
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:429`) : jamais de zone
|
|
381
381
|
> « protégée » silencieusement ouverte à cause d'une faute de frappe.
|
|
382
382
|
|
|
383
383
|
## 🧑⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
|
|
@@ -470,10 +470,10 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
|
|
|
470
470
|
|
|
471
471
|
## 🛡️ En-têtes de sécurité, CSRF, CORS
|
|
472
472
|
|
|
473
|
-
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:
|
|
474
|
-
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:
|
|
473
|
+
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:1045`) : CSP, Referrer-Policy, COOP/COEP/CORP
|
|
474
|
+
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1045`) :
|
|
475
475
|
alloué seulement si une directive en a besoin.
|
|
476
|
-
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:
|
|
476
|
+
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
|
|
477
477
|
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:764`), puis double-submit `x-csrf-token` ≡
|
|
478
478
|
cookie + HMAC (`firewall.ts:778`).
|
|
479
479
|
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
|
|
@@ -482,13 +482,13 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
|
|
|
482
482
|
|
|
483
483
|
| Domaine | Norme | Ancrage |
|
|
484
484
|
| ---------------------- | --------------- | ------------------------------------------------------ |
|
|
485
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
485
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
|
|
486
486
|
| Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
|
|
487
487
|
| JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
|
|
488
488
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
|
|
489
489
|
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:764`) |
|
|
490
490
|
| Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
|
|
491
|
-
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:
|
|
491
|
+
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:948`) |
|
|
492
492
|
| Modèle | Zero Trust | `firewall.ts:611` (aucune preuve → 401) |
|
|
493
493
|
|
|
494
494
|
## ⚡ Performance & mémoire
|
package/docs/headers.md
CHANGED
|
@@ -39,7 +39,7 @@ source: "src/packages/@nodefony/security/docs/headers.md"
|
|
|
39
39
|
> (`@nodefony/http`, dès l'entrée brute — couvre aussi les fichiers statiques et les erreurs) et la
|
|
40
40
|
> couche **applicative** (`@nodefony/security`, dans le pipeline — CSP, Referrer-Policy, isolation
|
|
41
41
|
> cross-origin). Ancré sur `SecurityHeaders` (`securityHeaders.ts:42`) et
|
|
42
|
-
> `Firewall.applySecurityHeaders()` (`firewall.ts:
|
|
42
|
+
> `Firewall.applySecurityHeaders()` (`firewall.ts:1045`).
|
|
43
43
|
|
|
44
44
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **En-têtes de sécurité**
|
|
45
45
|
|
|
@@ -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:963`). 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
|
|
|
@@ -452,7 +452,7 @@ rester imprévisible, jamais pilotable par le client — contrairement au `reque
|
|
|
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:1372`). 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
|
|
|
@@ -510,7 +510,7 @@ Trois propriétés à retenir :
|
|
|
510
510
|
|
|
511
511
|
L'exemple de référence vit dans le framework : en développement, `@nodefony/frontend` déclare les
|
|
512
512
|
origines du serveur Vite et `'unsafe-eval'` (exigé par le Fast Refresh de React) via
|
|
513
|
-
`FrontendService.#viteCspFragment()` (`FrontendService.ts:
|
|
513
|
+
`FrontendService.#viteCspFragment()` (`FrontendService.ts:1012`) — ce qui explique qu'un CSP observé
|
|
514
514
|
en dev soit plus large qu'en production, où ce fragment n'existe pas.
|
|
515
515
|
|
|
516
516
|
## 📜 Normes appliquées
|
|
@@ -523,7 +523,7 @@ 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:239`) |
|
|
525
525
|
| Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
|
|
526
|
-
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:
|
|
526
|
+
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:963`) |
|
|
527
527
|
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:330`) |
|
|
528
528
|
|
|
529
529
|
## ⚡ Performance & mémoire
|
|
@@ -539,7 +539,7 @@ Le coût est concentré au boot, par construction :
|
|
|
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:1067`) ; 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:958-966`), 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
|
|
@@ -550,7 +550,7 @@ dans le périmètre du gate mémoire, qu'il ne peut structurellement pas dégrad
|
|
|
550
550
|
L'écran **Firewall** de Studio affiche la section « En-têtes de sécurité » — pilotée par
|
|
551
551
|
`headers.enabled` (`FirewallDefenses.tsx:219`) — avec le CSP effectif, l'état du nonce par requête, la
|
|
552
552
|
Referrer-Policy et les valeurs d'isolation. Les données
|
|
553
|
-
viennent de `Firewall.describe()` (`firewall.ts:
|
|
553
|
+
viennent de `Firewall.describe()` (`firewall.ts:549`), qui projette la config **sans aucun secret**,
|
|
554
554
|
exposée par `GET /nodefony/security/api/firewall`.
|
|
555
555
|
|
|
556
556
|
L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Zod — chaque champ y
|
package/docs/index.md
CHANGED
|
@@ -138,6 +138,30 @@ Services `Firewall`, `AuthFlow`, `TokenService`, `ApiKeyService`, `Authorization
|
|
|
138
138
|
Les signatures exactes vivent dans le graphe généré — `jq '.symbols.Firewall' .ai/symbols.json` —
|
|
139
139
|
jamais recopiées ici (elles divergeraient).
|
|
140
140
|
|
|
141
|
+
## 🛠️ Gérer les comptes en exploitation
|
|
142
|
+
|
|
143
|
+
Sur un serveur, la console d'administration suppose d'y être déjà entré — ce qui est précisément
|
|
144
|
+
impossible quand on a perdu le mot de passe. Ces commandes sont l'autre porte, et elles s'exécutent
|
|
145
|
+
sans ouvrir de port (profil console) :
|
|
146
|
+
|
|
147
|
+
<!-- prettier-ignore -->
|
|
148
|
+
| Commande | Ce qu'elle fait |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `nodefony security:user:add <id> [--admin]` | crée un compte, puis dit comment s'authentifier |
|
|
151
|
+
| `nodefony security:user:list [-q <motif>]` | identifiant, rôles, état |
|
|
152
|
+
| `nodefony security:user:password <id>` | change le mot de passe — **et révoque sessions et jetons** |
|
|
153
|
+
| `nodefony security:user:delete <id>` | supprime, après confirmation ; refuse le dernier administrateur |
|
|
154
|
+
| `nodefony security:secrets [--write]` | engendre les clés attendues et guide leur câblage |
|
|
155
|
+
| `nodefony security:token` | émet un jeton d'accès pour la porte MCP |
|
|
156
|
+
|
|
157
|
+
En terminal, le mot de passe est **demandé masqué** et confirmé ; pour un script, `--password <pwd>`
|
|
158
|
+
l'accepte en argument — au prix de l'historique du shell, que la commande rappelle.
|
|
159
|
+
|
|
160
|
+
Deux garde-fous qui se constatent plutôt qu'ils ne se supposent : le **dernier administrateur actif
|
|
161
|
+
ne se supprime pas** (`security-user-delete.ts:101`), et un changement de mot de passe **éjecte les
|
|
162
|
+
accès en cours** par la même cascade que la suppression (`userRevocationCascade.ts:37`) — on change
|
|
163
|
+
un mot de passe parce qu'il est perdu ou compromis.
|
|
164
|
+
|
|
141
165
|
## ⚙️ Configuration
|
|
142
166
|
|
|
143
167
|
Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod
|