@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/authenticators.md
CHANGED
|
@@ -31,7 +31,7 @@ source: "src/packages/@nodefony/security/docs/authenticators.md"
|
|
|
31
31
|
> Un **authenticator** répond à une seule question : _« qui es-tu, et peux-tu le prouver ? »_. Il ne
|
|
32
32
|
> décide **pas** des droits (ça, c'est l'autorisation / les voters) — il établit une **identité**.
|
|
33
33
|
> Le firewall enchaîne les authenticators déclarés par une zone jusqu'à obtenir une preuve valide,
|
|
34
|
-
> sinon il ferme en 401 (Zero Trust). Nodefony en fournit **
|
|
34
|
+
> sinon il ferme en 401 (Zero Trust). Nodefony en fournit **sept** intégrés, tous ancrés ici sur le
|
|
35
35
|
> code (`src/packages/@nodefony/security/nodefony/src/authenticator/`).
|
|
36
36
|
|
|
37
37
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Authenticators**
|
|
@@ -90,7 +90,7 @@ classe d'attaque précise — détaillées brique par brique dans le catalogue :
|
|
|
90
90
|
|
|
91
91
|
### Le contrat `IAuthenticator`
|
|
92
92
|
|
|
93
|
-
Tout authenticator implémente le même cycle (`IAuthenticator.ts:
|
|
93
|
+
Tout authenticator implémente le même cycle (`IAuthenticator.ts:23`), ce qui rend le firewall
|
|
94
94
|
totalement agnostique de la stratégie :
|
|
95
95
|
|
|
96
96
|
| Méthode | Rôle |
|
|
@@ -100,23 +100,23 @@ totalement agnostique de la stratégie :
|
|
|
100
100
|
| `authenticate(token)` | **Vérifie** (signature/hash/session), applique la **révocation**, **re-résout le sujet** — ou lève un 401. |
|
|
101
101
|
| `onSuccess(ctx,tok)` | Effet de bord au succès (poser l'identité en session, audit). |
|
|
102
102
|
| `onFailure(ctx,err)` | Slot d'audit (le 401 + challenge sont posés par le firewall). |
|
|
103
|
-
| `challenge()` | **Optionnel** (`IAuthenticator.ts:
|
|
103
|
+
| `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
|
|
104
104
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
107
107
|
Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
|
|
108
|
-
(`firewall.ts:
|
|
108
|
+
(`firewall.ts:435`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
|
-
- Les **
|
|
111
|
+
- Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
|
|
112
112
|
s'enregistrent à l'import du module via `registerAuthenticatorFactory()`
|
|
113
|
-
(`authenticatorRegistry.ts:
|
|
114
|
-
- Le
|
|
115
|
-
lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:
|
|
113
|
+
(`authenticatorRegistry.ts:73-145`) — donc toujours avant le boot.
|
|
114
|
+
- Le septième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
|
|
115
|
+
lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:279`).
|
|
116
116
|
- La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
|
|
117
117
|
`tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
|
|
118
118
|
- Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
|
|
119
|
-
(`firewall.ts:
|
|
119
|
+
(`firewall.ts:588`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
|
|
120
120
|
faute de frappe.
|
|
121
121
|
|
|
122
122
|
## 🚀 Démarrage rapide
|
|
@@ -205,16 +205,17 @@ Requête par requête, qui répond :
|
|
|
205
205
|
| `Authorization: Bearer nf_…` | `apikey` | identifié — clé vérifiée au store, révocable |
|
|
206
206
|
| rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1200`) |
|
|
207
207
|
|
|
208
|
-
## 🔐 Les
|
|
208
|
+
## 🔐 Les authenticators intégrés
|
|
209
209
|
|
|
210
|
-
| Nom | Credential
|
|
211
|
-
| ------------------- |
|
|
212
|
-
| `anonymous` | (aucun)
|
|
213
|
-
| `userpassword` | `Authorization: Basic base64(id:mdp)`
|
|
214
|
-
| `session` | cookie de session (identifiant en blob)
|
|
215
|
-
| `jwt` | `Authorization: Bearer <a.b.c>`
|
|
216
|
-
| `
|
|
217
|
-
| `
|
|
210
|
+
| Nom | Credential | Vérité | Révocable | Pour… |
|
|
211
|
+
| ------------------- | --------------------------------------------------- | ---------- | :------------: | ------------------------------------------ |
|
|
212
|
+
| `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
|
|
213
|
+
| `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
|
|
214
|
+
| `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
|
|
215
|
+
| `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
|
|
216
|
+
| `external-jwt` | `Authorization: Bearer <a.b.c>` d'un émetteur TIERS | émetteur | via l'émetteur | jetons d'un serveur d'autorisation externe |
|
|
217
|
+
| `apikey` | `Authorization: Bearer nf_…` | serveur | immédiate | API/CI/scripts d'un user |
|
|
218
|
+
| `firewall-realtime` | identité déjà résolue au handshake (ALS) | serveur | 1 fenêtre | le **WebSocket** de toute identité |
|
|
218
219
|
|
|
219
220
|
### `anonymous` — accepter explicitement l'anonymat
|
|
220
221
|
|
|
@@ -227,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
227
228
|
si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
|
|
228
229
|
« canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
|
|
229
230
|
- Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
|
|
230
|
-
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:
|
|
231
|
+
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:850`).
|
|
231
232
|
- **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
|
|
232
233
|
défaut.
|
|
233
234
|
|
|
@@ -235,7 +236,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
235
236
|
|
|
236
237
|
Schéma **HTTP Basic** (RFC 7617) : `Authorization: Basic base64(id:mdp)`, charset UTF-8, scheme
|
|
237
238
|
case-insensitive (`UserPasswordAuthenticator.ts:11`) ; `createToken()` split au **premier** `:` —
|
|
238
|
-
le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:
|
|
239
|
+
le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
|
|
239
240
|
|
|
240
241
|
- **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
|
|
241
242
|
leurre anti-timing, re-hash transparent — l'authenticator ne voit que le verdict.
|
|
@@ -245,7 +246,7 @@ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:74`).
|
|
|
245
246
|
(`UserPasswordAuthenticator.ts:101-103`) — un identifiant bloqué ne coûte **aucun hash** → le
|
|
246
247
|
throttle protège aussi le serveur du **DoS argon2**. Échec compté, succès remis à zéro
|
|
247
248
|
(`UserPasswordAuthenticator.ts:111-114`). `ThrottledError` → **429 + `Retry-After`**
|
|
248
|
-
(`firewall.ts:
|
|
249
|
+
(`firewall.ts:780`).
|
|
249
250
|
- **Le throttler est PARTAGÉ** avec le login JSON du BFF — même `loginThrottler` du container : un
|
|
250
251
|
attaquant ne contourne pas le backoff en changeant de porte (`authenticatorRegistry.ts:75-79`).
|
|
251
252
|
- Challenge : `Basic realm="nodefony", charset="UTF-8"` (`UserPasswordAuthenticator.ts:130`).
|
|
@@ -258,7 +259,7 @@ Après le login, chaque requête web prouve son identité par la **session serve
|
|
|
258
259
|
Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
259
260
|
|
|
260
261
|
- **N'ouvre jamais la session lui-même** : `supports()` exige une session **déjà reprise** porteuse
|
|
261
|
-
d'un utilisateur (`SessionAuthenticator.ts:
|
|
262
|
+
d'un utilisateur (`SessionAuthenticator.ts:75-78`) — le pipeline http démarre la session _avant_
|
|
262
263
|
le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
|
|
263
264
|
- **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
|
|
264
265
|
(`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
|
|
@@ -272,21 +273,32 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
|
272
273
|
|
|
273
274
|
Réservé aux **API service↔service / agents** (le web reste sur la session). Vérifie un access token
|
|
274
275
|
**EdDSA** signé par le keystore du serveur ; `supports()` ne réclame que la structure compacte
|
|
275
|
-
`a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:
|
|
276
|
+
`a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`). Les défenses **dures** du JWT BCP, toutes
|
|
276
277
|
prouvées en test :
|
|
277
278
|
|
|
278
279
|
| Défense | Comment | Attaque fermée |
|
|
279
280
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
|
280
281
|
| **Allowlist d'algorithmes** | `algorithms: ["EdDSA"]` — jamais l'algo de l'en-tête du token (`JwtAuthenticator.ts:120`) | `alg=none`, algorithm confusion (§3.1) |
|
|
281
|
-
| **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:
|
|
282
|
-
| **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:
|
|
283
|
-
| **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:
|
|
284
|
-
| **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:
|
|
282
|
+
| **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:174`) | injection de clé / SSRF (§3.5) |
|
|
283
|
+
| **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:119-124`) | refresh présenté comme access, token d'un autre service (§3.8-3.9) |
|
|
284
|
+
| **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:138-148`) | jeton auto-porté volé, logout global |
|
|
285
|
+
| **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:190-203`) | compte banni encore « valide » via son token (§3.10) |
|
|
285
286
|
|
|
286
|
-
Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:
|
|
287
|
+
Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:25`) : la cause fine
|
|
287
288
|
(expiré, `aud`, signature, sujet banni) part en **audit**, jamais au client — anti-oracle. Le token
|
|
288
|
-
promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:
|
|
289
|
-
importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:
|
|
289
|
+
promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:178-188`). `jose` est
|
|
290
|
+
importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:112`).
|
|
291
|
+
|
|
292
|
+
Les six premiers se déclarent dans une zone (registre `authenticatorRegistry.ts:73`) ;
|
|
293
|
+
`firewall-realtime` ne se déclare pas — le firewall le câble lui-même au handshake WebSocket.
|
|
294
|
+
|
|
295
|
+
### `external-jwt` — un jeton émis par un serveur d'autorisation TIERS
|
|
296
|
+
|
|
297
|
+
Là où `jwt` ne vérifie que les jetons signés par Nodefony, `external-jwt` accepte un jeton d'un
|
|
298
|
+
émetteur de confiance déclaré (`resourceServer.issuers`), vérifié contre ses clés publiques, et
|
|
299
|
+
rattache son sujet à un compte local. `jwt` et `external-jwt` s'aiguillent par l'émetteur (`iss`) :
|
|
300
|
+
leur ordre dans la zone ne décide de rien. Tout le détail — audience, espace de noms du sujet, panne
|
|
301
|
+
de l'émetteur en 503 — est dans **[Jetons d'un émetteur tiers](external-jwt.md)**.
|
|
290
302
|
|
|
291
303
|
### `apikey` — PAT opaque révocable
|
|
292
304
|
|
|
@@ -294,17 +306,17 @@ Clé API personnelle en `Authorization: Bearer nf_…` (préfixe `apiKeys.prefix
|
|
|
294
306
|
Contrairement au JWT (auto-porté), un PAT est un **bearer opaque** dont la vérité vit **côté
|
|
295
307
|
serveur** (`ITokenStore`) → **révocable immédiatement**. Défenses :
|
|
296
308
|
|
|
297
|
-
- **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:
|
|
309
|
+
- **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:103-106`)
|
|
298
310
|
→ une valeur malformée n'atteint jamais la base (**anti-DoS**).
|
|
299
|
-
- **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:
|
|
311
|
+
- **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:109`) — le secret n'existe
|
|
300
312
|
**nulle part au repos**.
|
|
301
|
-
- **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:
|
|
302
|
-
**ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:
|
|
303
|
-
- **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:
|
|
313
|
+
- **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:113-116`) +
|
|
314
|
+
**ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:121-122`).
|
|
315
|
+
- **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:127`).
|
|
304
316
|
- **`lastUsedAt` throttlé** : aucune écriture sur le hot path tant que la fenêtre
|
|
305
|
-
`apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:
|
|
317
|
+
`apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:136-143`).
|
|
306
318
|
|
|
307
|
-
Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:
|
|
319
|
+
Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:146-149`).
|
|
308
320
|
|
|
309
321
|
### `firewall-realtime` — la promotion, en WebSocket, de l'identité déjà posée
|
|
310
322
|
|
|
@@ -323,7 +335,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
323
335
|
- **Perf : il ne relit pas la base.** `authenticate()` réutilise l'identité de l'ALS
|
|
324
336
|
(`FirewallRealtimeAuthenticator.ts:91`) au lieu de refaire deux lectures base par connexion —
|
|
325
337
|
un coût évitable sur le différenciateur temps réel.
|
|
326
|
-
- **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:
|
|
338
|
+
- **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:279`) pour toute zone
|
|
327
339
|
protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:289`).
|
|
328
340
|
- **Le mode de preuve suit le jeton du firewall**, il n'est pas deviné : absent (zone historique),
|
|
329
341
|
on retombe sur le mode le plus strict, la session (`FirewallRealtimeAuthenticator.ts:101-103`).
|
|
@@ -333,7 +345,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
333
345
|
`buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
|
|
334
346
|
lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:227`). En mode jeton porteur, la
|
|
335
347
|
preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
|
|
336
|
-
(`FirewallRealtimeAuthenticator.ts:
|
|
348
|
+
(`FirewallRealtimeAuthenticator.ts:179`).
|
|
337
349
|
|
|
338
350
|
> [!NOTE]
|
|
339
351
|
> **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
|
|
@@ -344,7 +356,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
|
|
|
344
356
|
## ⚙️ Composer une zone — ordre, mode, cohabitation
|
|
345
357
|
|
|
346
358
|
La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
|
|
347
|
-
(`firewall.ts:
|
|
359
|
+
(`firewall.ts:1128`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
|
|
348
360
|
|
|
349
361
|
### Situation 1 — humains ET machines sur la même API (`first`)
|
|
350
362
|
|
|
@@ -377,14 +389,14 @@ authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le mon
|
|
|
377
389
|
### Situation 3 — empiler les preuves (`all`)
|
|
378
390
|
|
|
379
391
|
En mode `all`, **chaque** maillon est obligatoire : `supports()` faux = 401 immédiat
|
|
380
|
-
(`firewall.ts:
|
|
392
|
+
(`firewall.ts:1144-1149`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1198-1199`) —
|
|
381
393
|
utile pour exiger une preuve de canal (mTLS) **et** une identité, ou un « sudo mode » session +
|
|
382
394
|
re-saisie du mot de passe. Scénario complet côté zones : [firewall](./firewall.md).
|
|
383
395
|
|
|
384
396
|
### Cohabitation JWT + clé API dans une même zone
|
|
385
397
|
|
|
386
398
|
Les deux sont des `Bearer`, mais Nodefony les **discrimine sur la forme** — un JWT a la structure
|
|
387
|
-
compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:
|
|
399
|
+
compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`), un PAT porte le préfixe `nf_` sans
|
|
388
400
|
point (`looksLikeApiKey`, `ApiKeyAuthenticator.ts:72`). Chaque `supports()` ne réclame que _son_
|
|
389
401
|
format → aucun conflit, aucune double vérification.
|
|
390
402
|
|
|
@@ -397,7 +409,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
|
|
|
397
409
|
Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
|
|
398
410
|
invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
|
|
399
411
|
client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
|
|
400
|
-
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:
|
|
412
|
+
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
|
|
401
413
|
|
|
402
414
|
## 🧩 Ajouter un authenticator maison
|
|
403
415
|
|
|
@@ -423,13 +435,13 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
423
435
|
<!-- prettier-ignore -->
|
|
424
436
|
| Domaine | Norme | Ancrage |
|
|
425
437
|
| --- | --- | --- |
|
|
426
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
438
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
|
|
427
439
|
| Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
|
|
428
440
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
429
441
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
|
430
442
|
| Backoff de login | NIST SP 800-63B | `#throttler.check()` avant le verifier (`UserPasswordAuthenticator.ts:101-103`) |
|
|
431
|
-
| Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:
|
|
432
|
-
| Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:
|
|
443
|
+
| Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:780`) |
|
|
444
|
+
| Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:25`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
|
|
433
445
|
|
|
434
446
|
## ⚡ Performance & mémoire
|
|
435
447
|
|
|
@@ -437,7 +449,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
437
449
|
(le chemin chaud/froid vit dans le [firewall](./firewall.md)).
|
|
438
450
|
- Résolutions **lazy** : le verifier `#resolveVerifier` est mémoïsé au premier login
|
|
439
451
|
(`UserPasswordAuthenticator.ts:105`) ; keystore, `tokenStore` et `users` sont résolus du
|
|
440
|
-
container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:
|
|
452
|
+
container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:112`).
|
|
441
453
|
- `anonymous` : singleton gelé `anonymousUser`, zéro allocation d'utilisateur
|
|
442
454
|
(`AnonymousToken.ts:9`).
|
|
443
455
|
- `apikey` : écriture `lastUsedAt` **coalescée** — pas une écriture par requête
|
|
@@ -451,12 +463,12 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
451
463
|
<!-- prettier-ignore -->
|
|
452
464
|
| Symptôme | Cause (dans le code) | Correction |
|
|
453
465
|
| --- | --- | --- |
|
|
454
|
-
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:
|
|
466
|
+
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:850`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
|
|
455
467
|
| 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
|
|
456
468
|
| JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
|
|
457
469
|
| Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
|
|
458
470
|
| Révocation WS pas immédiate | Identité figée au handshake — asymétrie assumée du `FirewallRealtimeAuthenticator` (`FirewallRealtimeAuthenticator.ts:51-55`) | Attendre la fenêtre de re-validation, ou utiliser JWT + canal révocation |
|
|
459
|
-
| Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:
|
|
471
|
+
| Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:613`) | Configurer `rateLimit` pour poser le throttler |
|
|
460
472
|
|
|
461
473
|
## 🧪 Tests & couverture
|
|
462
474
|
|
package/docs/authorization.md
CHANGED
|
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
331
331
|
|
|
332
332
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
333
333
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
334
|
-
(`Resolver.ts:
|
|
334
|
+
(`Resolver.ts:700-715`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
335
335
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
336
336
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
337
337
|
|
|
@@ -360,25 +360,25 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
|
|
|
360
360
|
|
|
361
361
|
| Déclaration | Sémantique |
|
|
362
362
|
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
363
|
-
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:
|
|
364
|
-
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:
|
|
365
|
-
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:
|
|
366
|
-
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:
|
|
367
|
-
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:
|
|
368
|
-
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:
|
|
369
|
-
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:
|
|
370
|
-
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:
|
|
363
|
+
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
|
|
364
|
+
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
|
|
365
|
+
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
|
|
366
|
+
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
|
|
367
|
+
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:789`) |
|
|
368
|
+
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
|
|
369
|
+
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
|
|
370
|
+
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
|
|
371
371
|
|
|
372
372
|
La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
|
|
373
|
-
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:
|
|
373
|
+
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:406-408`). Le
|
|
374
374
|
même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
|
|
375
375
|
transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
|
|
376
|
-
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:
|
|
376
|
+
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:719`).
|
|
377
377
|
|
|
378
378
|
> [!IMPORTANT]
|
|
379
379
|
> **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
|
|
380
380
|
> chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
|
|
381
|
-
> (`Resolver.ts:
|
|
381
|
+
> (`Resolver.ts:678-688`). Une route gardée doit être couverte par une zone — voir
|
|
382
382
|
> [firewall](./firewall.md).
|
|
383
383
|
|
|
384
384
|
## 🧑⚖️ Les voters intégrés — deux axes, un même jury
|
|
@@ -399,7 +399,7 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
|
|
|
399
399
|
default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
|
|
400
400
|
(`@IsGranted(["A","B"])`) possible.
|
|
401
401
|
- La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
|
|
402
|
-
(`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:
|
|
402
|
+
(`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:209`).
|
|
403
403
|
- Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
|
|
404
404
|
|
|
405
405
|
#### Deux échelles de rôles — plateforme et organisation
|
|
@@ -511,14 +511,14 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
511
511
|
|
|
512
512
|
## 🔌 HTTP et WebSocket — une garde, N transports
|
|
513
513
|
|
|
514
|
-
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:
|
|
514
|
+
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:372`) est le point unique
|
|
515
515
|
d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
|
|
516
516
|
`@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
|
|
517
517
|
`ws-isgranted-jwt`).
|
|
518
518
|
- **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
|
|
519
519
|
et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
|
|
520
520
|
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
|
|
521
|
-
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:
|
|
521
|
+
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:290`) délègue à `Firewall.hasRole()`
|
|
522
522
|
(`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
|
|
523
523
|
|
|
524
524
|
## 📜 Normes appliquées
|
|
@@ -529,7 +529,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
529
529
|
| Modèle de rôles | RBAC **hiérarchique**, au sens des composantes _Core_ et _Hierarchical_ d'ANSI INCITS 359 — sans séparation des devoirs | `RoleHierarchyWalker.reachableRoles()` |
|
|
530
530
|
| Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
|
|
531
531
|
| Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
|
|
532
|
-
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:
|
|
532
|
+
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:678-688`) |
|
|
533
533
|
| Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
|
|
534
534
|
|
|
535
535
|
> Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
|
|
@@ -541,10 +541,10 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
541
541
|
## ⚡ Performance & mémoire
|
|
542
542
|
|
|
543
543
|
- **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
|
|
544
|
-
alloc (`Resolver.ts:
|
|
545
|
-
requêtes (`SecurityRequirement`, `routerDecorators.ts:
|
|
546
|
-
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:
|
|
547
|
-
instanciés **une seule fois** au boot (`authorization.ts:
|
|
544
|
+
alloc (`Resolver.ts:406`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
545
|
+
requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
|
|
546
|
+
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
|
|
547
|
+
instanciés **une seule fois** au boot (`authorization.ts:70`).
|
|
548
548
|
- **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
|
|
549
549
|
(`RoleHierarchyWalker.ts:23-30`).
|
|
550
550
|
- **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
|
|
@@ -560,7 +560,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
560
560
|
| Symptôme | Cause (dans le code) | Correction |
|
|
561
561
|
| -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
562
562
|
| Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
|
|
563
|
-
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:
|
|
563
|
+
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:678-688`) | Couvrir la route par une zone firewall |
|
|
564
564
|
| Un voter métier bloque tout | Il renvoie `DENY` au lieu d'`ABSTAIN` quand il ne s'applique pas | Renvoyer `ABSTAIN` hors de son domaine |
|
|
565
565
|
| Un `DENY` n'a pas bloqué | Attributs d'une clause = jurys **séparés** (OR) — un autre attribut a accordé | Porter l'interdit en clause AND (empiler les `@IsGranted`) |
|
|
566
566
|
| Clé API accède à une action non prévue | Type de jeton traité comme humain (allowlist) | Vérifier que le type n'est pas dans `NON_SCOPABLE_TOKEN_TYPES` |
|
package/docs/cors.md
CHANGED
|
@@ -103,13 +103,13 @@ Trois conséquences que beaucoup de développeurs découvrent trop tard :
|
|
|
103
103
|
`Cors` (`cors.ts:33`) est une classe **pure et synchrone** : elle ne touche ni au réseau, ni à la
|
|
104
104
|
requête — elle prend une origine et renvoie la table des en-têtes à poser, ou `null`. Elle est
|
|
105
105
|
instanciée **une seule fois au boot** par le firewall, si et seulement si la section est activée
|
|
106
|
-
(`firewall.ts:
|
|
106
|
+
(`firewall.ts:239-240`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
|
|
107
107
|
requête se réduit à une lecture de `Set` (voir Performance).
|
|
108
108
|
|
|
109
109
|
Trois invariants de sécurité, tenus par construction :
|
|
110
110
|
|
|
111
111
|
- **Jamais `*` avec credentials.** La combinaison est **rejetée au boot** par un `refine` Zod
|
|
112
|
-
(`config.ts:
|
|
112
|
+
(`config.ts:156-159`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
|
|
113
113
|
instanciée à la main avec cette combinaison, `Cors.#allowOrigin()` **reflète l'origine** au lieu
|
|
114
114
|
d'émettre `*` (`cors.ts:58`).
|
|
115
115
|
- **Reflet d'origine ⇒ `Vary: Origin`.** Dès que la valeur `Allow-Origin` n'est pas `*`, la politique
|
|
@@ -204,8 +204,8 @@ curl -si http://localhost:5151/api/articles -H 'Origin: https://evil.com'
|
|
|
204
204
|
|
|
205
205
|
## ⚙️ Configuration et mises en situation
|
|
206
206
|
|
|
207
|
-
La section `cors` de la config du module (`corsSchema`, `config.ts:
|
|
208
|
-
`config.ts:
|
|
207
|
+
La section `cors` de la config du module (`corsSchema`, `config.ts:129` ; branchée à la racine en
|
|
208
|
+
`config.ts:1137`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
|
|
209
209
|
|
|
210
210
|
<!-- prettier-ignore -->
|
|
211
211
|
| Option | Type | Défaut | Effet |
|
|
@@ -242,7 +242,7 @@ cors: {
|
|
|
242
242
|
| `Origin: https://app.example.com:8443` | rien — le **port** fait partie de l'origine, match exact |
|
|
243
243
|
| `Origin: https://sub.app.example.com` | rien — un sous-domaine n'est **pas** couvert par le parent |
|
|
244
244
|
| `Origin: http://app.example.com` | rien — le **scheme** fait partie de l'origine (downgrade refusé) |
|
|
245
|
-
| aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:
|
|
245
|
+
| aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:1013`) |
|
|
246
246
|
|
|
247
247
|
### Situation 2 — une API publique en lecture seule (le joker `*`)
|
|
248
248
|
|
|
@@ -273,7 +273,7 @@ cors: { origins: ["https://app.example.com", "https://admin.example.com"], crede
|
|
|
273
273
|
|
|
274
274
|
> [!WARNING]
|
|
275
275
|
> `origins: ["*"]` **avec** `credentials: true` est rejeté par le schéma Zod au démarrage
|
|
276
|
-
> (`config.ts:
|
|
276
|
+
> (`config.ts:156-159`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
|
|
277
277
|
> combinaison, si un serveur la contournait en reflétant chaque origine, laisserait **tout site du
|
|
278
278
|
> web** lire les réponses authentifiées de tes utilisateurs. Ne « corrige » jamais cette erreur en
|
|
279
279
|
> reflétant l'origine reçue sans la valider.
|
|
@@ -322,23 +322,23 @@ sequenceDiagram
|
|
|
322
322
|
R-->>B: 200 + Access-Control-*
|
|
323
323
|
```
|
|
324
324
|
|
|
325
|
-
`Firewall.handleCors()` (`firewall.ts:
|
|
326
|
-
(`http-kernel.ts:
|
|
325
|
+
`Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
+
(`http-kernel.ts:1333`), à la ligne `http-kernel.ts:1333` — **avant le routing**. La raison est
|
|
327
327
|
concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
|
|
328
328
|
router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
|
|
329
329
|
credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
|
|
330
330
|
|
|
331
|
-
Quatre sorties en no-op, dans cet ordre (`firewall.ts:
|
|
331
|
+
Quatre sorties en no-op, dans cet ordre (`firewall.ts:1007`) :
|
|
332
332
|
|
|
333
333
|
1. CORS désactivé ⇒ `#cors` est `null`, retour immédiat ;
|
|
334
|
-
2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:
|
|
334
|
+
2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:1013`) ;
|
|
335
335
|
3. la réponse n'expose pas `setHeader` ⇒ c'est un **WebSocket**, il n'y a pas d'en-tête HTTP à poser
|
|
336
|
-
(`firewall.ts:
|
|
336
|
+
(`firewall.ts:83`) ;
|
|
337
337
|
4. origine hors allowlist ⇒ la table est `null`, aucun en-tête n'est posé — mais un preflight reste
|
|
338
|
-
court-circuité en 204 (`firewall.ts:
|
|
338
|
+
court-circuité en 204 (`firewall.ts:1032`).
|
|
339
339
|
|
|
340
340
|
**La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
|
|
341
|
-
`Access-Control-Request-Method` (`firewall.ts:
|
|
341
|
+
`Access-Control-Request-Method` (`firewall.ts:1018-1020`). Un `OPTIONS` nu — celui d'un client qui interroge
|
|
342
342
|
les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
|
|
343
343
|
|
|
344
344
|
### Ce que chaque moment pose
|
|
@@ -354,10 +354,10 @@ Deux nuances utiles :
|
|
|
354
354
|
`Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
|
|
355
355
|
point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
|
|
356
356
|
- **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
|
|
357
|
-
(`http-kernel.ts:
|
|
357
|
+
(`http-kernel.ts:1382`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
|
|
358
358
|
tes routes.
|
|
359
359
|
|
|
360
|
-
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:
|
|
360
|
+
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
|
|
361
361
|
signifie « je suis un preflight, réponds et arrête-toi ».
|
|
362
362
|
|
|
363
363
|
## 🛡️ CORS, CSRF et en-têtes de sécurité — qui protège quoi
|
|
@@ -371,12 +371,12 @@ Trois briques voisines, souvent confondues. Une seule ligne chacune :
|
|
|
371
371
|
| **[En-têtes](./headers.md)** (CSP, COOP) | ce que la **page** a le droit de faire | dans le navigateur | XSS, injection, fenêtres croisées |
|
|
372
372
|
|
|
373
373
|
Les deux premières se parlent. Au boot, la liste des origines de confiance CSRF est l'**union** de
|
|
374
|
-
`csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:
|
|
374
|
+
`csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:605`) : ce que tu autorises explicitement en
|
|
375
375
|
CORS ne peut pas être, au même instant, traité comme une tentative CSRF.
|
|
376
376
|
|
|
377
377
|
L'inverse n'est pas vrai, et c'est délibéré : `csrf.trustedOrigins` déclare un **alias de domaine**
|
|
378
378
|
légitime de ton app (une façade multi-domaine) sans pour autant exposer tes réponses au JS de cette
|
|
379
|
-
origine (`config.ts:
|
|
379
|
+
origine (`config.ts:188`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
|
|
380
380
|
`csrf.trustedOrigins`.
|
|
381
381
|
|
|
382
382
|
## 🔌 Et le WebSocket ?
|
|
@@ -387,7 +387,7 @@ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un con
|
|
|
387
387
|
(`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
|
|
388
388
|
|
|
389
389
|
La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
|
|
390
|
-
(`http-kernel.ts:
|
|
390
|
+
(`http-kernel.ts:621`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
|
|
391
391
|
si elle est refusée. Sa doctrine :
|
|
392
392
|
|
|
393
393
|
- **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
|
|
@@ -405,22 +405,22 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
|
|
|
405
405
|
| Sujet | Norme | Comment le code s'y conforme |
|
|
406
406
|
| --------------------------------- | --------------------------- | --------------------------------------------------------------------------- |
|
|
407
407
|
| Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts:33`) — preflight vs requête réelle séparés |
|
|
408
|
-
| Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:
|
|
409
|
-
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:
|
|
408
|
+
| Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:1032`) |
|
|
409
|
+
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
|
|
410
410
|
| Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
|
|
411
411
|
| Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
|
|
412
|
-
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:
|
|
412
|
+
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:621`) |
|
|
413
413
|
|
|
414
414
|
## ⚡ Performance & mémoire
|
|
415
415
|
|
|
416
416
|
La politique est **précalculée au boot** : les listes `methods`, `allowedHeaders`, `exposedHeaders` et
|
|
417
|
-
`maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42`), jamais par requête. Il
|
|
417
|
+
`maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42-49`), jamais par requête. Il
|
|
418
418
|
ne reste à l'exécution qu'un `Set.has()` sur l'origine.
|
|
419
419
|
|
|
420
420
|
Le coût par requête est donc :
|
|
421
421
|
|
|
422
|
-
- **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:
|
|
423
|
-
- **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:
|
|
422
|
+
- **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:1013`) ;
|
|
423
|
+
- **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:83`) ;
|
|
424
424
|
- **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts:166`) ;
|
|
425
425
|
- **une petite table d'en-têtes** allouée uniquement pour une requête cross-origin autorisée. Une
|
|
426
426
|
origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts:74`).
|
|
@@ -428,12 +428,12 @@ Le coût par requête est donc :
|
|
|
428
428
|
## 📡 Observabilité — Studio
|
|
429
429
|
|
|
430
430
|
La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
|
|
431
|
-
`Firewall.describe()` (`firewall.ts:
|
|
432
|
-
(`firewall.ts:
|
|
433
|
-
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:
|
|
431
|
+
`Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
|
+
(`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
|
+
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
|
|
434
434
|
cette surface.
|
|
435
435
|
|
|
436
|
-
- **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:
|
|
436
|
+
- **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:328`), protégé
|
|
437
437
|
`ROLE_NODEFONY_ADMIN`.
|
|
438
438
|
- **Écran** : console **Firewall** → section _Défenses_ (`FirewallDefenses`,
|
|
439
439
|
`FirewallDefenses.tsx:114`), carte CORS à côté des cartes CSRF, en-têtes et throttle.
|
|
@@ -447,7 +447,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
447
447
|
|
|
448
448
|
| Symptôme | Cause | Correction |
|
|
449
449
|
| --------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
450
|
-
| Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:
|
|
450
|
+
| Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:156-159`) | Lister les origines, ou passer `credentials:false` |
|
|
451
451
|
| Requête bloquée alors que l'origine « est » dans la liste | Match **exact** : port, scheme ou sous-domaine divergent | Écrire l'origine complète `scheme://host:port`, une entrée par variante |
|
|
452
452
|
| `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur | Normal — reproduire avec un `Origin` explicite (`curl -H 'Origin: …'`) |
|
|
453
453
|
| Cookie non envoyé malgré `credentials:true` | Opt-in serveur seul : le client n'a pas `credentials: "include"` | Activer les deux côtés (et un cookie `SameSite=None; Secure` en cross-site) |
|
|
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
455
455
|
| Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
|
|
456
456
|
| Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
|
|
457
457
|
| `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
|
|
458
|
-
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:
|
|
458
|
+
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:621`) — vérifier `allowedOrigins` |
|
|
459
459
|
| Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
|
|
460
460
|
|
|
461
461
|
## 🧪 Tests & couverture
|