@nodefony/realtime 10.0.0-beta.1 → 10.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +17 -10
- package/dist/nodefony/config/config.js +6 -3
- package/dist/nodefony/config/configSchema.js +19 -0
- package/dist/nodefony/config/defineModuleConfig.js +42 -15
- package/dist/nodefony/decorators/realtimeDecorators.js +68 -17
- package/dist/nodefony/src/backplane/RedisBackplane.js +21 -4
- package/dist/nodefony/src/backplane/bootNotices.js +43 -0
- package/dist/nodefony/src/backplane/publishQueue.js +1 -4
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +15 -4
- package/dist/nodefony/src/guards.js +41 -0
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -4
- package/dist/nodefony/src/server/RealtimeController.js +120 -50
- package/dist/nodefony/src/server/RealtimeHub.js +29 -51
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +13 -5
- package/dist/nodefony/src/server/syslogUplink.js +4 -2
- package/dist/nodefony/src/service/RealtimeService.js +3 -2
- package/dist/nodefony/testing/index.js +41 -1
- package/dist/types/nodefony/config/configSchema.d.ts +12 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +29 -6
- package/dist/types/nodefony/decorators/realtimeDecorators.d.ts +19 -2
- package/dist/types/nodefony/interfaces/IRealtimeController.d.ts +1 -1
- package/dist/types/nodefony/interfaces/IRealtimeHandshake.d.ts +1 -1
- package/dist/types/nodefony/src/backplane/RedisBackplane.d.ts +7 -0
- package/dist/types/nodefony/src/backplane/bootNotices.d.ts +25 -0
- package/dist/types/nodefony/src/guards.d.ts +32 -0
- package/dist/types/nodefony/src/server/AnonymousRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +24 -4
- package/dist/types/nodefony/src/server/RealtimeHub.d.ts +22 -16
- package/dist/types/nodefony/src/service/RealtimeService.d.ts +1 -1
- package/dist/types/nodefony/testing/index.d.ts +2 -2
- package/docs/actions.md +124 -41
- package/docs/architecture.md +93 -91
- package/docs/cadence-et-contre-pression.md +634 -0
- package/docs/configuration.md +66 -56
- package/docs/cookbook-chat.md +39 -33
- package/docs/index.md +47 -31
- package/docs/observabilite.md +21 -18
- package/docs/protocole.md +79 -65
- package/docs/securite.md +69 -65
- package/docs/vocabulaire.md +80 -79
- package/package.json +16 -12
- package/docs/couverture-bancs.md +0 -139
package/docs/securite.md
CHANGED
|
@@ -93,16 +93,16 @@ Les étapes 1 et 2 appartiennent à `@nodefony/http` et `@nodefony/security` : l
|
|
|
93
93
|
Une WebSocket authentifiée est une cible de choix : elle porte une identité, vit longtemps et
|
|
94
94
|
diffuse en continu.
|
|
95
95
|
|
|
96
|
-
| Attaque | Ce qui la bloque
|
|
97
|
-
| ------------------------------------------------------------------------------------------------------ |
|
|
98
|
-
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte
|
|
99
|
-
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod | Plancher des namespaces réservés (authentifié + `ROLE_NODEFONY_ADMIN`)
|
|
100
|
-
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles
|
|
101
|
-
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP | Re-match de la MÊME zone firewall que `GET {path}`
|
|
102
|
-
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser | Re-validation périodique de l'identité, fermeture `4001`
|
|
103
|
-
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux | Plafond de canaux par connexion (256 par défaut)
|
|
104
|
-
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM | Jet de frames
|
|
105
|
-
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits | Motif de refus **générique** (`forbidden`), jamais le détail
|
|
96
|
+
| Attaque | Ce qui la bloque | Où |
|
|
97
|
+
| ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
98
|
+
| **CSWSH** — `evil.com` fait `new WebSocket("wss://app.exemple.com/rt")`, le navigateur joint le cookie | Contrôle d'`Origin` same-origin par défaut, puis allowlist stricte | `@nodefony/http` transport, puis `csrf.checkOrigin` realtime |
|
|
99
|
+
| **Écoute des flux internes** — un visiteur s'abonne à `nodefony:syslog` et lit les logs du pod | Plancher des namespaces réservés (authentifié + `ROLE_NODEFONY_ADMIN`) | Verrou de frame `@nodefony/security` |
|
|
100
|
+
| **Élévation par canal métier** — un `ROLE_USER` s'abonne au canal admin d'un autre module | Policy déclarée sur le canal, évaluée avec la hiérarchie de rôles | `@RealtimeChannel(name, { roles })` + verrou de frame |
|
|
101
|
+
| **Pont API plus permissif que REST** — `api.request {path}` pour contourner un 401 HTTP | Re-match de la MÊME zone firewall que `GET {path}` | Verrou de frame, surface `api.request` |
|
|
102
|
+
| **Socket zombie** — un admin se déconnecte, sa socket continue de diffuser | Re-validation périodique de l'identité, fermeture `4001` | Tick de révocation du hub |
|
|
103
|
+
| **DoS mémoire par abonnements** — une connexion ouvre des milliers de canaux | Plafond de canaux par connexion (256 par défaut) | `limits.maxChannelsPerConnection` |
|
|
104
|
+
| **DoS mémoire par lenteur** — un client ne lit pas, la file d'envoi enfle jusqu'à l'OOM | Jet de frames au-delà de 4 MiB, fermeture `1013` après 1000 jets d'affilée | Back-pressure du transport WS |
|
|
105
|
+
| **Oracle d'autorisation** — sonder les canaux pour cartographier les droits | Motif de refus **générique** (`forbidden`), jamais le détail | `realtime:denied` |
|
|
106
106
|
|
|
107
107
|
> [!IMPORTANT]
|
|
108
108
|
> Les lignes 2 à 5 supposent `@nodefony/security` chargé **avec au moins une zone protégée**. Sans
|
|
@@ -115,11 +115,11 @@ HTTP et WebSocket vivent dans le même contexte : la sécurité en hérite, **on
|
|
|
115
115
|
politiques.**
|
|
116
116
|
|
|
117
117
|
- Une zone du firewall couvre HTTP **et** WS par défaut (`realtime: true` —
|
|
118
|
-
`security/nodefony/config/config.ts:
|
|
118
|
+
`security/nodefony/config/config.ts:132`). L'opt-out est explicite ; un opt-in aurait été
|
|
119
119
|
_fail-open_ (une zone qui oublie le flag laisserait le WS anonyme).
|
|
120
120
|
- Le verrou de frame consulte la **même** fonction de match que le HTTP, `Firewall.matchPath()`
|
|
121
|
-
(`firewall.ts:
|
|
122
|
-
(`firewall.ts:
|
|
121
|
+
(`firewall.ts:783`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
|
|
122
|
+
(`firewall.ts:521`). Invariant par construction : `api.request {path}` n'accorde jamais plus que
|
|
123
123
|
`GET {path}`.
|
|
124
124
|
- L'identité du handshake est celle du firewall HTTP : `FirewallRealtimeAuthenticator`
|
|
125
125
|
(`FirewallRealtimeAuthenticator.ts:57`) ne relit ni cookie ni base, il **promeut** l'`IUser` déjà
|
|
@@ -220,9 +220,9 @@ refus est **visible**, jamais un silence.
|
|
|
220
220
|
|
|
221
221
|
```typescript
|
|
222
222
|
// frontend — le client isomorphe du core
|
|
223
|
-
import {
|
|
223
|
+
import { NodefonySocket } from "nodefony/client";
|
|
224
224
|
|
|
225
|
-
const socket = new
|
|
225
|
+
const socket = new NodefonySocket({ url: "wss://app.exemple.com/rt/orders" });
|
|
226
226
|
|
|
227
227
|
// Refus poussés par le serveur — première classe côté client.
|
|
228
228
|
socket.onDenied((denied) => {
|
|
@@ -261,13 +261,13 @@ Nodefony pose **deux barrières successives**, à deux étages différents.
|
|
|
261
261
|
| Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
|
|
262
262
|
|
|
263
263
|
**Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
|
|
264
|
-
(`http-kernel.ts:
|
|
264
|
+
(`http-kernel.ts:724`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
|
|
265
265
|
loopback en développement et une allowlist optionnelle (`allowedOrigins`,
|
|
266
|
-
`http/nodefony/config/config.ts:
|
|
266
|
+
`http/nodefony/config/config.ts:570`) acceptant le hostname exact ou un wildcard à un label. Une
|
|
267
267
|
requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.
|
|
268
268
|
|
|
269
269
|
**Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
|
|
270
|
-
(`RealtimeHub.ts:
|
|
270
|
+
(`RealtimeHub.ts:967`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
|
|
271
271
|
(`RealtimeService.ts:313`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts:162`).
|
|
272
272
|
Trois différences comptent :
|
|
273
273
|
|
|
@@ -292,18 +292,18 @@ n'est jamais traitée.
|
|
|
292
292
|
|
|
293
293
|
### Le pipeline exact
|
|
294
294
|
|
|
295
|
-
`RealtimeController.onHandshake()` (`RealtimeController.ts:
|
|
295
|
+
`RealtimeController.onHandshake()` (`RealtimeController.ts:450`) exécute, une fois par connexion :
|
|
296
296
|
|
|
297
297
|
1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
|
|
298
|
-
(`RealtimeController.ts:
|
|
298
|
+
(`RealtimeController.ts:1356`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
|
|
299
299
|
dépendance à `@nodefony/security` dans le contrat.
|
|
300
300
|
2. Contrôle d'origine (verrou 1).
|
|
301
|
-
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:
|
|
301
|
+
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts:943`) :
|
|
302
302
|
les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
|
|
303
303
|
4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
|
|
304
304
|
connexion : lire un store est acceptable ici, jamais par frame).
|
|
305
305
|
5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
|
|
306
|
-
(`RealtimeHub.ts:
|
|
306
|
+
(`RealtimeHub.ts:999`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
|
|
307
307
|
frame.
|
|
308
308
|
|
|
309
309
|
Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
|
|
@@ -312,8 +312,8 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
312
312
|
|
|
313
313
|
### Les matchers — quelle porte, quel vigile
|
|
314
314
|
|
|
315
|
-
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:
|
|
316
|
-
`compileMatcher()` (`RealtimeHub.ts:
|
|
315
|
+
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts:925`) associe un sélecteur à une stratégie.
|
|
316
|
+
`compileMatcher()` (`RealtimeHub.ts:1399`) le compile une fois :
|
|
317
317
|
|
|
318
318
|
- `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
|
|
319
319
|
`"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
|
|
@@ -321,16 +321,16 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
321
321
|
- `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
|
|
322
322
|
wildcard.
|
|
323
323
|
- Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
|
|
324
|
-
(`RealtimeController.ts:
|
|
324
|
+
(`RealtimeController.ts:1408`) extrait `pathname + search` du `WebsocketContext.url`, qui est
|
|
325
325
|
absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
|
|
326
326
|
|
|
327
327
|
`@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
|
|
328
|
-
(`firewall.ts:
|
|
328
|
+
(`firewall.ts:306`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
|
|
329
329
|
identité d'instance et une instance partagée n'enregistrerait que le premier matcher.
|
|
330
330
|
|
|
331
331
|
### Zero Trust — il y a toujours un token
|
|
332
332
|
|
|
333
|
-
`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:
|
|
333
|
+
`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts:1011`) ne renvoie **jamais** `null` : à défaut de
|
|
334
334
|
token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts:18`), singleton gelé,
|
|
335
335
|
`isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
|
|
336
336
|
n'a jamais à écrire « et s'il n'y a pas de token ? ».
|
|
@@ -422,7 +422,7 @@ sequenceDiagram
|
|
|
422
422
|
P-->>C: realtime:denied {channel, reason:"forbidden"}
|
|
423
423
|
```
|
|
424
424
|
|
|
425
|
-
Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts:
|
|
425
|
+
Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts:40`). Un `await` par
|
|
426
426
|
frame coûterait une microtask et sérialiserait le pipeline RPC de la connexion. Il ne lit que de la
|
|
427
427
|
mémoire : le token déjà résolu, et la cible de la frame.
|
|
428
428
|
|
|
@@ -468,32 +468,32 @@ Trois durcissements méritent d'être connus :
|
|
|
468
468
|
`authenticated: true`. Le test porte sur le **namespace du canal**, pas sur le préfixe de la règle
|
|
469
469
|
qui a matché : un préfixe de config plus court ou altéré ne contourne rien.
|
|
470
470
|
- **La config passe avant les défauts** — les règles de `realtimeChannels`
|
|
471
|
-
(`security/nodefony/config/config.ts:
|
|
471
|
+
(`security/nodefony/config/config.ts:1447`) sont placées en tête, premier match gagnant. On peut
|
|
472
472
|
donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
|
|
473
473
|
|
|
474
474
|
Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
|
|
475
|
-
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:
|
|
475
|
+
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts:1278`) : il devient servable par n'importe
|
|
476
476
|
quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
|
|
477
477
|
dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
|
|
478
478
|
jamais de canal d'audit non gardé.
|
|
479
479
|
|
|
480
480
|
### La policy métier — déclarer sur le canal
|
|
481
481
|
|
|
482
|
-
`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts:
|
|
483
|
-
(`realtimeDecorators.ts:
|
|
482
|
+
`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts:240`) et `@RealtimeInbound(name, policy)`
|
|
483
|
+
(`realtimeDecorators.ts:278`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
|
|
484
484
|
cumulatifs (ET) ; un axe absent n'impose rien :
|
|
485
485
|
|
|
486
486
|
| Axe | Sens | Évalué par |
|
|
487
487
|
| --------------- | ----------------------------------------------------------------------------- | ---------------------------------------- |
|
|
488
488
|
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts:290`) |
|
|
489
|
-
| `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:
|
|
489
|
+
| `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts:521`) |
|
|
490
490
|
| `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
|
|
491
491
|
|
|
492
|
-
Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts:
|
|
492
|
+
Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts:91`) ignore un
|
|
493
493
|
objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont publiées
|
|
494
|
-
au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts:
|
|
494
|
+
au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts:1124`,
|
|
495
495
|
idempotent) ; le décideur les relit par `RealtimeService.resolveChannelPolicy()`
|
|
496
|
-
(`RealtimeService.ts:
|
|
496
|
+
(`RealtimeService.ts:291`).
|
|
497
497
|
|
|
498
498
|
### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas
|
|
499
499
|
|
|
@@ -508,7 +508,7 @@ là, le canal fonctionne, et le refus attendu n'arrive jamais.
|
|
|
508
508
|
|
|
509
509
|
Le serveur le **dit** désormais. Au premier canal servi dynamiquement sans politique, alors que le
|
|
510
510
|
module en déclare pour d'autres canaux, un avertissement est journalisé une fois
|
|
511
|
-
(`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts:
|
|
511
|
+
(`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts:1251`) :
|
|
512
512
|
|
|
513
513
|
```
|
|
514
514
|
le canal "chat:room:1000" est servi par une fabrique DYNAMIQUE et n'est couvert par AUCUNE
|
|
@@ -517,12 +517,16 @@ politique déclarée, alors que ce module en déclare pour d'autres canaux. […
|
|
|
517
517
|
|
|
518
518
|
Deux façons de fermer, selon l'intention :
|
|
519
519
|
|
|
520
|
-
- **
|
|
521
|
-
|
|
520
|
+
- **la famille doit être gardée** → déclarer un **motif** : `@RealtimeChannel("chat:room:*", { roles })`
|
|
521
|
+
garde tous les canaux de la famille ; l'avertissement propose le motif à écrire et se tait dès
|
|
522
|
+
qu'un motif couvre le canal ;
|
|
523
|
+
- **le canal doit être gardé seul** → l'exposer sous un nom déclaré (un canal par nom exact), ou
|
|
524
|
+
porter la vérification **dans la fabrique** elle-même, qui reçoit le nom demandé et peut refuser
|
|
525
|
+
(`null`) ;
|
|
522
526
|
- **le canal est public** → rien à faire, l'avertissement dit seulement qu'il n'est pas gardé.
|
|
523
527
|
|
|
524
|
-
>
|
|
525
|
-
>
|
|
528
|
+
> Le nom exact l'emporte sur un motif ; entre deux motifs, le plus spécifique gagne. Un `*` dans un
|
|
529
|
+
> nom souscrit est refusé : il désigne un motif de politique, jamais un canal.
|
|
526
530
|
|
|
527
531
|
### ⚠️ La condition d'activation — le point critique
|
|
528
532
|
|
|
@@ -531,21 +535,21 @@ Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraie
|
|
|
531
535
|
1. `@nodefony/security` est chargé, et
|
|
532
536
|
2. au moins une zone a `security: true` **et** `realtime: true`.
|
|
533
537
|
|
|
534
|
-
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:
|
|
538
|
+
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts:306`) : sans zone qualifiante,
|
|
535
539
|
`wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
|
|
536
540
|
évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
|
|
537
541
|
|
|
538
542
|
Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
|
|
539
|
-
posé au moment de son handshake (`RealtimeController.ts:
|
|
540
|
-
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:
|
|
543
|
+
posé au moment de son handshake (`RealtimeController.ts:562`, via
|
|
544
|
+
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts:1039`). Choix de performance délibéré (un hub
|
|
541
545
|
non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
|
|
542
546
|
la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
|
|
543
547
|
firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
|
|
544
548
|
`setFrameAuthorizer()` à chaud.
|
|
545
549
|
|
|
546
550
|
**Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
|
|
547
|
-
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:
|
|
548
|
-
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:
|
|
551
|
+
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts:1098`) renvoie `true` et le controller
|
|
552
|
+
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts:639`) :
|
|
549
553
|
|
|
550
554
|
```text
|
|
551
555
|
Realtime channel policies declared but NO frame authorizer is wired —
|
|
@@ -571,8 +575,8 @@ Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
|
|
|
571
575
|
|
|
572
576
|
| Type de frame refusée | Réponse au client |
|
|
573
577
|
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
|
574
|
-
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:
|
|
575
|
-
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:
|
|
578
|
+
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts:419`) |
|
|
579
|
+
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts:553`) |
|
|
576
580
|
| Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
|
|
577
581
|
| Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
|
|
578
582
|
|
|
@@ -598,20 +602,20 @@ ne peut pas relire la session. Un administrateur qui se déconnecte garderait se
|
|
|
598
602
|
|
|
599
603
|
Nodefony ferme l'écart par deux mécanismes de granularité différente.
|
|
600
604
|
|
|
601
|
-
| Surface | Re-validation | Fenêtre d'exposition | Où
|
|
602
|
-
| --------------------------- | -------------------- | -------------------- |
|
|
603
|
-
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:
|
|
604
|
-
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:
|
|
605
|
+
| Surface | Re-validation | Fenêtre d'exposition | Où |
|
|
606
|
+
| --------------------------- | -------------------- | -------------------- | ---------------------------------------------------------------------- |
|
|
607
|
+
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts:1070`) |
|
|
608
|
+
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts:815`) |
|
|
605
609
|
|
|
606
610
|
**Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
|
|
607
611
|
ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
|
|
608
612
|
courant. Une erreur de re-validation vaut refus (fail-closed).
|
|
609
613
|
|
|
610
614
|
**Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
|
|
611
|
-
`isValid` (`RealtimeController.ts:
|
|
612
|
-
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:
|
|
615
|
+
`isValid` (`RealtimeController.ts:715`) — anonymes et JWT n'y entrent jamais, coût nul.
|
|
616
|
+
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts:796`) démarre un `setInterval` `unref` au premier
|
|
613
617
|
inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
|
|
614
|
-
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:
|
|
618
|
+
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts:112`), 30 s, alignée sur le heartbeat WS.
|
|
615
619
|
|
|
616
620
|
Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
|
|
617
621
|
|
|
@@ -625,7 +629,7 @@ Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
|
|
|
625
629
|
| registre vide | tick no-op, aucun crash |
|
|
626
630
|
|
|
627
631
|
La source de vérité côté security est `buildSessionRevalidator()`
|
|
628
|
-
(`FirewallRealtimeAuthenticator.ts:
|
|
632
|
+
(`FirewallRealtimeAuthenticator.ts:275`) : il relit la session BFF **par son id capturé au handshake**
|
|
629
633
|
et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
|
|
630
634
|
attrape le changement de compte sur un navigateur partagé.
|
|
631
635
|
|
|
@@ -652,12 +656,12 @@ pas** borné.
|
|
|
652
656
|
Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
|
|
653
657
|
peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
|
|
654
658
|
|
|
655
|
-
`RealtimeController.startChannel()` (`RealtimeController.ts:
|
|
659
|
+
`RealtimeController.startChannel()` (`RealtimeController.ts:869`) refuse au-delà de
|
|
656
660
|
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts:142`), défaut **256**,
|
|
657
661
|
`null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
|
|
658
662
|
|
|
659
663
|
- le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
|
|
660
|
-
(`RealtimeHub.ts:
|
|
664
|
+
(`RealtimeHub.ts:380`), il n'y a pas de fenêtre où la garde n'existerait pas ;
|
|
661
665
|
- au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
|
|
662
666
|
- le refus est **observable** (`realtime:denied`, motif `limit`) ;
|
|
663
667
|
- un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
|
|
@@ -676,7 +680,7 @@ canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts:82`) applique
|
|
|
676
680
|
|
|
677
681
|
| `bufferedAmount` | Action |
|
|
678
682
|
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
|
679
|
-
| > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts:
|
|
683
|
+
| > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts:1226`) | politique `websocket.backpressurePolicy` : `drop` (défaut) jette la frame — canaux d'état, le prochain snapshot la remplace |
|
|
680
684
|
| `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut | `close(1013)` « Try Again Later » ; le client se reconnecte et resynchronise |
|
|
681
685
|
|
|
682
686
|
### Taille des messages entrants
|
|
@@ -693,7 +697,7 @@ au défaut de la librairie `ws`.
|
|
|
693
697
|
| Limite de **fréquence** des frames entrantes | Un client authentifié peut inonder le peer ; seul le coût CPU le freine |
|
|
694
698
|
| Limite de **connexions par IP ou par utilisateur** | Rien n'empêche N sockets par client au niveau du module |
|
|
695
699
|
| Plafond **global** de canaux du process | Le plafond est par connexion ; M connexions × 256 canaux reste possible |
|
|
696
|
-
| Seuils de back-pressure
|
|
700
|
+
| Seuils de back-pressure dans ce module | aucun réglage dans `@nodefony/realtime` : ils se règlent sur le serveur WebSocket de `@nodefony/http` (`websocket.maxBackpressure`, `.backpressureCloseAfterDrops`) |
|
|
697
701
|
|
|
698
702
|
## ⚙️ Configuration de sécurité
|
|
699
703
|
|
|
@@ -724,9 +728,9 @@ Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/c
|
|
|
724
728
|
Cette section existe pour éviter une confiance mal placée. Chaque point est vérifié au code.
|
|
725
729
|
|
|
726
730
|
**1. Il n'y a pas de frontière de canal entre modules.** Un nom de canal est une chaîne dans un
|
|
727
|
-
registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts:
|
|
731
|
+
registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts:327`). Rien n'empêche le
|
|
728
732
|
controller du module A de servir un canal au nom d'un canal du module B, ni de déclarer une policy
|
|
729
|
-
plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts:
|
|
733
|
+
plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts:757`, idempotent
|
|
730
734
|
par écrasement). En pratique, l'exploitation exige que le controller fautif accepte le nom (sa
|
|
731
735
|
factory doit renvoyer un provider), donc un override permissif de `createRealtimeChannel` ; et les
|
|
732
736
|
namespaces réservés restent couverts par le plancher système **si** le verrou est posé. Une garde
|
|
@@ -765,7 +769,7 @@ n'est pas appliquée.
|
|
|
765
769
|
| **RFC 6455 §7.4.2** (codes 4000-4999) | `4001` unauthorized / session revoked, `4003` origin not allowed |
|
|
766
770
|
| **RFC 6455 §7.4.1 / registre IANA** | `1008` Policy Violation (origine transport), `1009` Message Too Big, `1013` Try Again Later |
|
|
767
771
|
| **RFC 6455 §4.1** | `Sec-WebSocket-Protocol` normalisé en liste dans le DTO de handshake |
|
|
768
|
-
| **RFC
|
|
772
|
+
| **RFC 9110 §7.2** | Comparaison du `Host` par le matcher d'authenticator |
|
|
769
773
|
| **JSON-RPC 2.0 §5.1** | `-32001` (plage `Server error`) pour une requête refusée, `-32602` pour des params invalides |
|
|
770
774
|
| **OWASP WSTG-CLNT-10** | Anti-CSWSH : validation d'`Origin` obligatoire, les navigateurs n'appliquent pas CORS aux WS |
|
|
771
775
|
| **Zero Trust / OWASP A01** | Zone protégée + anonyme = refus ; token toujours présent, jamais `null` |
|
|
@@ -773,7 +777,7 @@ n'est pas appliquée.
|
|
|
773
777
|
## 📡 Observabilité — Studio
|
|
774
778
|
|
|
775
779
|
- **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
|
|
776
|
-
(`RealtimeHub.ts:
|
|
780
|
+
(`RealtimeHub.ts:847`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
|
|
777
781
|
du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
|
|
778
782
|
précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
|
|
779
783
|
(namespace réservé : `ROLE_NODEFONY_ADMIN`).
|
|
@@ -795,7 +799,7 @@ n'est pas appliquée.
|
|
|
795
799
|
| Un admin déconnecté garde ses flux pendant une dizaine de secondes | Le tick de révocation est périodique (30 s), pas immédiat | Comportement attendu ; pour une coupure immédiate, fermer la socket côté serveur |
|
|
796
800
|
| Une session révoquée ne ferme **jamais** la socket | La session n'était pas lisible au handshake → revalidateur `null`, `isValid()` répond toujours `true` | Vérifier que le handshake traverse bien la zone (session chargée avant le controller realtime) |
|
|
797
801
|
| Le client se croit abonné, ne reçoit rien | Refus d'autorisation, plafond atteint, **ou nom de canal sans producteur** (pas de réponse RPC) | Écouter `realtime:denied` : `forbidden` (droits), `limit` (borne), `unknown` (le nom ne désigne rien) |
|
|
798
|
-
| `slowConsumer.bytes` augmenté, les frames sont toujours jetées à
|
|
802
|
+
| `slowConsumer.bytes` augmenté, les frames sont toujours jetées à 4 MiB | Cette clé pilote le **comptage** de la sonde, pas les seuils de drop/close du transport | Régler `websocket.maxBackpressure` / `.backpressureCloseAfterDrops` dans `@nodefony/http` |
|
|
799
803
|
| Deux déploiements se parlent en cross-talk | Pas de `backplane.namespace` sur un Redis mutualisé (la base Redis ne cloisonne pas le pub/sub) | Poser un `namespace` explicite par déploiement |
|
|
800
804
|
| Le fan-out cross-pod s'arrête après avoir posé un secret | `backplane.secret` différent d'un pod à l'autre : les messages sont scellés, aucun ne se vérifie | Le **même** secret sur tous les pods (`NF_REALTIME_BACKPLANE_SECRET`) ; suivre `ingressRejectedTotal` |
|
|
801
805
|
| Un canal broadcast ne reçoit rien des autres pods | Le préfixe n'est pas déclaré côté receveur → l'entrée refuse le canal (compté) | Déclarer le préfixe (`broadcast` du controller) sur **tous** les pods, pas seulement l'émetteur |
|