@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.1
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-token.js +4 -4
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +6 -5
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +1 -1
- package/dist/nodefony/service/tokenService.js +30 -20
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +41 -33
- package/dist/nodefony/service/webhooks.js +55 -44
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- 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 +67 -65
- package/docs/audit.md +87 -82
- package/docs/authenticators.md +61 -49
- package/docs/authorization.md +21 -21
- package/docs/cors.md +30 -30
- package/docs/csrf.md +28 -28
- package/docs/external-jwt.md +21 -13
- package/docs/firewall.md +32 -32
- package/docs/headers.md +29 -29
- package/docs/index.md +3 -1
- package/docs/oauth2.md +61 -61
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +56 -56
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +85 -85
- package/docs/webhooks.md +50 -47
- package/package.json +19 -16
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:1524`) 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:1650-1655`.)
|
|
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:1142`) 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:156-186`) — 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:
|
|
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:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:486`). |
|
|
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
|
|
|
@@ -325,7 +325,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
325
325
|
| ------------------------------ | --------------------------------- | -------------------------------------- |
|
|
326
326
|
| Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
|
|
327
327
|
| Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
|
|
328
|
-
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:
|
|
328
|
+
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
|
|
329
329
|
| Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
|
|
330
330
|
| Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
|
|
331
331
|
| Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
|
|
@@ -339,14 +339,14 @@ 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:
|
|
349
|
-
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:
|
|
348
|
+
`Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
|
|
349
|
+
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:607`).
|
|
350
350
|
|
|
351
351
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
352
352
|
|
|
@@ -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.
|
|
@@ -44,16 +44,24 @@ d'accès » :
|
|
|
44
44
|
|
|
45
45
|
```ts
|
|
46
46
|
// nodefony.config.ts
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
import { defineConfig, use } from "nodefony";
|
|
48
|
+
|
|
49
|
+
export default defineConfig({
|
|
50
|
+
modules: [
|
|
51
|
+
"@nodefony/http",
|
|
52
|
+
"@nodefony/framework",
|
|
53
|
+
use("@nodefony/security", {
|
|
54
|
+
resourceServer: {
|
|
55
|
+
issuers: [
|
|
56
|
+
{
|
|
57
|
+
issuer: "https://auth.example.com/realms/mon-royaume",
|
|
58
|
+
algorithms: ["RS256"],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
53
61
|
},
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
});
|
|
57
65
|
```
|
|
58
66
|
|
|
59
67
|
Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
|
|
@@ -81,7 +89,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
|
|
|
81
89
|
Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
|
|
82
90
|
paire `(émetteur, sujet)`, jamais le sujet seul.
|
|
83
91
|
|
|
84
|
-
En mode `prefixed` (`config.ts:
|
|
92
|
+
En mode `prefixed` (`config.ts:656-660`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
|
|
85
93
|
pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
|
|
86
94
|
identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
|
|
87
95
|
l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
|
|
@@ -96,7 +104,7 @@ parce qu'il **est** cette application, ou parce que ses sujets sont déjà des i
|
|
|
96
104
|
Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
|
|
97
105
|
utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
|
|
98
106
|
- **`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:
|
|
107
|
+
avec les rôles d'`ephemeralRoles` (`config.ts:678`). C'est le mode de l'appelant **purement machine** : un agent, un
|
|
100
108
|
service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
|
|
101
109
|
— qui viennent du jeton, donc bornés par le serveur d'autorisation.
|
|
102
110
|
|
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:728`) rattache la requête à une **zone** via
|
|
56
|
+
`Firewall.matchPath()` (`firewall.ts:719`) ; `Firewall.handleSecurity()` (`firewall.ts:761`) 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:279`).
|
|
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,11 +373,11 @@ 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** —
|
|
380
|
-
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:
|
|
380
|
+
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:435`) : 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 ? »)
|
|
@@ -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:1216`) : le **code de fermeture** suffit.
|
|
521
521
|
|
|
522
522
|
## 🛡️ En-têtes de sécurité, CSRF, CORS
|
|
523
523
|
|
|
@@ -525,22 +525,22 @@ 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
|
-
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:
|
|
530
|
+
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:1014`).
|
|
531
531
|
|
|
532
532
|
## 📜 Normes appliquées
|
|
533
533
|
|
|
534
534
|
| Domaine | Norme | Ancrage |
|
|
535
535
|
| ---------------------- | --------------- | ------------------------------------------------------ |
|
|
536
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
536
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
|
|
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
|
|
package/docs/headers.md
CHANGED
|
@@ -122,8 +122,8 @@ 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:348`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
|
|
126
|
+
boot ; `onHttpRequest` (`http-kernel.ts:972`) ne fait plus que trois `setHeader`.
|
|
127
127
|
- Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
|
|
128
128
|
(`Context.ts:253`) ne génère ses 128 bits (`randomBytes(16)` en base64) 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.
|
|
@@ -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:33`), 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:238`) : 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,8 +275,8 @@ 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:
|
|
279
|
-
(`http/nodefony/config/config.ts:
|
|
278
|
+
(`http-kernel.ts:989`) — et configuré côté `@nodefony/http` avec `frameOptions`
|
|
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/`.
|
|
282
282
|
|
|
@@ -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:986`). 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,15 +297,15 @@ 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:348`) : `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:992`). 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
|
|
|
307
307
|
> [!CAUTION]
|
|
308
|
-
> `preload: true` (`http/nodefony/config/config.ts:
|
|
308
|
+
> `preload: true` (`http/nodefony/config/config.ts:107`) inscrit ton domaine dans la liste
|
|
309
309
|
> pré-chargée des navigateurs. C'est un **engagement quasi irréversible** : tout sous-domaine
|
|
310
310
|
> incapable de servir en HTTPS devient inaccessible, et la sortie de liste prend des mois. À ne
|
|
311
311
|
> jamais activer « pour voir ».
|
|
@@ -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:278`), 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
|
|
|
@@ -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:206`).
|
|
383
383
|
|
|
384
384
|
<!-- prettier-ignore -->
|
|
385
385
|
| Option | Type | Défaut | Effet |
|
|
@@ -396,8 +396,8 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:194`).
|
|
|
396
396
|
|
|
397
397
|
### Socle transport — `use("@nodefony/http", { securityHeaders })`
|
|
398
398
|
|
|
399
|
-
Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:
|
|
400
|
-
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:
|
|
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:398`)
|
|
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:257`, `config.ts:257`, `config.ts:257`, `config.ts:257`) 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:282`) : 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,7 +439,7 @@ 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:1045`) 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
|
|
@@ -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:1386`). 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,7 +476,7 @@ 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:1101`), jamais par requête. Le merge d'une route
|
|
480
480
|
`@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
|
|
481
481
|
`securityHeaders.ts:115`) ; le cas courant reste le simple `join`.
|
|
482
482
|
|
|
@@ -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:1099`) 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:1113`), 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:1034`) — 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:
|
|
522
|
+
| HSTS | RFC 6797 | posé sur TLS uniquement (`http-kernel.ts:969`) |
|
|
523
523
|
| Champ structuré booléen | RFC 8941 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
|
|
524
|
-
| Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:
|
|
524
|
+
| Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:267`) |
|
|
525
525
|
| Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
|
|
526
|
-
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:
|
|
527
|
-
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:
|
|
526
|
+
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:986`) |
|
|
527
|
+
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:348`) |
|
|
528
528
|
|
|
529
529
|
## ⚡ Performance & mémoire
|
|
530
530
|
|
|
@@ -538,7 +538,7 @@ Le coût est concentré au boot, par construction :
|
|
|
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:
|
|
541
|
+
l'enregistrement (`firewall.ts:1086`) ; celui d'une route ne coûte que sur les routes `@Csp`.
|
|
542
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
|
|
|
@@ -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:555`), 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:969`) | 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
|
@@ -126,7 +126,9 @@ flowchart LR
|
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
Le module consomme `@nodefony/user` (jamais l'inverse) et n'importe `@nodefony/http` / `framework`
|
|
129
|
-
qu'en **type-only
|
|
129
|
+
qu'en **type-only**, à une exception près : `schemaMismatchOf` (`webhooks.ts:49`), importé de
|
|
130
|
+
`@nodefony/http` pour reconnaître une base au schéma en retard avec la même règle que le cœur HTTP,
|
|
131
|
+
plutôt qu'avec une copie.
|
|
130
132
|
|
|
131
133
|
## 🧰 Surface publique
|
|
132
134
|
|