@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3
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.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
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:792`) rattache la requête à une **zone** via
|
|
56
|
+
`Firewall.matchPath()` (`firewall.ts:783`) ; `Firewall.handleSecurity()` (`firewall.ts:825`) 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:258`).
|
|
59
59
|
|
|
60
60
|
## 📖 Lexique
|
|
61
61
|
|
|
@@ -64,7 +64,7 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
|
|
|
64
64
|
| Zone | Un motif d'URL (+ host) avec sa politique (`config.areas`, un objet par nom). |
|
|
65
65
|
| Authenticator | Une stratégie d'identification (session, userpassword, jwt, apikey, anonymous). |
|
|
66
66
|
| Zero Trust | Sans preuve valide sur une zone protégée → 401. |
|
|
67
|
-
| Challenge | En-tête `WWW-Authenticate` (RFC
|
|
67
|
+
| Challenge | En-tête `WWW-Authenticate` (RFC 9110 §11) qui dit comment s'authentifier. |
|
|
68
68
|
| BFF | Backend-For-Frontend : le serveur gère session/jetons pour le front web. |
|
|
69
69
|
| PAT | Personal Access Token : une clé d'API opaque, révocable côté serveur. |
|
|
70
70
|
| Bearer | Schéma `Authorization: Bearer <token>` (RFC 6750). |
|
|
@@ -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
|
|
77
|
-
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:
|
|
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
|
+
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1443-1444`) :
|
|
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
|
|
|
@@ -182,6 +182,39 @@ Authorization: Bearer eyJhbGciOiJFZERTQS␣…␣.␣…␣.␣… # un J
|
|
|
182
182
|
Authorization: Bearer nf_9a2c… # une clé API (préfixe nf_)
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
### Protéger des pages : conduire le visiteur à la connexion (`entryPoint`)
|
|
186
|
+
|
|
187
|
+
Sans réglage, un visiteur anonyme qui ouvre une page protégée reçoit un `401` brut.
|
|
188
|
+
`entryPoint` lui donne la page de connexion à la place :
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
use("@nodefony/security", {
|
|
192
|
+
areas: {
|
|
193
|
+
pages: {
|
|
194
|
+
pattern: "^/compte(/|$)",
|
|
195
|
+
authenticators: ["session"],
|
|
196
|
+
entryPoint: "/login", // la page de connexion du framework (loginPage.path)
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
| La requête refusée | Réponse |
|
|
203
|
+
| ---------------------------------------------------------------------------- | ------------------------------------ |
|
|
204
|
+
| `GET`/`HEAD`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` ou absent | `302` → `/login?from=%2Fcompte` |
|
|
205
|
+
| appel d'API (`Accept: application/json`), `fetch()` (`Sec-Fetch-Mode: cors`) | `401` + `WWW-Authenticate`, inchangé |
|
|
206
|
+
| `POST`, `PUT`, `DELETE`…, WebSocket | refus inchangé |
|
|
207
|
+
|
|
208
|
+
- La page de connexion ramène au chemin demandé (`from`, requête comprise) une fois connecté.
|
|
209
|
+
`from` passe par `safeRedirectPath`, la garde anti-redirection ouverte du cœur.
|
|
210
|
+
- C'est toujours un **refus** : l'audit enregistre `auth.denied` / `no_credentials`, et le hook
|
|
211
|
+
`onAuthFailure` se déclenche, comme pour un `401`.
|
|
212
|
+
- `entryPoint` doit être un chemin de l'origine : une adresse externe (`https://…`, `//…`) est
|
|
213
|
+
refusée au démarrage.
|
|
214
|
+
- La page de connexion elle-même n'est jamais redirigée vers elle-même : si elle tombe dans la
|
|
215
|
+
zone, sa requête garde son `401`.
|
|
216
|
+
- Absent (le défaut), tous les clients reçoivent le `401`.
|
|
217
|
+
|
|
185
218
|
## 🔐 Les authenticators intégrés
|
|
186
219
|
|
|
187
220
|
Tous respectent le **même contrat** (`IAuthenticator`) : `supports(context)` (test bon marché : la
|
|
@@ -280,7 +313,7 @@ Sans lui, zone protégée + aucune preuve = 401.
|
|
|
280
313
|
|
|
281
314
|
Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
|
|
282
315
|
porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
|
|
283
|
-
handshake des zones protégées `realtime` (`firewall.ts:
|
|
316
|
+
handshake des zones protégées `realtime` (`firewall.ts:302`).
|
|
284
317
|
|
|
285
318
|
- **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
|
|
286
319
|
l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
|
|
@@ -320,7 +353,7 @@ laisser chercher pourquoi `context.session` est nul.
|
|
|
320
353
|
## ⚙️ Ordre et modes (`mode: "first"` vs `"all"`)
|
|
321
354
|
|
|
322
355
|
La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
|
|
323
|
-
(`firewall.ts:
|
|
356
|
+
(`firewall.ts:1236`). Le `mode` dit comment la parcourir. Trois situations concrètes :
|
|
324
357
|
|
|
325
358
|
### Situation 1 — humains ET machines sur la même API (`first`, le mode courant)
|
|
326
359
|
|
|
@@ -342,7 +375,7 @@ Ce qui se passe, requête par requête :
|
|
|
342
375
|
| --- | --- | --- |
|
|
343
376
|
| le cookie de session | `session` | identifié, `apikey` jamais consulté |
|
|
344
377
|
| `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:
|
|
378
|
+
| une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1236`) |
|
|
346
379
|
| rien | aucun | **401** (Zero Trust) |
|
|
347
380
|
|
|
348
381
|
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
@@ -373,11 +406,11 @@ danger: {
|
|
|
373
406
|
|
|
374
407
|
Le client doit présenter **les deux preuves** dans la même requête (cookie + `Authorization:
|
|
375
408
|
Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte l'identité
|
|
376
|
-
(`firewall.ts:
|
|
409
|
+
(`firewall.ts:1311`) — ici la preuve mot de passe, la plus fraîche.
|
|
377
410
|
|
|
378
411
|
> [!TIP]
|
|
379
412
|
> Un nom d'authenticator inconnu en config **fait échouer le boot** —
|
|
380
|
-
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:
|
|
413
|
+
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:468`) : jamais de zone
|
|
381
414
|
> « protégée » silencieusement ouverte à cause d'une faute de frappe.
|
|
382
415
|
|
|
383
416
|
## 🧑⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
|
|
@@ -463,12 +496,12 @@ inchangé : la zone n'exige qu'une identité.
|
|
|
463
496
|
C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
|
|
464
497
|
explicite du code — jamais un contournement :
|
|
465
498
|
|
|
466
|
-
| Ce qui décide à la place
|
|
467
|
-
|
|
|
468
|
-
| Une **garde
|
|
469
|
-
| Une route **`
|
|
470
|
-
| Un **`bypassFirewall`**
|
|
471
|
-
| L'**ouverture d'une connexion WebSocket**
|
|
499
|
+
| Ce qui décide à la place | Pourquoi |
|
|
500
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
501
|
+
| 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. |
|
|
502
|
+
| Une route **`areaRoleExempt`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
|
|
503
|
+
| 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. |
|
|
504
|
+
| 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
505
|
|
|
473
506
|
Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
|
|
474
507
|
une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
|
|
@@ -477,7 +510,7 @@ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rô
|
|
|
477
510
|
puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
|
|
478
511
|
destinataire.
|
|
479
512
|
|
|
480
|
-
> ⚠️ **`
|
|
513
|
+
> ⚠️ **`areaRoleExempt` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
|
|
481
514
|
> qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
|
|
482
515
|
> que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
|
|
483
516
|
> Dans le doute, une garde d'action explicite est toujours préférable.
|
|
@@ -505,7 +538,7 @@ registerVoterFactory(
|
|
|
505
538
|
// l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.
|
|
506
539
|
```
|
|
507
540
|
|
|
508
|
-
Le voter est **découvert automatiquement** par
|
|
541
|
+
Le voter est **découvert automatiquement** par le service `authorization` — aucun changement dans le
|
|
509
542
|
cœur (`registerVoterFactory()`, `voterRegistry.ts:40`). Pourquoi un registre et pas un scan DI des
|
|
510
543
|
`@injectable` : les interfaces TS sont **effacées à la compilation** — rien à scanner ; le registre
|
|
511
544
|
**est** le marqueur explicite (TSDoc du registre, `voterRegistry.ts:6-16`). Trois axes (rôles,
|
|
@@ -513,34 +546,34 @@ scopes, métier), un même jury, combinables.
|
|
|
513
546
|
|
|
514
547
|
## 🔌 HTTP et WebSocket — le même firewall
|
|
515
548
|
|
|
516
|
-
`Firewall.#wireRealtime()` (`firewall.ts:
|
|
517
|
-
(opt-out, `firewall.ts:
|
|
518
|
-
**et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:
|
|
549
|
+
`Firewall.#wireRealtime()` (`firewall.ts:301`) câble, pour toute zone protégée `realtime !== false`
|
|
550
|
+
(opt-out, `firewall.ts:278`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:313`)
|
|
551
|
+
**et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:361`). Même résolution de zone que HTTP.
|
|
519
552
|
Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
|
|
520
|
-
`firewall.ts:
|
|
553
|
+
`firewall.ts:1345`) : le **code de fermeture** suffit.
|
|
521
554
|
|
|
522
555
|
## 🛡️ En-têtes de sécurité, CSRF, CORS
|
|
523
556
|
|
|
524
|
-
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:
|
|
525
|
-
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:
|
|
557
|
+
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:1152`) : CSP, Referrer-Policy, COOP/COEP/CORP
|
|
558
|
+
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1081`) :
|
|
526
559
|
alloué seulement si une directive en a besoin.
|
|
527
|
-
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:
|
|
528
|
-
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:
|
|
529
|
-
cookie + HMAC (`firewall.ts:
|
|
530
|
-
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:
|
|
560
|
+
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:1042`) : Fetch Metadata
|
|
561
|
+
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:977`), puis double-submit `x-csrf-token` ≡
|
|
562
|
+
cookie + HMAC (`firewall.ts:628`).
|
|
563
|
+
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:1098`).
|
|
531
564
|
|
|
532
565
|
## 📜 Normes appliquées
|
|
533
566
|
|
|
534
567
|
| Domaine | Norme | Ancrage |
|
|
535
568
|
| ---------------------- | --------------- | ------------------------------------------------------ |
|
|
536
|
-
| Challenge d'auth (401) | RFC
|
|
569
|
+
| Challenge d'auth (401) | RFC 9110 §11 | `Firewall.#setChallenge()` (`firewall.ts:1345`) |
|
|
537
570
|
| Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
|
|
538
571
|
| JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
|
|
539
572
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
|
|
540
|
-
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:
|
|
573
|
+
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:838-841`) |
|
|
541
574
|
| Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
|
|
542
|
-
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:
|
|
543
|
-
| Modèle | Zero Trust | `firewall.ts:
|
|
575
|
+
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:1042`) |
|
|
576
|
+
| Modèle | Zero Trust | `firewall.ts:819-834` (aucune preuve → 401) |
|
|
544
577
|
|
|
545
578
|
## ⚡ Performance & mémoire
|
|
546
579
|
|
|
@@ -553,16 +586,16 @@ ne paie quasiment rien.
|
|
|
553
586
|
|
|
554
587
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
555
588
|
|
|
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
|
|
589
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
590
|
+
| ---------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
591
|
+
| Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
|
|
592
|
+
| Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
|
|
593
|
+
| 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
|
|
594
|
+
| Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
|
|
595
|
+
| API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
|
|
596
|
+
| JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
|
|
597
|
+
| 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) |
|
|
598
|
+
| 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
|
|
566
599
|
|
|
567
600
|
## 📡 Observabilité — Studio
|
|
568
601
|
|
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:1152`).
|
|
43
43
|
|
|
44
44
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **En-têtes de sécurité**
|
|
45
45
|
|
|
@@ -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:411`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
|
|
126
|
+
boot ; `onHttpRequest` (`http-kernel.ts:1093`) 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
|
|
@@ -173,7 +173,7 @@ export default defineConfig(() => ({
|
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
Les clés sont **typées et auto-complétées** : le module augmente le registre `NodefonyModuleConfig`
|
|
176
|
-
du core (`index.ts:
|
|
176
|
+
du core (`index.ts:37`), donc `use("@nodefony/security", …)` propose les clés **et** les valeurs
|
|
177
177
|
d'enum (`referrerPolicy`, `coop`, `corp`…). Une valeur hors enum casse le boot, pas la production.
|
|
178
178
|
|
|
179
179
|
### 2. Ce qu'on observe
|
|
@@ -255,7 +255,7 @@ Choisir en cinq secondes — puis le détail dans les cartes.
|
|
|
255
255
|
**La menace** : n'importe quelle entrée non échappée (commentaire, nom d'utilisateur, paramètre
|
|
256
256
|
réfléchi) devient un vecteur d'exécution de code. Le CSP est le filet quand l'échappement a raté.
|
|
257
257
|
|
|
258
|
-
**Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts:
|
|
258
|
+
**Le défaut Nodefony** est délibérément « secure-but-usable » (`config.ts:276`) : seul `script-src`
|
|
259
259
|
est **strict** — `'self'` plus le nonce de la requête, ce qui est la vraie défense XSS. Le reste
|
|
260
260
|
couvre les besoins réels d'une app moderne (CSS-in-JS via `style-src 'unsafe-inline'`, images
|
|
261
261
|
`data:`/`blob:`, workers, fetch/WS same-origin) et ajoute les durcissements gratuits :
|
|
@@ -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:1144`) — 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:1141`). 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:411`) : `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:1147`). 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
|
|
|
@@ -315,7 +315,7 @@ inclus.
|
|
|
315
315
|
**La menace** : la fuite d'URL. Chemins parlants, identifiants de session dans une query, jetons de
|
|
316
316
|
réinitialisation — tout part chez le site suivant via le `Referer`.
|
|
317
317
|
|
|
318
|
-
Défaut Nodefony : `no-referrer` (`security/nodefony/config/config.ts:
|
|
318
|
+
Défaut Nodefony : `no-referrer` (`security/nodefony/config/config.ts:316`), la valeur la plus stricte. La valeur est un
|
|
319
319
|
**enum W3C fermé** — huit valeurs validées au boot, donc pas de faute de frappe qui passerait en
|
|
320
320
|
silence (l'écriture libre `no-refferer` casserait la protection sans prévenir).
|
|
321
321
|
|
|
@@ -359,7 +359,7 @@ propres sous-domaines ; `cross-origin` ouvre — c'est ce qu'il faut sur une CDN
|
|
|
359
359
|
**La menace** : plusieurs origines partageant heap et processus, donc des canaux auxiliaires
|
|
360
360
|
mesurables.
|
|
361
361
|
|
|
362
|
-
Nodefony l'émet comme un **booléen de champ structuré** RFC
|
|
362
|
+
Nodefony l'émet comme un **booléen de champ structuré** RFC 9651 : la valeur est littéralement `?1`
|
|
363
363
|
(`securityHeaders.ts:75`). C'est une **demande**, pas une garantie — le navigateur décide.
|
|
364
364
|
|
|
365
365
|
### `Permissions-Policy` — coupe le micro par défaut
|
|
@@ -379,7 +379,7 @@ config security est la première source de confusion sur ce sujet.
|
|
|
379
379
|
|
|
380
380
|
### Couche applicative — `use("@nodefony/security", { headers })`
|
|
381
381
|
|
|
382
|
-
Dérivé du schéma Zod `headersSchema` (`config.ts:
|
|
382
|
+
Dérivé du schéma Zod `headersSchema` (`config.ts:256`).
|
|
383
383
|
|
|
384
384
|
<!-- prettier-ignore -->
|
|
385
385
|
| Option | Type | Défaut | Effet |
|
|
@@ -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:461`)
|
|
401
401
|
recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
|
|
402
402
|
|
|
403
403
|
| Option | Type | Défaut | Effet |
|
|
@@ -411,10 +411,10 @@ recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
|
|
|
411
411
|
|
|
412
412
|
> [!WARNING]
|
|
413
413
|
> Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
|
|
414
|
-
> (`config.ts:
|
|
414
|
+
> (`config.ts:263`, `config.ts:263`, `config.ts:263`, `config.ts:263`) mais **ne pilotent rien** : la couche
|
|
415
415
|
> applicative ne les lit pas (`securityHeaders.ts:6`), elles ne servent qu'à l'introspection
|
|
416
416
|
> affichée dans Studio. Pour changer réellement `X-Frame-Options`, c'est `securityHeaders.frameOptions`
|
|
417
|
-
> **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:
|
|
417
|
+
> **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:288`) : Nodefony n'émet aucun
|
|
418
418
|
> `X-Powered-By`, l'option est un no-op documenté.
|
|
419
419
|
|
|
420
420
|
## 🏗️ Le CSP en détail — deux régimes, trois façons de l'étendre
|
|
@@ -439,20 +439,20 @@ Le chemin complet, sans surprise :
|
|
|
439
439
|
|
|
440
440
|
1. **Au boot**, la chaîne CSP est **pré-découpée** autour de `{{nonce}}` (`securityHeaders.ts:58`).
|
|
441
441
|
Aucun parsing ni regex n'aura lieu pendant une requête.
|
|
442
|
-
2. **Par requête**, `Firewall.applySecurityHeaders()` (`firewall.ts:
|
|
442
|
+
2. **Par requête**, `Firewall.applySecurityHeaders()` (`firewall.ts:1152`) lit `context.cspNonce` —
|
|
443
443
|
ce qui **génère** le jeton à cet instant (`Context.ts:253`) — puis appelle
|
|
444
444
|
`SecurityHeaders.cspFor()` (`securityHeaders.ts:100`) : un seul `join`.
|
|
445
445
|
3. **Dans la vue**, le contrôleur relit `context.cspNonce`, qui est **mémoïsé** : l'en-tête et le
|
|
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:1666`). 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
|
|
|
@@ -476,9 +476,9 @@ directive absente est ajoutée en fin. La fonction est **pure et déterministe**
|
|
|
476
476
|
les tests fiables.
|
|
477
477
|
|
|
478
478
|
**Coût** : le merge d'un module est payé **une fois**, au (dés)enregistrement
|
|
479
|
-
(`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:
|
|
479
|
+
(`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1209`), 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
|
|
|
@@ -502,15 +502,15 @@ Trois propriétés à retenir :
|
|
|
502
502
|
|
|
503
503
|
- **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
|
|
504
504
|
`registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
|
|
505
|
-
- **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:
|
|
505
|
+
- **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1202`) retire le fragment et
|
|
506
506
|
reconstruit le CSP de base. C'est ce que fait `@nodefony/frontend` à l'arrêt du serveur Vite.
|
|
507
507
|
- **Idempotent** : la reconstruction repart **toujours** du `headers.csp` d'origine
|
|
508
|
-
(`firewall.ts:
|
|
508
|
+
(`firewall.ts:1212`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
|
|
509
509
|
enregistrements.
|
|
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:804`) — 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
|
|
@@ -519,12 +519,12 @@ en dev soit plus large qu'en production, où ce fragment n'existe pas.
|
|
|
519
519
|
| ------------------------------------ | -------------------------------- | ------------------------------------------------------------ |
|
|
520
520
|
| Politique de sécurité du contenu | W3C CSP Level 3 | `mergeCspFragments()` (`csp.ts:56`), directive non dupliquée |
|
|
521
521
|
| Nonce CSP (unicité, imprévisibilité) | W3C CSP Level 3 §6.7.4 | `Context.cspNonce` — 128 bits CSPRNG (`Context.ts:253`) |
|
|
522
|
-
| HSTS | RFC 6797 | posé sur TLS uniquement (`http-kernel.ts:
|
|
523
|
-
| Champ structuré booléen | RFC
|
|
524
|
-
| Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:
|
|
522
|
+
| HSTS | RFC 6797 | posé sur TLS uniquement (`http-kernel.ts:981`) |
|
|
523
|
+
| Champ structuré booléen | RFC 9651 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
|
|
524
|
+
| Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:273`) |
|
|
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:1141`) |
|
|
527
|
+
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:411`) |
|
|
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
|
-
l'enregistrement (`firewall.ts:
|
|
542
|
-
- **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:
|
|
541
|
+
l'enregistrement (`firewall.ts:1142`) ; 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:1131-1137`), 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:588`), 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
|
|
@@ -570,7 +570,7 @@ porte sa description, ce qui en fait la référence toujours à jour des défaut
|
|
|
570
570
|
| CSP contenant `'nonce-{{nonce}}'` littéral | `cspNonces` désactivé, placeholder laissé | Nodefony purge le résiduel (`securityHeaders.ts:63`) ; retirer le placeholder |
|
|
571
571
|
| Une extension `script-src` de module sans effet | Second en-tête / directive dupliquée (ignorée, W3C CSP3 §3) | Déclarer un fragment via `registerCspOrigins()`, jamais un `setHeader` |
|
|
572
572
|
| Assets tiers cassés après activation de l'isolation | `coep: "require-corp"` exige CORP/CORS sur **chaque** ressource | Retirer `coep` ou faire annoncer les ressources |
|
|
573
|
-
| HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts:
|
|
573
|
+
| HSTS absent en développement | Posé sur TLS uniquement (`http-kernel.ts:981`) | Comportement conforme RFC 6797 — vérifier sur le port HTTPS |
|
|
574
574
|
| Le CSP est plus large en dev qu'en prod | `@nodefony/frontend` déclare les origines Vite (`FrontendService.ts:695`) | Attendu : le fragment n'existe pas en production |
|
|
575
575
|
| Aucun en-tête applicatif | Module security absent ou `headers.enabled: false` | Le socle transport reste actif ; réactiver security pour CSP/Referrer/isolation |
|
|
576
576
|
| Domaine injoignable après activation de `preload` | Inscription à la liste pré-chargée, sortie très lente | Ne jamais activer sans plan HTTPS sur **tous** les sous-domaines |
|
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
|
|
|
@@ -59,9 +59,11 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
|
|
|
59
59
|
**J'ouvre un login à des humains** — navigateur, comptes, second facteur.
|
|
60
60
|
|
|
61
61
|
1. [Authenticators](authenticators.md) — la session BFF, et pourquoi le login est **déjà fourni**.
|
|
62
|
-
2. [
|
|
63
|
-
3. [
|
|
64
|
-
4. [
|
|
62
|
+
2. [Page de connexion](login-page.md) — la page servie à `/login`, et comment l'habiller à ton nom.
|
|
63
|
+
3. [OAuth2](oauth2.md) — « se connecter avec GitHub/Google » et le Shadow User.
|
|
64
|
+
4. [Keycloak](keycloak.md) — brancher l'annuaire de l'entreprise, du realm au premier login.
|
|
65
|
+
5. [WebAuthn / passkeys](webauthn.md) — se connecter sans mot de passe, résistant au phishing.
|
|
66
|
+
6. [TOTP](totp.md) — le second facteur classique, et l'élévation de privilège (step-up).
|
|
65
67
|
|
|
66
68
|
**J'audite avant une mise en production** — la passe qu'on regrette de ne pas avoir faite.
|
|
67
69
|
|
|
@@ -86,7 +88,9 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
|
|
|
86
88
|
| [CSRF](csrf.md) | requête authentifiée forgée par un site tiers | tu sers un front avec cookie de session |
|
|
87
89
|
| [CORS](cors.md) | lecture cross-origine de tes réponses | ton front est sur un autre domaine |
|
|
88
90
|
| [En-têtes](headers.md) | CSP, HSTS, COOP/COEP, Referrer-Policy | tu sers du HTML à un navigateur |
|
|
91
|
+
| [Page de connexion](login-page.md) | la page `/login` servie, habillable | des humains se connectent à ton application |
|
|
89
92
|
| [OAuth2](oauth2.md) | login social + provisionnement d'identité | « se connecter avec … » |
|
|
93
|
+
| [Keycloak](keycloak.md) | connexion par l'annuaire auto-hébergé | tes comptes vivent dans un realm Keycloak |
|
|
90
94
|
| [WebAuthn](webauthn.md) | passkeys, connexion résistante au phishing | tu veux supprimer les mots de passe |
|
|
91
95
|
| [TOTP](totp.md) | second facteur temporel + step-up | 2FA, ou re-preuve avant une action sensible |
|
|
92
96
|
| [Webhooks](webhooks.md) | notifier un tiers, signé et sans SSRF | un système externe doit réagir à tes événements |
|
|
@@ -98,11 +102,14 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
|
|
|
98
102
|
"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
103
|
"meta": "à lire en premier — toutes les autres briques s'y branchent" },
|
|
100
104
|
{ "icon": "🪪", "title": "authenticators", "href": "authenticators.md",
|
|
101
|
-
"desc": "Prouver qui appelle :
|
|
105
|
+
"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
106
|
"meta": "commence par « Ordre et modes » : les pièges de configuration sont là" },
|
|
103
107
|
{ "icon": "⚖️", "title": "authorization", "href": "authorization.md",
|
|
104
108
|
"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.",
|
|
105
109
|
"meta": "juste après les authenticators — authentifier sans autoriser ne protège rien" },
|
|
110
|
+
{ "icon": "🚪", "title": "page de connexion", "href": "login-page.md",
|
|
111
|
+
"desc": "La page servie à /login : identifiant, mot de passe, second facteur, boutons des fournisseurs, thèmes clair et sombre. Textes, panneau et couleurs se règlent par la configuration et une feuille de style — sans copier son balisage.",
|
|
112
|
+
"meta": "le piège : une couleur qui dépend du thème se redéfinit pour les DEUX thèmes" },
|
|
106
113
|
{ "icon": "🎫", "title": "tokens", "href": "tokens.md",
|
|
107
114
|
"desc": "L'identité matérialisée : émission, keystore, rotation, révocation — et le choix structurant du framework, session opaque côté serveur pour le web, JWT pour les API.",
|
|
108
115
|
"meta": "et pourquoi ce n'est pas « full stateless »" },
|
|
@@ -155,6 +162,7 @@ sans ouvrir de port (profil console) :
|
|
|
155
162
|
| `nodefony security:user:delete <id>` | supprime, après confirmation ; refuse le dernier administrateur |
|
|
156
163
|
| `nodefony security:secrets [--write]` | engendre les clés attendues et guide leur câblage |
|
|
157
164
|
| `nodefony security:token` | émet un jeton d'accès pour la porte MCP |
|
|
165
|
+
| `nodefony security:keycloak:realm [--write\|--check]` | écrit le realm Keycloak déduit de la configuration ([Keycloak](keycloak.md)) |
|
|
158
166
|
|
|
159
167
|
En terminal, le mot de passe est **demandé masqué** et confirmé ; pour un script, `--password <pwd>`
|
|
160
168
|
l'accepte en argument — au prix de l'historique du shell, que la commande rappelle.
|
|
@@ -167,7 +175,7 @@ un mot de passe parce qu'il est perdu ou compromis.
|
|
|
167
175
|
## ⚙️ Configuration
|
|
168
176
|
|
|
169
177
|
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`, `
|
|
178
|
+
au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `rateLimit`
|
|
171
179
|
(backoff NIST), puis un bloc par brique (`jwt`, `apiKeys`, `totp`, `webhooks`, `audit`, `passkeys`,
|
|
172
180
|
`oauth2`). **Chaque page de brique détaille son bloc**, avec une table dérivée du schéma.
|
|
173
181
|
|
|
@@ -176,7 +184,7 @@ au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `l
|
|
|
176
184
|
<!-- prettier-ignore -->
|
|
177
185
|
| Domaine | Normes |
|
|
178
186
|
| --- | --- |
|
|
179
|
-
| Auth / challenge (401) | RFC
|
|
187
|
+
| Auth / challenge (401) | RFC 9110 §11 |
|
|
180
188
|
| JWT | RFC 7519, 8725 (BCP) |
|
|
181
189
|
| OAuth 2 | RFC 9700 (BCP), 8707, 8693, 9449 (DPoP) |
|
|
182
190
|
| Passkeys | W3C WebAuthn L3, CTAP2 |
|