@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +1 -2
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +10 -8
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +1 -2
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +1 -2
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/nodefony/testing/index.js +108 -0
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/testing/index.d.ts +51 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +136 -124
- package/package.json +19 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/authorization.md
CHANGED
|
@@ -118,20 +118,20 @@ pour les cas où le rôle ne suffit pas.
|
|
|
118
118
|
|
|
119
119
|
## La vision Nodefony — un jury découplé et fail-closed
|
|
120
120
|
|
|
121
|
-
`Authorization.decide(token, attribute, subject?)` (`authorization.ts
|
|
122
|
-
`supports()` en place — zéro allocation par appel (`authorization.ts
|
|
121
|
+
`Authorization.decide(token, attribute, subject?)` (`authorization.ts#Authorization.decide`) itère les voters, teste
|
|
122
|
+
`supports()` en place — zéro allocation par appel (`authorization.ts#Authorization.decide`) — et applique la
|
|
123
123
|
stratégie ci-dessus. Points structurants :
|
|
124
124
|
|
|
125
125
|
- **Fail-closed sur erreur** : un voter qui `throw` (lookup DB down, bug) ne fait ni accorder
|
|
126
126
|
l'accès ni planter la requête en 500 — on **refuse** cette décision + log `ERROR`
|
|
127
|
-
(`authorization.ts
|
|
127
|
+
(`authorization.ts#Authorization.decide`). Même posture que le firewall sur une erreur interne.
|
|
128
128
|
- **Découverte par registre** : les voters sont instanciés **une fois au boot** par
|
|
129
|
-
`Authorization.#build()` (`authorization.ts
|
|
130
|
-
le service. Les builtins `role`/`scope` s'enregistrent à l'import (`voterRegistry.ts
|
|
129
|
+
`Authorization.#build()` (`authorization.ts#Authorization.#build`) depuis le registre — aucun nom en dur dans
|
|
130
|
+
le service. Les builtins `role`/`scope` s'enregistrent à l'import (`voterRegistry.ts`).
|
|
131
131
|
- **Transport-agnostique** : l'audit lit `getUserIdentifier()` (et non `getUser()`), commun au
|
|
132
|
-
token HTTP **et** au token WS `IRealtimeToken` (`authorization.ts
|
|
132
|
+
token HTTP **et** au token WS `IRealtimeToken` (`authorization.ts#Authorization.#auditDeny`).
|
|
133
133
|
- **Audit asymétrique** : tout **refus** est audité par `Authorization.#auditDeny()` — WARNING +
|
|
134
|
-
`recordAudit` (`authorization.ts
|
|
134
|
+
`recordAudit` (`authorization.ts#Authorization`) ; les octrois restent **muets** (volume, pas un
|
|
135
135
|
signal). Le refus porte sa raison : `veto` / `abstain` / `no-voter` / `error`.
|
|
136
136
|
|
|
137
137
|
## 🚀 Démarrage rapide
|
|
@@ -327,18 +327,18 @@ class LegalHoldVoter implements IAccessVoter {
|
|
|
327
327
|
| `DocumentVoter` GRANT (propriétaire) + `LegalHoldVoter` ABSTAIN | ✅ 200 |
|
|
328
328
|
| `DocumentVoter` GRANT + `LegalHoldVoter` **DENY** | ❌ 403 (`veto`) — le DENY bat le GRANT |
|
|
329
329
|
|
|
330
|
-
Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`authorization.ts
|
|
330
|
+
Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`authorization.ts#Authorization.decide`).
|
|
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#Resolver._enforceSecurity`) : 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
|
|
|
338
338
|
### Situation 3 — le silence ferme la porte (la typo devient un 403, pas une faille)
|
|
339
339
|
|
|
340
340
|
Tu déploies `@IsGranted("doc.edti")` (faute de frappe), ou tu as oublié d'enregistrer ton voter.
|
|
341
|
-
**Aucun voter compétent** → refus par défaut (`!granted`, `authorization.ts
|
|
341
|
+
**Aucun voter compétent** → refus par défaut (`!granted`, `authorization.ts#Authorization`) : la route répond 403
|
|
342
342
|
systématiquement, et le log nomme la cause :
|
|
343
343
|
|
|
344
344
|
```
|
|
@@ -356,29 +356,29 @@ se voit au premier test.
|
|
|
356
356
|
## 🧰 Déclarer l'exigence — `@IsGranted`, `@RequireScope`, `@Anonymous`
|
|
357
357
|
|
|
358
358
|
Les décorateurs n'écrivent **que des métadonnées** (0 import `@nodefony/security`, 0 cycle) ; le
|
|
359
|
-
moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts
|
|
360
|
-
|
|
361
|
-
| Déclaration | Sémantique
|
|
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
|
|
359
|
+
moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts#Resolver._callWithIdempotency`) :
|
|
360
|
+
|
|
361
|
+
| Déclaration | Sémantique |
|
|
362
|
+
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
363
|
+
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts#IsGranted`) |
|
|
364
|
+
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts#SecurityClause.anyOf`) |
|
|
365
|
+
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts#SecurityRequirement.clauses`) |
|
|
366
|
+
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts#computeSecurityRequirement`) |
|
|
367
|
+
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts#Resolver._resolveSubject`) |
|
|
368
|
+
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts#RequireScope`) |
|
|
369
|
+
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts#Anonymous`) |
|
|
370
|
+
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts#CurrentUser`) |
|
|
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#Resolver._enforceSecurity`). 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#Resolver._enforceSecurity`).
|
|
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#Resolver._callWithIdempotency`). 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
|
|
@@ -391,16 +391,16 @@ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:871`).
|
|
|
391
391
|
|
|
392
392
|
### `role` — l'axe « qui tu es »
|
|
393
393
|
|
|
394
|
-
Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts
|
|
394
|
+
Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts#RoleVoter.supports`) et vote :
|
|
395
395
|
|
|
396
396
|
- **`GRANT`** si l'utilisateur possède le rôle, hiérarchie résolue ; **`ABSTAIN` sinon — jamais
|
|
397
|
-
`DENY`** (`RoleVoter.vote()`, `RoleVoter.ts
|
|
397
|
+
`DENY`** (`RoleVoter.vote()`, `RoleVoter.ts#RoleVoter.vote`). L'absence d'un rôle ne doit pas opposer un
|
|
398
398
|
**veto** aux autres axes (un accès peut être légitime via un scope ou l'ownership) : c'est le
|
|
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
|
|
403
|
-
- Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts
|
|
402
|
+
(`RoleVoter.ts#RoleVoter.vote`), posée par le firewall au boot (`firewall.ts#Firewall.#build`).
|
|
403
|
+
- Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts#RoleVoter.vote`).
|
|
404
404
|
|
|
405
405
|
#### Deux échelles de rôles — plateforme et organisation
|
|
406
406
|
|
|
@@ -424,28 +424,75 @@ héberge aussi les autres.
|
|
|
424
424
|
### `scope` — l'axe « ce qu'une clé déléguée peut faire »
|
|
425
425
|
|
|
426
426
|
Frère du `role` sur l'autre axe. Capte la forme conventionnée `api:action` — un `:`, jamais
|
|
427
|
-
`ROLE_*` (`ScopeVoter.supports()`, `ScopeVoter.ts
|
|
427
|
+
`ROLE_*` (`ScopeVoter.supports()`, `ScopeVoter.ts#ScopeVoter.supports`) → aucune collision avec les rôles ni un
|
|
428
428
|
verbe métier. Le cœur est le **modèle de confiance** :
|
|
429
429
|
|
|
430
430
|
- **Jeton humain** (`session`, `userpassword`, `anonymous`) → `GRANT` no-op : un scope ne bride
|
|
431
|
-
**jamais** un humain, son autorisation passe par ses rôles (`ScopeVoter.ts
|
|
431
|
+
**jamais** un humain, son autorisation passe par ses rôles (`ScopeVoter.ts#ScopeVoter.vote`).
|
|
432
432
|
- **Jeton machine** (`apikey`, `jwt`, `oauth2`, ou tout type futur) → `GRANT` si le scope exact
|
|
433
|
-
est présent, sinon `ABSTAIN` (`ScopeVoter.ts
|
|
433
|
+
est présent, sinon `ABSTAIN` (`ScopeVoter.ts#ScopeVoter.vote`).
|
|
434
434
|
- **Fail-closed côté machine** : `NON_SCOPABLE_TOKEN_TYPES` est une **allowlist d'humains**
|
|
435
|
-
(`ScopeVoter.ts
|
|
435
|
+
(`ScopeVoter.ts#NON_SCOPABLE_TOKEN_TYPES`) — tout type absent (`mtls`, `agent`…) est considéré **scopable**, donc
|
|
436
436
|
bridé par défaut. Un nouveau type de jeton délégué est **fermé par oubli**, jamais ouvert.
|
|
437
437
|
- Pur : aucune dépendance, aucune I/O — instancié une fois au boot.
|
|
438
438
|
|
|
439
|
+
### Qui OBTIENT un scope — `grantableScopes`
|
|
440
|
+
|
|
441
|
+
Le voter `scope` contrôle un scope à l'**usage** : il regarde ce que le jeton porte, jamais comment
|
|
442
|
+
il l'a obtenu. `@RequireScope("vitrine:write")` et les `scopes` d'un outil MCP sont des contrôles
|
|
443
|
+
d'usage. Reste à décider qui a le droit de **recevoir** ce scope : sans réponse, l'émetteur de
|
|
444
|
+
l'application l'accorde à tout compte connecté qui le demande, et le contrôle d'usage ne filtre
|
|
445
|
+
plus rien.
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
use("@nodefony/security", {
|
|
449
|
+
grantableScopes: {
|
|
450
|
+
"vitrine:write": ["ROLE_ADMIN"], // un scope exact
|
|
451
|
+
"shop:*": ["ROLE_SHOP"], // une famille
|
|
452
|
+
"shop:refund": ["ROLE_SHOP_MANAGER"], // l'exact l'emporte sur la famille
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
- **Clé** : un scope exact, ou une famille `préfixe:*`. Une clé exacte l'emporte sur une famille,
|
|
458
|
+
la famille la plus longue sur une plus courte ; l'ordre des clés ne décide de rien
|
|
459
|
+
(`ScopeGrantPolicy.rolesFor()`, `scopeGrants.ts#ScopeGrantPolicy.rolesFor`).
|
|
460
|
+
- **Valeur** : les rôles dont **un** suffit, hiérarchie de rôles comprise. Une liste vide est
|
|
461
|
+
refusée au démarrage : pour n'accorder un scope à personne, on ne l'expose pas.
|
|
462
|
+
- **Non déclaré** : le scope reste accordé à tout compte connecté. Rien ne change pour une
|
|
463
|
+
application qui ne déclare rien.
|
|
464
|
+
- **Refusées au démarrage** : `admin:read`, `admin:write` et `admin:*`, réservés au plan
|
|
465
|
+
d'administration du framework (`scopeGrantKeyProblem()`, `scopeGrants.ts#scopeGrantKeyProblem`). Le message nomme
|
|
466
|
+
la clé et la raison.
|
|
467
|
+
|
|
468
|
+
Un seul juge pour les deux émetteurs de l'application, et pour les deux familles de scopes
|
|
469
|
+
(`refusedScopes()`, `scopeGrants.ts#refusedScopes`) :
|
|
470
|
+
|
|
471
|
+
| Émetteur | Scope refusé | Pourquoi cette forme |
|
|
472
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
473
|
+
| Jeton d'accès (`/token`, `security:mcp:token`) | retiré ; le jeton est émis avec le reste, le refus est nommé au journal (`tokenService.ts#TokenService.#grantableScopes`) | RFC 6749 §3.3 : le serveur accorde moins que demandé et le dit dans `scope` |
|
|
474
|
+
| Renouvellement d'un jeton | rejugé sur les rôles du compte relu (`tokenService.ts#TokenService.refresh`) | un rôle retiré retire son scope au prochain renouvellement |
|
|
475
|
+
| Clé API | création refusée, **403** qui nomme le scope (`apiKeys.ts#ApiKeyService.createForSubject`) | une clé créée avec moins que demandé surprendrait sans rien dire |
|
|
476
|
+
|
|
477
|
+
`grantableScopes` ne borne **pas** un jeton signé par un émetteur tiers (Keycloak…) : celui-ci
|
|
478
|
+
accorde selon sa propre politique. Et ce n'est pas une règle de zone : un jeton ne sait pas quelle
|
|
479
|
+
zone le lira, et `security:mcp:token` signe sans aucune requête.
|
|
480
|
+
|
|
481
|
+
**Depuis un module.** Un module complète la section par `module-security` dans sa configuration
|
|
482
|
+
(voir [configuration](../../../../../docs/guides/configuration.md)). Un préfixe = un propriétaire :
|
|
483
|
+
la fusion de configuration fusionne les listes **case par case**, si bien qu'une clé déclarée à deux
|
|
484
|
+
endroits mêle les deux listes de rôles.
|
|
485
|
+
|
|
439
486
|
### La hiérarchie de rôles — aplatie et vérifiée au boot
|
|
440
487
|
|
|
441
488
|
`RoleHierarchyWalker` se déclare dans la config (`use("@nodefony/security", { roleHierarchy })`,
|
|
442
489
|
voir [firewall](./firewall.md)) et fait deux choses au boot :
|
|
443
490
|
|
|
444
491
|
- **Aplatissement DFS précalculé** (`#detectCycles()` puis `#precompute()`,
|
|
445
|
-
`RoleHierarchyWalker.ts
|
|
446
|
-
(`RoleHierarchyWalker.ts
|
|
492
|
+
`RoleHierarchyWalker.ts#RoleHierarchyWalker.#precompute`) → `RoleHierarchyWalker.hasRole()` est **O(1)** sur le hot path
|
|
493
|
+
(`RoleHierarchyWalker.ts#RoleHierarchyWalker.hasRole`).
|
|
447
494
|
- **Détection de cycles** par DFS coloré — un arc vers un nœud « en cours de visite » = cycle, et
|
|
448
|
-
le boot **jette avec le chemin complet** `A → B → A` (`RoleHierarchyWalker.ts
|
|
495
|
+
le boot **jette avec le chemin complet** `A → B → A` (`RoleHierarchyWalker.ts#RoleHierarchyWalker.#detectCycles`) : jamais
|
|
449
496
|
de boucle infinie silencieuse en production.
|
|
450
497
|
|
|
451
498
|
## 🛡️ Le rôle par défaut d'une zone — et ce qu'il n'écrase pas
|
|
@@ -493,44 +540,44 @@ doit valoir partout se place donc dans le code de l'action, pas seulement dans u
|
|
|
493
540
|
|
|
494
541
|
## 🧩 Étendre le jury — le contrat et le registre
|
|
495
542
|
|
|
496
|
-
Le contrat `IAccessVoter` (`IAccessVoter.ts
|
|
543
|
+
Le contrat `IAccessVoter` (`IAccessVoter.ts#IAccessVoter`) tient en deux méthodes :
|
|
497
544
|
|
|
498
545
|
- `supports(attribute, subject?)` — test **bon marché** : ce voter sait-il décider de cet
|
|
499
546
|
attribut ? Appelé sur chaque voter à chaque `decide()`.
|
|
500
547
|
- `vote(token, attribute, subject?)` — **async** (les voters métier font des lookups DB) ; renvoie
|
|
501
|
-
un `VoterVote` : `GRANT` / `DENY` / `ABSTAIN` (`IAccessVoter.ts
|
|
548
|
+
un `VoterVote` : `GRANT` / `DENY` / `ABSTAIN` (`IAccessVoter.ts#VoterVote`).
|
|
502
549
|
|
|
503
550
|
L'enregistrement passe par le registre — `registerVoterFactory(name, factory)`
|
|
504
|
-
(`voterRegistry.ts
|
|
505
|
-
`voterRegistry.ts
|
|
551
|
+
(`voterRegistry.ts#registerVoterFactory`), consommé une fois au boot (`listVoterFactories()`,
|
|
552
|
+
`voterRegistry.ts#listVoterFactories`). La fabrique reçoit `{ container }` et ne fait **que construire** : les
|
|
506
553
|
résolutions coûteuses restent lazy dans l'instance (cf. le `DocumentVoter` du Démarrage rapide).
|
|
507
554
|
|
|
508
555
|
Pourquoi un registre et pas un scan DI des `@injectable` : les interfaces TS sont **effacées à la
|
|
509
556
|
compilation** — rien à scanner au runtime ; le registre **est** le marqueur explicite
|
|
510
|
-
(`voterRegistry.ts
|
|
557
|
+
(`voterRegistry.ts#IVoterFactoryContext`). Convention-frère : `authenticatorRegistry`, `tokenStoreRegistry`.
|
|
511
558
|
|
|
512
559
|
## 🔌 HTTP et WebSocket — une garde, N transports
|
|
513
560
|
|
|
514
|
-
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts
|
|
561
|
+
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts#Resolver.executeAction`) est le point unique
|
|
515
562
|
d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
|
|
516
563
|
`@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
|
|
517
564
|
`ws-isgranted-jwt`).
|
|
518
565
|
- **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
|
|
519
|
-
et `IRealtimeToken` (WS) (`authorization.ts
|
|
566
|
+
et `IRealtimeToken` (WS) (`authorization.ts#Authorization.#auditDeny`).
|
|
520
567
|
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
|
|
521
|
-
hiérarchie** : `satisfies()` (`frameAuthorizer.ts
|
|
522
|
-
(`firewall.ts
|
|
568
|
+
hiérarchie** : `satisfies()` (`frameAuthorizer.ts#satisfies`) délègue à `Firewall.hasRole()`
|
|
569
|
+
(`firewall.ts#Firewall.hasRole`) — les rôles exigés par un canal héritent comme partout ailleurs.
|
|
523
570
|
|
|
524
571
|
## 📜 Normes appliquées
|
|
525
572
|
|
|
526
|
-
| Domaine | Norme / posture | Ancrage
|
|
527
|
-
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
528
|
-
| Contrôle d'accès | OWASP Top 10 **A01** (IDOR, élévation) | défaut `DENY` du jury (`authorization.ts
|
|
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
|
-
| Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()`
|
|
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
|
|
533
|
-
| Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts
|
|
573
|
+
| Domaine | Norme / posture | Ancrage |
|
|
574
|
+
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
575
|
+
| Contrôle d'accès | OWASP Top 10 **A01** (IDOR, élévation) | défaut `DENY` du jury (`authorization.ts#Authorization`) |
|
|
576
|
+
| 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()` |
|
|
577
|
+
| Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
|
|
578
|
+
| Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
|
|
579
|
+
| Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts#Resolver._callWithIdempotency`) |
|
|
580
|
+
| Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts#Authorization`) |
|
|
534
581
|
|
|
535
582
|
> Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
|
|
536
583
|
> ANSI 359 » (ni sessions de rôles, ni séparation des devoirs), pas « conforme XACML » (un seul
|
|
@@ -541,14 +588,14 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
541
588
|
## ⚡ Performance & mémoire
|
|
542
589
|
|
|
543
590
|
- **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
|
|
591
|
+
alloc (`Resolver.ts#Resolver._enforceActionSecurity`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
592
|
+
requêtes (`SecurityRequirement`, `routerDecorators.ts#SecurityRequirement`).
|
|
593
|
+
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts#Authorization.decide`),
|
|
594
|
+
instanciés **une seule fois** au boot (`authorization.ts#Authorization.decide`).
|
|
548
595
|
- **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
|
|
549
|
-
(`RoleHierarchyWalker.ts
|
|
596
|
+
(`RoleHierarchyWalker.ts#RoleHierarchyWalker.hasRole`).
|
|
550
597
|
- **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
|
|
551
|
-
`JSON.stringify` aveugle (`describeSubject()`, `authorization.ts
|
|
598
|
+
`JSON.stringify` aveugle (`describeSubject()`, `authorization.ts#describeSubject`).
|
|
552
599
|
|
|
553
600
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
554
601
|
|
|
@@ -557,16 +604,16 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
557
604
|
| Une action gardée par `@RequireScope` seul reste accessible à un humain | le voter de scope accorde sans condition à une session — il ne contraint que les clés déléguées | ajouter un `@IsGranted`, ou compter sur le rôle par défaut de la zone |
|
|
558
605
|
| `@RequireScope("openid")` refuse tout, sans message | un scope sans `:` n'est capté par aucun voter → le jury ferme par défaut | employer la forme `api:action`, ou déclarer un voter pour cette famille |
|
|
559
606
|
|
|
560
|
-
| Symptôme | Cause (dans le code)
|
|
561
|
-
| -------------------------------------- |
|
|
562
|
-
| Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas
|
|
563
|
-
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts
|
|
564
|
-
| Un voter métier bloque tout | Il renvoie `DENY` au lieu d'`ABSTAIN` quand il ne s'applique pas
|
|
565
|
-
| Un `DENY` n'a pas bloqué | Attributs d'une clause = jurys **séparés** (OR) — un autre attribut a accordé
|
|
566
|
-
| Clé API accède à une action non prévue | Type de jeton traité comme humain (allowlist)
|
|
567
|
-
| `ROLE_ADMIN` n'hérite pas `ROLE_USER` | Hiérarchie non déclarée / non posée au container
|
|
568
|
-
| Boot qui plante « cycle détecté » | Hiérarchie de rôles cyclique
|
|
569
|
-
| Accès accordé à un voter qui a planté | (n'arrive pas) fail-closed : une erreur de voter = refus
|
|
607
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
608
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
609
|
+
| Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
|
|
610
|
+
| 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts#Resolver._callWithIdempotency`) | Couvrir la route par une zone firewall |
|
|
611
|
+
| 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 |
|
|
612
|
+
| 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`) |
|
|
613
|
+
| 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` |
|
|
614
|
+
| `ROLE_ADMIN` n'hérite pas `ROLE_USER` | Hiérarchie non déclarée / non posée au container | Déclarer `roleHierarchy` (config security) au boot |
|
|
615
|
+
| Boot qui plante « cycle détecté » | Hiérarchie de rôles cyclique | Casser le cycle (le message nomme le chemin) |
|
|
616
|
+
| Accès accordé à un voter qui a planté | (n'arrive pas) fail-closed : une erreur de voter = refus | Corriger le voter ; l'erreur est loggée ERROR |
|
|
570
617
|
|
|
571
618
|
## 📡 Observabilité — Studio
|
|
572
619
|
|
package/docs/cors.md
CHANGED
|
@@ -100,28 +100,28 @@ Trois conséquences que beaucoup de développeurs découvrent trop tard :
|
|
|
100
100
|
|
|
101
101
|
## La vision Nodefony — une politique pure, fail-safe, verrouillée au boot
|
|
102
102
|
|
|
103
|
-
`Cors` (`cors.ts
|
|
103
|
+
`Cors` (`cors.ts#Cors`) 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#Firewall.#build`). 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#areaSchema`) — 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
|
-
d'émettre `*` (`cors.ts
|
|
114
|
+
d'émettre `*` (`cors.ts#Cors.#allowOrigin`).
|
|
115
115
|
- **Reflet d'origine ⇒ `Vary: Origin`.** Dès que la valeur `Allow-Origin` n'est pas `*`, la politique
|
|
116
|
-
ajoute `Vary` elle-même, sans que l'appelant ait à y penser (`Cors.reflectsOrigin()`, `cors.ts
|
|
117
|
-
posé en `cors.ts
|
|
116
|
+
ajoute `Vary` elle-même, sans que l'appelant ait à y penser (`Cors.reflectsOrigin()`, `cors.ts#Cors.reflectsOrigin` ;
|
|
117
|
+
posé en `cors.ts#Cors.preflightHeaders` et `cors.ts#Cors.actualHeaders`). Sans lui, un cache partagé servirait à une origine la réponse
|
|
118
118
|
taillée pour une autre — un empoisonnement de cache.
|
|
119
119
|
- **Origine inconnue ⇒ `null` ⇒ aucun en-tête.** Pas de message d'erreur, pas de 403 bavard
|
|
120
|
-
(`cors.ts
|
|
120
|
+
(`cors.ts#Cors.preflightHeaders`, `cors.ts#Cors.actualHeaders`). La réponse part normalement mais n'est pas partageable : le navigateur
|
|
121
121
|
bloque, et un attaquant n'apprend **rien** sur le contenu de ton allowlist.
|
|
122
122
|
|
|
123
|
-
Le contrat d'entrée est `ICorsOptions` (`cors.ts
|
|
124
|
-
du module, rien de plus. La sortie est une table nom → valeur (`CorsHeaders`, `cors.ts
|
|
123
|
+
Le contrat d'entrée est `ICorsOptions` (`cors.ts#ICorsOptions`) — exactement le sous-ensemble `cors` de la config
|
|
124
|
+
du module, rien de plus. La sortie est une table nom → valeur (`CorsHeaders`, `cors.ts#CorsHeaders`) que
|
|
125
125
|
l'appelant recopie sur la réponse.
|
|
126
126
|
|
|
127
127
|
## 🚀 Démarrage rapide
|
|
@@ -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#corsSchema` ; branchée à la racine en
|
|
208
|
+
`config.ts#loginPageSchema`). 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#Firewall.#recordAuth`) |
|
|
246
246
|
|
|
247
247
|
### Situation 2 — une API publique en lecture seule (le joker `*`)
|
|
248
248
|
|
|
@@ -256,7 +256,7 @@ cors: {
|
|
|
256
256
|
}
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
-
Ici, et seulement ici, la politique émet littéralement `*` (`cors.ts
|
|
259
|
+
Ici, et seulement ici, la politique émet littéralement `*` (`cors.ts#Cors.#allowOrigin`) — et donc **pas de
|
|
260
260
|
`Vary: Origin`** : la réponse est identique pour tout le monde, elle est cachable telle quelle.
|
|
261
261
|
|
|
262
262
|
### Situation 3 — le contre-exemple piégeux : `*` + credentials
|
|
@@ -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#corsSchema`), 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.
|
|
@@ -291,7 +291,7 @@ cors: {
|
|
|
291
291
|
}
|
|
292
292
|
```
|
|
293
293
|
|
|
294
|
-
Cet en-tête n'est posé **que sur la requête réelle** (`cors.ts
|
|
294
|
+
Cet en-tête n'est posé **que sur la requête réelle** (`cors.ts#Cors.actualHeaders`), jamais sur le preflight — un
|
|
295
295
|
preflight ne transporte pas de corps, il n'y a rien à exposer. Symétrie à ne pas confondre :
|
|
296
296
|
`allowedHeaders` = ce que le front a le droit d'**envoyer** ; `exposedHeaders` = ce qu'il a le droit de
|
|
297
297
|
**lire**.
|
|
@@ -322,42 +322,42 @@ 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#Firewall.handleCors`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
+
(`http-kernel.ts#HttpKernel.handleHttp`), à la ligne `http-kernel.ts#HttpKernel.createHttpContext` — **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#Firewall.#recordAuth`) :
|
|
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#Firewall.#recordAuth`) ;
|
|
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#IHeaderCapableResponse.setHeader`) ;
|
|
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#Firewall.enforceCsrf`).
|
|
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#Firewall.enforceCsrf`). 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
|
|
345
345
|
|
|
346
|
-
| Moment | Déclencheur | En-têtes posés | Ancrage
|
|
347
|
-
| ------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
348
|
-
| **Preflight** | `OPTIONS` + `Access-Control-Request-Method` | `Allow-Origin`, `Allow-Methods`, `Allow-Headers`, `Max-Age` (+ `Vary`, + `Allow-Credentials`) | `cors.ts
|
|
349
|
-
| **Requête réelle** | tout le reste, avec un `Origin` | `Allow-Origin` (+ `Vary`, + `Allow-Credentials`, + **`Expose-Headers`**) | `cors.ts
|
|
346
|
+
| Moment | Déclencheur | En-têtes posés | Ancrage |
|
|
347
|
+
| ------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------- |
|
|
348
|
+
| **Preflight** | `OPTIONS` + `Access-Control-Request-Method` | `Allow-Origin`, `Allow-Methods`, `Allow-Headers`, `Max-Age` (+ `Vary`, + `Allow-Credentials`) | `cors.ts#Cors.preflightHeaders` |
|
|
349
|
+
| **Requête réelle** | tout le reste, avec un `Origin` | `Allow-Origin` (+ `Vary`, + `Allow-Credentials`, + **`Expose-Headers`**) | `cors.ts#Cors.actualHeaders` |
|
|
350
350
|
|
|
351
351
|
Deux nuances utiles :
|
|
352
352
|
|
|
353
353
|
- **`Allow-Headers` est statique**, dérivé de la config — la politique ne réfléchit pas le
|
|
354
|
-
`Access-Control-Request-Headers` du client (`cors.ts
|
|
354
|
+
`Access-Control-Request-Headers` du client (`cors.ts#Cors.preflightHeaders`). 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#HttpKernel.runHttpPipeline`) : 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#IFirewall.handleCors`) : `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#Firewall.#describeDefenses`) : 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#corsSchema`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
|
|
380
380
|
`csrf.trustedOrigins`.
|
|
381
381
|
|
|
382
382
|
## 🔌 Et le WebSocket ?
|
|
@@ -384,11 +384,11 @@ origine (`config.ts:194`). Ajouter une origine à `cors.origins` est **plus** pe
|
|
|
384
384
|
**Les navigateurs n'appliquent pas CORS aux WebSockets.** Une page tierce peut ouvrir un
|
|
385
385
|
`new WebSocket("wss://api.example.com/…")` et le handshake partira **avec le cookie de session de la
|
|
386
386
|
victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un contexte WS
|
|
387
|
-
(`firewall.ts
|
|
387
|
+
(`firewall.ts#Firewall.handleCors`) — 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
|
|
391
|
-
si elle est refusée. Sa doctrine :
|
|
390
|
+
(`http-kernel.ts#HttpKernel.checkWebsocketOrigin`) valide l'`Origin` **au handshake**, avant l'accept, et répond `403` sans
|
|
391
|
+
ouvrir la socket 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 ;
|
|
394
394
|
- **loopback toléré en development** uniquement, pour le cross-port Vite ↔ serveur ;
|
|
@@ -402,41 +402,41 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
|
|
|
402
402
|
|
|
403
403
|
## 📜 Normes appliquées
|
|
404
404
|
|
|
405
|
-
| Sujet | Norme | Comment le code s'y conforme
|
|
406
|
-
| --------------------------------- | --------------------------- |
|
|
407
|
-
| Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts
|
|
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
|
|
410
|
-
| Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts
|
|
411
|
-
| Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts
|
|
412
|
-
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts
|
|
405
|
+
| Sujet | Norme | Comment le code s'y conforme |
|
|
406
|
+
| --------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
407
|
+
| Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts#Cors`) — preflight vs requête réelle séparés |
|
|
408
|
+
| Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts#Firewall.enforceCsrf`) |
|
|
409
|
+
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts#areaSchema`) + reflet défensif (`cors.ts#Cors.#allowOrigin`) |
|
|
410
|
+
| Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts#Cors.preflightHeaders`, `cors.ts#Cors.actualHeaders`) |
|
|
411
|
+
| Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts#Cors.#allowOrigin`) |
|
|
412
|
+
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts#HttpKernel.checkWebsocketOrigin`) |
|
|
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
|
|
417
|
+
`maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts#ICorsOptions.maxAgeS`), 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
|
|
424
|
-
- **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts
|
|
422
|
+
- **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts#Firewall.#recordAuth`) ;
|
|
423
|
+
- **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts#IHeaderCapableResponse.setHeader`) ;
|
|
424
|
+
- **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts#Firewall.#cors`) ;
|
|
425
425
|
- **une petite table d'en-têtes** allouée uniquement pour une requête cross-origin autorisée. Une
|
|
426
|
-
origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts
|
|
426
|
+
origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts#Cors.preflightHeaders`).
|
|
427
427
|
|
|
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#Firewall.describe`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
|
+
(`firewall.ts#Firewall.#describeDefenses`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
|
+
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts#Firewall.#describeDefenses`) — 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#createSecurityAdminApi`), protégé
|
|
437
437
|
`ROLE_NODEFONY_ADMIN`.
|
|
438
438
|
- **Écran** : console **Firewall** → section _Défenses_ (`FirewallDefenses`,
|
|
439
|
-
`FirewallDefenses.tsx
|
|
439
|
+
`FirewallDefenses.tsx#FirewallDefenses`), carte CORS à côté des cartes CSRF, en-têtes et throttle.
|
|
440
440
|
- **Schéma de config** : `securityConfigJsonSchema()` alimente le formulaire d'édition de Studio —
|
|
441
441
|
la section `cors` y apparaît avec ses libellés et défauts, sans UI écrite à la main.
|
|
442
442
|
|
|
@@ -445,18 +445,18 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
445
445
|
|
|
446
446
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
447
447
|
|
|
448
|
-
| Symptôme | Cause
|
|
449
|
-
| --------------------------------------------------------- |
|
|
450
|
-
| Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts
|
|
451
|
-
| Requête bloquée alors que l'origine « est » dans la liste | Match **exact** : port, scheme ou sous-domaine divergent
|
|
452
|
-
| `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur
|
|
453
|
-
| Cookie non envoyé malgré `credentials:true` | Opt-in serveur seul : le client n'a pas `credentials: "include"`
|
|
454
|
-
| `response.headers.get("X-…")` renvoie `null` | En-tête non déclaré dans `exposedHeaders` (`cors.ts
|
|
455
|
-
| Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts
|
|
456
|
-
| Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts
|
|
457
|
-
| `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight
|
|
458
|
-
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS
|
|
459
|
-
| Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev
|
|
448
|
+
| Symptôme | Cause | Correction |
|
|
449
|
+
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
450
|
+
| Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts#corsSchema`) | Lister les origines, ou passer `credentials:false` |
|
|
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
|
+
| `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur | Normal — reproduire avec un `Origin` explicite (`curl -H 'Origin: …'`) |
|
|
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) |
|
|
454
|
+
| `response.headers.get("X-…")` renvoie `null` | En-tête non déclaré dans `exposedHeaders` (`cors.ts#Cors.actualHeaders`) | L'ajouter à `cors.exposedHeaders` |
|
|
455
|
+
| Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts#Cors.preflightHeaders`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
|
|
456
|
+
| Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts#Cors.preflightHeaders`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
|
|
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#HttpKernel.checkWebsocketOrigin`) — vérifier `allowedOrigins` |
|
|
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
|
|
462
462
|
|