@nodefony/realtime 10.0.0-beta.3 → 10.0.0-beta.4
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 +4 -6
- package/dist/nodefony/config/config.js +1 -2
- package/dist/nodefony/config/configSchema.js +1 -2
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/decorators/realtimeDecorators.js +1 -2
- package/dist/nodefony/interfaces/IRealtimeConnectionState.js +1 -0
- package/dist/nodefony/src/backplane/ClusterBackplane.js +1 -2
- package/dist/nodefony/src/backplane/LoopbackBackplane.js +1 -2
- package/dist/nodefony/src/backplane/RedisBackplane.js +1 -2
- package/dist/nodefony/src/backplane/backplaneRegistry.js +1 -2
- package/dist/nodefony/src/backplane/bootNotices.js +1 -2
- package/dist/nodefony/src/backplane/envelope.js +1 -2
- package/dist/nodefony/src/backplane/originId.js +1 -2
- package/dist/nodefony/src/backplane/publishQueue.js +0 -1
- package/dist/nodefony/src/cluster/ClusterProbeClient.js +1 -2
- package/dist/nodefony/src/guards.js +1 -2
- package/dist/nodefony/src/server/AnonymousRealtimeToken.js +1 -2
- package/dist/nodefony/src/server/RealtimeAdminApi.js +1 -2
- package/dist/nodefony/src/server/RealtimeController.js +154 -80
- package/dist/nodefony/src/server/RealtimeHub.js +3 -3
- package/dist/nodefony/src/server/ServerRealtimeSocket.js +18 -9
- package/dist/nodefony/src/server/deniedDetail.js +1 -2
- package/dist/nodefony/src/server/subscribedAck.js +29 -0
- package/dist/nodefony/src/server/syslogUplink.js +1 -2
- package/dist/nodefony/src/server/welcomeEnv.js +1 -2
- package/dist/nodefony/src/service/RealtimeService.js +1 -2
- package/dist/nodefony/src/transport/WsConnectionTransport.js +42 -2
- package/dist/nodefony/testing/index.js +77 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IRealtimeConnectionState.d.ts +40 -0
- package/dist/types/nodefony/interfaces/IRealtimeToken.d.ts +13 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/src/server/RealtimeController.d.ts +58 -0
- package/dist/types/nodefony/src/server/ServerRealtimeSocket.d.ts +18 -8
- package/dist/types/nodefony/src/server/subscribedAck.d.ts +23 -0
- package/dist/types/nodefony/src/transport/WsConnectionTransport.d.ts +26 -1
- package/dist/types/nodefony/testing/index.d.ts +6 -1
- package/docs/actions.md +44 -43
- package/docs/architecture.md +125 -119
- package/docs/cadence-et-contre-pression.md +31 -22
- package/docs/configuration.md +106 -82
- package/docs/cookbook-chat.md +37 -34
- package/docs/index.md +35 -24
- package/docs/observabilite.md +49 -49
- package/docs/protocole.md +91 -68
- package/docs/securite.md +125 -114
- package/docs/vocabulaire.md +117 -114
- package/package.json +10 -10
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/securite.md
CHANGED
|
@@ -49,7 +49,7 @@ collectées, personne ne les fait respecter.
|
|
|
49
49
|
```mermaid
|
|
50
50
|
flowchart TD
|
|
51
51
|
C["Client — GET /realtime<br/>Upgrade: websocket"] --> H1["1. Transport HTTP<br/>same-origin anti-CSWSH"]
|
|
52
|
-
H1 -->|refus| X1["
|
|
52
|
+
H1 -->|refus| X1["403 au handshake<br/>aucune socket"]
|
|
53
53
|
H1 --> H2["2. Firewall HTTP<br/>zone protégée ? 401 si anonyme"]
|
|
54
54
|
H2 --> H3["3. Origin realtime<br/>allowlist stricte (opt-in)"]
|
|
55
55
|
H3 -->|refus| X2["close 4003"]
|
|
@@ -115,14 +115,14 @@ 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#areaSchema`). 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#Firewall.matchPath`), et la **même** hiérarchie de rôles, `Firewall.hasRole()`
|
|
122
|
+
(`firewall.ts#Firewall.hasRole`). 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
|
-
(`FirewallRealtimeAuthenticator.ts
|
|
125
|
+
(`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator`) ne relit ni cookie ni base, il **promeut** l'`IUser` déjà
|
|
126
126
|
posé dans l'ALS. Zéro lecture redondante par connexion.
|
|
127
127
|
|
|
128
128
|
Le compromis assumé : l'identité est **figée au handshake**, les frames lisent un cache O(1). C'est
|
|
@@ -136,36 +136,45 @@ aux administrateurs, et voir ce que reçoit un client non autorisé.
|
|
|
136
136
|
### 1. Déclarer la zone et activer l'origine stricte
|
|
137
137
|
|
|
138
138
|
```typescript
|
|
139
|
-
// nodefony
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
// nodefony/config/security.ts — la zone du handshake et la hiérarchie de rôles
|
|
140
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
141
|
+
|
|
142
|
+
export const securityConfig = () =>
|
|
143
|
+
({
|
|
144
|
+
areas: {
|
|
145
|
+
// Zone protégée : le handshake WS de /rt passe par le MÊME contrôle que
|
|
146
|
+
// le HTTP. `realtime` vaut true par défaut → ne pas l'écrire suffit.
|
|
147
|
+
app: {
|
|
148
|
+
pattern: "^/rt",
|
|
149
|
+
authenticators: ["session"],
|
|
150
|
+
},
|
|
147
151
|
},
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
152
|
+
roleHierarchy: {
|
|
153
|
+
ROLE_ADMIN: ["ROLE_USER"],
|
|
154
|
+
},
|
|
155
|
+
}) satisfies ISecurityConfigInput;
|
|
156
|
+
```
|
|
153
157
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
```typescript
|
|
159
|
+
// nodefony/config/realtime.ts — la barrière d'origine et la garde anti-OOM
|
|
160
|
+
import type { IRealtimeConfigInput } from "@nodefony/realtime";
|
|
161
|
+
|
|
162
|
+
export const realtimeConfig = () =>
|
|
163
|
+
({
|
|
164
|
+
csrf: {
|
|
165
|
+
checkOrigin: {
|
|
166
|
+
// Deuxième barrière d'origine : allowlist EXACTE (pas de wildcard).
|
|
167
|
+
enabled: true,
|
|
168
|
+
allowList: ["https://app.exemple.com", "https://127.0.0.1:5152"],
|
|
169
|
+
// Un client sans en-tête Origin (mobile natif, script) est refusé.
|
|
170
|
+
allowMissingOrigin: false,
|
|
171
|
+
},
|
|
162
172
|
},
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
});
|
|
173
|
+
limits: {
|
|
174
|
+
// Garde anti-OOM : au-delà, le subscribe est refusé (pas de close).
|
|
175
|
+
maxChannelsPerConnection: 64,
|
|
176
|
+
},
|
|
177
|
+
}) satisfies IRealtimeConfigInput;
|
|
169
178
|
```
|
|
170
179
|
|
|
171
180
|
### 2. Le controller : un canal libre, un canal gardé
|
|
@@ -255,20 +264,20 @@ vers ton domaine, et le navigateur y joindra les cookies de la victime. L'en-tê
|
|
|
255
264
|
|
|
256
265
|
Nodefony pose **deux barrières successives**, à deux étages différents.
|
|
257
266
|
|
|
258
|
-
| Barrière | Étage | Défaut | Origin absent | Refus
|
|
259
|
-
| -------------------- | -------------------- | ----------------------------------- | ------------- |
|
|
260
|
-
| Contrôle same-origin | `@nodefony/http` | **actif** (`allowedOrigins: false`) | accepté | `
|
|
261
|
-
| Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003`
|
|
267
|
+
| Barrière | Étage | Défaut | Origin absent | Refus |
|
|
268
|
+
| -------------------- | -------------------- | ----------------------------------- | ------------- | ----------------- |
|
|
269
|
+
| Contrôle same-origin | `@nodefony/http` | **actif** (`allowedOrigins: false`) | accepté | `403` (handshake) |
|
|
270
|
+
| Allowlist stricte | `@nodefony/realtime` | **inactif** (`enabled: false`) | configurable | `close 4003` |
|
|
262
271
|
|
|
263
272
|
**Barrière 1 — transport, active sans rien faire.** `HttpKernel.checkWebsocketOrigin()`
|
|
264
|
-
(`http-kernel.ts
|
|
273
|
+
(`http-kernel.ts#HttpKernel.checkWebsocketOrigin`) exige que l'`Origin` du handshake corresponde au `Host` servi, avec tolérance
|
|
265
274
|
loopback en développement et une allowlist optionnelle (`allowedOrigins`,
|
|
266
|
-
`http/nodefony/config/config.ts
|
|
275
|
+
`http/nodefony/config/config.ts#websocketSchema`) acceptant le hostname exact ou un wildcard à un label. Une
|
|
267
276
|
requête **sans** `Origin` est acceptée : un attaquant non-navigateur n'a pas besoin de CSWSH.
|
|
268
277
|
|
|
269
278
|
**Barrière 2 — module realtime, opt-in et plus stricte.** `RealtimeHub.checkOrigin()`
|
|
270
|
-
(`RealtimeHub.ts
|
|
271
|
-
(`RealtimeService.ts
|
|
279
|
+
(`RealtimeHub.ts#RealtimeHub.checkOrigin`) consulte une garde compilée une fois au boot par `buildOriginGuard()`
|
|
280
|
+
(`RealtimeService.ts#buildOriginGuard`) depuis `checkOriginSchema` (`realtime/nodefony/config/config.ts#checkOriginSchema`).
|
|
272
281
|
Trois différences comptent :
|
|
273
282
|
|
|
274
283
|
1. **Match exact scheme + host + port**, aucun wildcard. Durcissement volontaire par rapport au
|
|
@@ -292,18 +301,18 @@ n'est jamais traitée.
|
|
|
292
301
|
|
|
293
302
|
### Le pipeline exact
|
|
294
303
|
|
|
295
|
-
`RealtimeController.onHandshake()` (`RealtimeController.ts
|
|
304
|
+
`RealtimeController.onHandshake()` (`RealtimeController.ts#RealtimeController.onHandshake`) exécute, une fois par connexion :
|
|
296
305
|
|
|
297
306
|
1. Construction d'un DTO neutre `IRealtimeHandshake` par `buildHandshakeFromContext()`
|
|
298
|
-
(`RealtimeController.ts
|
|
307
|
+
(`RealtimeController.ts#buildHandshakeFromContext`) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune
|
|
299
308
|
dépendance à `@nodefony/security` dans le contrat.
|
|
300
309
|
2. Contrôle d'origine (verrou 1).
|
|
301
|
-
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts
|
|
310
|
+
3. Résolution de l'authenticator par `RealtimeHub.resolveAuthenticator()` (`RealtimeHub.ts#RealtimeHub.resolveAuthenticator`) :
|
|
302
311
|
les matchers sont testés dans l'ordre d'enregistrement, **le premier qui matche capture**.
|
|
303
312
|
4. `authenticator.authenticate(handshake)` — **async autorisé** (on est en cold path, une fois par
|
|
304
313
|
connexion : lire un store est acceptable ici, jamais par frame).
|
|
305
314
|
5. Pose du token sur la WeakMap `peer → token` via `RealtimeHub.setTokenForPeer()`
|
|
306
|
-
(`RealtimeHub.ts
|
|
315
|
+
(`RealtimeHub.ts#RealtimeHub.setTokenForPeer`), **avant** l'envoi du `welcome` : le lookup est garanti dès la première
|
|
307
316
|
frame.
|
|
308
317
|
|
|
309
318
|
Un `throw` de `authenticate()` ferme la socket en `4001` « unauthorized », après un log `WARNING`
|
|
@@ -312,8 +321,8 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
312
321
|
|
|
313
322
|
### Les matchers — quelle porte, quel vigile
|
|
314
323
|
|
|
315
|
-
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts
|
|
316
|
-
`compileMatcher()` (`RealtimeHub.ts
|
|
324
|
+
`RealtimeHub.useAuthenticator()` (`RealtimeHub.ts#RealtimeHub.useAuthenticator`) associe un sélecteur à une stratégie.
|
|
325
|
+
`compileMatcher()` (`RealtimeHub.ts#compileMatcher`) le compile une fois :
|
|
317
326
|
|
|
318
327
|
- `pattern` chaîne → RegExp **préfixe ancrée**, méta-caractères échappés par `RegExp.escape`.
|
|
319
328
|
`"/admin/"` matche `/admin/` et `/admin/foo`, littéralement.
|
|
@@ -321,17 +330,17 @@ d'audit défectueux ne peut pas empêcher la fermeture.
|
|
|
321
330
|
- `host` optionnel → comparaison **stricte** (insensible à la casse) sur l'en-tête `Host`, sans
|
|
322
331
|
wildcard.
|
|
323
332
|
- Le match porte sur le **path**, query comprise, jamais sur l'URL absolue : `handshakePath()`
|
|
324
|
-
(`RealtimeController.ts
|
|
333
|
+
(`RealtimeController.ts#handshakePath`) extrait `pathname + search` du `WebsocketContext.url`, qui est
|
|
325
334
|
absolu. Sans cette extraction, un matcher `^/nodefony/…` ne se déclencherait jamais.
|
|
326
335
|
|
|
327
336
|
`@nodefony/security` enregistre ces matchers automatiquement dans `Firewall.#wireRealtime()`
|
|
328
|
-
(`firewall.ts
|
|
337
|
+
(`firewall.ts#Firewall.#wireRealtime`) : **une instance d'authenticator par zone protégée**, car le hub dédoublonne par
|
|
329
338
|
identité d'instance et une instance partagée n'enregistrerait que le premier matcher.
|
|
330
339
|
|
|
331
340
|
### Zero Trust — il y a toujours un token
|
|
332
341
|
|
|
333
|
-
`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts
|
|
334
|
-
token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts
|
|
342
|
+
`RealtimeHub.getTokenForPeer()` (`RealtimeHub.ts#RealtimeHub.getTokenForPeer`) ne renvoie **jamais** `null` : à défaut de
|
|
343
|
+
token posé, c'est `ANONYMOUS_REALTIME_TOKEN` (`AnonymousRealtimeToken.ts#ANONYMOUS_REALTIME_TOKEN`), singleton gelé,
|
|
335
344
|
`isAuthenticated() === false`, `roles: ["ROLE_ANONYMOUS"]`. Le code consommateur (verrou, audit)
|
|
336
345
|
n'a jamais à écrire « et s'il n'y a pas de token ? ».
|
|
337
346
|
|
|
@@ -346,7 +355,7 @@ n'a jamais à écrire « et s'il n'y a pas de token ? ».
|
|
|
346
355
|
> [!WARNING]
|
|
347
356
|
> La troisième ligne est un comportement à comprendre : un authenticator qui **matche mais ne
|
|
348
357
|
> supporte pas** le handshake fait retomber la connexion en anonyme, sans fermeture. C'est le cas
|
|
349
|
-
> de `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts
|
|
358
|
+
> de `FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.supports`) quand aucune
|
|
350
359
|
> identité authentifiée n'est en ALS. Ce n'est pas un trou **parce que** le firewall HTTP a déjà
|
|
351
360
|
> refusé l'anonyme en amont sur une zone protégée (Zero Trust appliqué à la requête d'upgrade). Sur
|
|
352
361
|
> une route WS **hors zone**, en revanche, la connexion anonyme aboutit : la protection y repose
|
|
@@ -422,13 +431,13 @@ sequenceDiagram
|
|
|
422
431
|
P-->>C: realtime:denied {channel, reason:"forbidden"}
|
|
423
432
|
```
|
|
424
433
|
|
|
425
|
-
Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts
|
|
434
|
+
Le verrou est **strictement synchrone** — `FrameAuthorizer` (`RealtimeHub.ts#FrameAuthorizer`). Un `await` par
|
|
426
435
|
frame coûterait une microtask et sérialiserait le pipeline RPC de la connexion. Il ne lit que de la
|
|
427
436
|
mémoire : le token déjà résolu, et la cible de la frame.
|
|
428
437
|
|
|
429
438
|
### Les trois surfaces gardées
|
|
430
439
|
|
|
431
|
-
`buildFrameAuthorizer()` (`frameAuthorizer.ts
|
|
440
|
+
`buildFrameAuthorizer()` (`frameAuthorizer.ts#buildFrameAuthorizer`) ne garde que ce qui atteint des données :
|
|
432
441
|
|
|
433
442
|
<!-- prettier-ignore -->
|
|
434
443
|
| Frame | Contrôle appliqué |
|
|
@@ -439,9 +448,9 @@ mémoire : le token déjà résolu, et la cible de la frame.
|
|
|
439
448
|
| action `@RealtimeAction` | Authentifié **par défaut** ; rôle/scope si déclarés ; ouverte seulement si `{ authenticated: false }` est écrit |
|
|
440
449
|
| `ping`, `unsubscribe` | **passent** — pas de surface de données |
|
|
441
450
|
|
|
442
|
-
Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts
|
|
451
|
+
Deux détails évitent des faux refus : `authorizeApiRequest()` (`frameAuthorizer.ts#authorizeApiRequest`) laisse
|
|
443
452
|
passer une frame au `path` invalide (le handler renverra `-32602` — le verrou ne duplique pas la
|
|
444
|
-
validation), et `authorizeChannel()` (`frameAuthorizer.ts
|
|
453
|
+
validation), et `authorizeChannel()` (`frameAuthorizer.ts#authorizeChannel`) laisse passer un `channel`
|
|
445
454
|
non-chaîne (`startChannel` ignore de toute façon un canal absent).
|
|
446
455
|
|
|
447
456
|
### Le plancher système — les namespaces réservés
|
|
@@ -449,51 +458,51 @@ non-chaîne (`startChannel` ignore de toute façon un canal absent).
|
|
|
449
458
|
Certains namespaces exposent l'intérieur du pod. Ils portent une politique par défaut que la config
|
|
450
459
|
peut **resserrer**, jamais desserrer.
|
|
451
460
|
|
|
452
|
-
| Cible | Politique par défaut
|
|
453
|
-
| ------------------------------------------ |
|
|
454
|
-
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts
|
|
455
|
-
| tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts
|
|
456
|
-
| tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts
|
|
457
|
-
| tout le reste | libre, sauf policy déclarée
|
|
461
|
+
| Cible | Politique par défaut |
|
|
462
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
463
|
+
| `nodefony:audit` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#SECURITY_CHANNEL_POLICY`) |
|
|
464
|
+
| tout le reste de `nodefony:` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY`) |
|
|
465
|
+
| tout canal contenant `:health` ou `:stats` | authentifié + `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts#matchSystemPolicy`) |
|
|
466
|
+
| tout le reste | libre, sauf policy déclarée |
|
|
458
467
|
|
|
459
468
|
Le journal d'audit est un **canal précis**, pas un namespace : sa règle est posée devant celle du
|
|
460
469
|
territoire (premier match gagnant), et elle n'existe que si le hub réserve bien ce territoire.
|
|
461
470
|
|
|
462
471
|
Trois durcissements méritent d'être connus :
|
|
463
472
|
|
|
464
|
-
- **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts
|
|
473
|
+
- **Match insensible à la casse, sans allocation** — `startsWithCI()` (`frameAuthorizer.ts#buildSystemRules`).
|
|
465
474
|
Un `NODEFONY:syslog` ne contourne pas le plancher `nodefony:` par un changement de casse.
|
|
466
|
-
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts
|
|
475
|
+
- **Plancher irréductible** — `floorReserved()` (`frameAuthorizer.ts#floorReserved`) : une règle de config qui
|
|
467
476
|
tenterait d'ouvrir un namespace réservé (`{ authenticated: false }`) se voit ré-imposer
|
|
468
477
|
`authenticated: true`. Le test porte sur le **namespace du canal**, pas sur le préfixe de la règle
|
|
469
478
|
qui a matché : un préfixe de config plus court ou altéré ne contourne rien.
|
|
470
479
|
- **La config passe avant les défauts** — les règles de `realtimeChannels`
|
|
471
|
-
(`security/nodefony/config/config.ts
|
|
480
|
+
(`security/nodefony/config/config.ts#securityConfigSchema`) sont placées en tête, premier match gagnant. On peut
|
|
472
481
|
donc re-cibler `nodefony:syslog` sur `ROLE_SECURITY_AUDITOR` ; on ne peut pas l'ouvrir à l'anonyme.
|
|
473
482
|
|
|
474
483
|
Le canal du journal d'audit (`nodefony:audit`) est enregistré comme **canal système** sur le hub
|
|
475
|
-
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts
|
|
484
|
+
(`RealtimeHub.registerSystemChannel()`, `RealtimeHub.ts#RealtimeHub.registerSystemChannel`) : il devient servable par n'importe
|
|
476
485
|
quel endpoint realtime, sans qu'aucun controller ne le connaisse — et il est gardé par sa règle
|
|
477
486
|
dédiée. Son enregistrement est **couplé** à la pose du verrou (même condition), donc il n'existe
|
|
478
487
|
jamais de canal d'audit non gardé.
|
|
479
488
|
|
|
480
489
|
### La policy métier — déclarer sur le canal
|
|
481
490
|
|
|
482
|
-
`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts
|
|
483
|
-
(`realtimeDecorators.ts
|
|
491
|
+
`@RealtimeChannel(name, policy)` (`realtimeDecorators.ts#RealtimeChannel`) et `@RealtimeInbound(name, policy)`
|
|
492
|
+
(`realtimeDecorators.ts#RealtimeInbound`) attachent un `IChannelPolicy` au **nom** du canal. Les trois axes sont
|
|
484
493
|
cumulatifs (ET) ; un axe absent n'impose rien :
|
|
485
494
|
|
|
486
|
-
| Axe | Sens | Évalué par
|
|
487
|
-
| --------------- | ----------------------------------------------------------------------------- |
|
|
488
|
-
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts
|
|
489
|
-
| `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts
|
|
490
|
-
| `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe
|
|
495
|
+
| Axe | Sens | Évalué par |
|
|
496
|
+
| --------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
|
|
497
|
+
| `authenticated` | token non anonyme | `satisfies()` (`frameAuthorizer.ts#satisfies`) |
|
|
498
|
+
| `roles` | un des rôles suffit, **hiérarchie comprise** | `Firewall.hasRole()` (`firewall.ts#Firewall.hasRole`) |
|
|
499
|
+
| `scopes` | un des scopes suffit — axe API (JWT, clé API), une session BFF n'en porte pas | comparaison directe |
|
|
491
500
|
|
|
492
|
-
Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts
|
|
501
|
+
Une policy **vide** n'est pas enregistrée : `definePolicy()` (`realtimeDecorators.ts#definePolicy`) ignore un
|
|
493
502
|
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
|
|
503
|
+
au hub **au handshake**, pas au boot (`RealtimeHub.registerChannelPolicy()`, `RealtimeHub.ts#RealtimeHub.registerChannelPolicy`,
|
|
495
504
|
idempotent) ; le décideur les relit par `RealtimeService.resolveChannelPolicy()`
|
|
496
|
-
(`RealtimeService.ts
|
|
505
|
+
(`RealtimeService.ts#RealtimeService.resolveChannelPolicy`).
|
|
497
506
|
|
|
498
507
|
### ⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n'héritent pas
|
|
499
508
|
|
|
@@ -507,12 +516,14 @@ C'est le piège le plus facile à ne pas voir, parce que tout a l'air correct :
|
|
|
507
516
|
là, le canal fonctionne, et le refus attendu n'arrive jamais.
|
|
508
517
|
|
|
509
518
|
Le serveur le **dit** désormais. Au premier canal servi dynamiquement sans politique, alors que le
|
|
510
|
-
module en déclare pour d'autres canaux
|
|
511
|
-
|
|
519
|
+
module en déclare pour d'autres canaux (hors canaux de plateforme `nodefony:`, que leur plancher
|
|
520
|
+
garde déjà), un avertissement est journalisé une fois
|
|
521
|
+
(`RealtimeHub.noticeUnguardedDynamicChannel()`, `RealtimeHub.ts#RealtimeHub.noticeUnguardedDynamicChannel`) :
|
|
512
522
|
|
|
513
523
|
```
|
|
514
|
-
le canal "chat:room:1000"
|
|
515
|
-
|
|
524
|
+
le canal "chat:room:1000" n'a aucune règle d'accès : tout client connecté peut s'y abonner.
|
|
525
|
+
Il est produit par createRealtimeChannel(), et une règle déclarée sur un nom exact ne couvre pas
|
|
526
|
+
ses variantes (suffixe de cadence, identifiant). […]
|
|
516
527
|
```
|
|
517
528
|
|
|
518
529
|
Deux façons de fermer, selon l'intention :
|
|
@@ -535,21 +546,21 @@ Le verrou n'existe que si quelqu'un le pose. Deux conditions doivent être vraie
|
|
|
535
546
|
1. `@nodefony/security` est chargé, et
|
|
536
547
|
2. au moins une zone a `security: true` **et** `realtime: true`.
|
|
537
548
|
|
|
538
|
-
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts
|
|
549
|
+
C'est exactement le test de `Firewall.#wireRealtime()` (`firewall.ts#Firewall.#wireRealtime`) : sans zone qualifiante,
|
|
539
550
|
`wired` reste faux, `setFrameAuthorizer` n'est jamais appelé, et **aucune** policy de canal n'est
|
|
540
551
|
évaluée — ni métier, ni système. `nodefony:syslog` redevient un canal ordinaire.
|
|
541
552
|
|
|
542
553
|
Deuxième subtilité : `beforeDispatch` n'est branché sur une connexion que si le verrou est **déjà**
|
|
543
|
-
posé au moment de son handshake (`RealtimeController.ts
|
|
544
|
-
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts
|
|
554
|
+
posé au moment de son handshake (`RealtimeController.ts#RealtimeController.onHandshake`, via
|
|
555
|
+
`RealtimeHub.hasFrameAuthorizer()` — `RealtimeHub.ts#RealtimeHub.hasFrameAuthorizer`). Choix de performance délibéré (un hub
|
|
545
556
|
non sécurisé garde un coût nul par frame), mais avec une conséquence : **une connexion ouverte avant
|
|
546
557
|
la pose du verrou n'est jamais gardée**, et ce jusqu'à sa fermeture. En fonctionnement normal le
|
|
547
558
|
firewall se construit au boot, avant tout trafic ; le cas ne se présente qu'en appelant
|
|
548
559
|
`setFrameAuthorizer()` à chaud.
|
|
549
560
|
|
|
550
561
|
**Le refus de dégrader en silence.** Quand des policies sont déclarées sans décideur câblé,
|
|
551
|
-
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts
|
|
552
|
-
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts
|
|
562
|
+
`RealtimeHub.hasUnenforcedChannelPolicies()` (`RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies`) renvoie `true` et le controller
|
|
563
|
+
émet un WARNING explicite, une seule fois par process (`RealtimeController.ts#RealtimeController.onHandshake`) :
|
|
553
564
|
|
|
554
565
|
```text
|
|
555
566
|
Realtime channel policies declared but NO frame authorizer is wired —
|
|
@@ -573,12 +584,12 @@ l'alerte. Le test ne prouve pas que le canal est fermé — il prouve que le fra
|
|
|
573
584
|
|
|
574
585
|
Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :
|
|
575
586
|
|
|
576
|
-
| Type de frame refusée | Réponse au client
|
|
577
|
-
| ----------------------------------- |
|
|
578
|
-
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts
|
|
579
|
-
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts
|
|
580
|
-
| Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }`
|
|
581
|
-
| Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }`
|
|
587
|
+
| Type de frame refusée | Réponse au client |
|
|
588
|
+
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
589
|
+
| Requête (avec `id`) | `-32001 "unauthorized"` — `JsonRpcPeer.receive()` (`JsonRpcPeer.ts#JsonRpcPeer.receive`) |
|
|
590
|
+
| Notification (`subscribe`, inbound) | `realtime:denied { channel, reason: "forbidden" }` (`RealtimeController.ts#RealtimeController.onHandshake`) |
|
|
591
|
+
| Dépassement du plafond de canaux | `realtime:denied { channel, reason: "limit" }` |
|
|
592
|
+
| Canal sans aucun producteur | `realtime:denied { channel, reason: "unknown" }` |
|
|
582
593
|
|
|
583
594
|
Le message est **générique** dans les trois cas : jamais « il te manque `ROLE_ADMIN` », jamais le nom
|
|
584
595
|
de la zone. Sans `realtime:denied`, une notification refusée serait droppée en silence (elle n'a pas
|
|
@@ -590,7 +601,7 @@ Le motif `limit` est volontairement distinct de
|
|
|
590
601
|
chercher un problème de droits.
|
|
591
602
|
|
|
592
603
|
Côté serveur, chaque refus alimente le journal d'audit via le rapporteur `onDeny`
|
|
593
|
-
(`FrameDenyReporter`, `frameAuthorizer.ts
|
|
604
|
+
(`FrameDenyReporter`, `frameAuthorizer.ts#FrameDenyReporter`), tiré **uniquement** sur refus — le chemin autorisé
|
|
594
605
|
n'alloue rien. L'entrée porte `category: "ws"`, `action: "frame.denied"`, l'acteur, la ressource et
|
|
595
606
|
une raison machine stable (`zone_protected` ou `channel_policy`).
|
|
596
607
|
|
|
@@ -602,20 +613,20 @@ ne peut pas relire la session. Un administrateur qui se déconnecte garderait se
|
|
|
602
613
|
|
|
603
614
|
Nodefony ferme l'écart par deux mécanismes de granularité différente.
|
|
604
615
|
|
|
605
|
-
| Surface | Re-validation | Fenêtre d'exposition | Où
|
|
606
|
-
| --------------------------- | -------------------- | -------------------- |
|
|
607
|
-
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts
|
|
608
|
-
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts
|
|
616
|
+
| Surface | Re-validation | Fenêtre d'exposition | Où |
|
|
617
|
+
| --------------------------- | -------------------- | -------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
618
|
+
| `api.request` (data plane) | **à chaque frame** | nulle | `RealtimeController.invokeApiRequest()` (`RealtimeController.ts#RealtimeController.invokeApiRequest`) |
|
|
619
|
+
| `subscribe` / flux de canal | **périodique**, 30 s | ≤ 30 s | `RealtimeHub.revalidateRevocable()` (`RealtimeHub.ts#RealtimeHub.revalidateRevocable`) |
|
|
609
620
|
|
|
610
621
|
**Sur `api.request`**, `token.isValid()` est appelé avant l'exécution de l'action ; identité périmée
|
|
611
622
|
ou changée → `-32000` avec `status: 401`, et le client bascule sur un `fetch` HTTP porteur du cookie
|
|
612
623
|
courant. Une erreur de re-validation vaut refus (fail-closed).
|
|
613
624
|
|
|
614
625
|
**Sur les canaux**, le hub n'inscrit au registre de révocation que les connexions dont le token porte
|
|
615
|
-
`isValid` (`RealtimeController.ts
|
|
616
|
-
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts
|
|
626
|
+
`isValid` (`RealtimeController.ts#RealtimeController.onHandshake`) — anonymes et JWT n'y entrent jamais, coût nul.
|
|
627
|
+
`RealtimeHub.registerRevocable()` (`RealtimeHub.ts#RealtimeHub.registerRevocable`) démarre un `setInterval` `unref` au premier
|
|
617
628
|
inscrit et l'arrête dès que le registre se vide : zéro timer au repos. Période :
|
|
618
|
-
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts
|
|
629
|
+
`REVOCATION_REVALIDATE_MS` (`RealtimeHub.ts#channelFamily`), 30 s, alignée sur le heartbeat WS.
|
|
619
630
|
|
|
620
631
|
Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
|
|
621
632
|
|
|
@@ -629,7 +640,7 @@ Ce que `realtimeRevocation.attack.test.ts` prouve exactement :
|
|
|
629
640
|
| registre vide | tick no-op, aucun crash |
|
|
630
641
|
|
|
631
642
|
La source de vérité côté security est `buildSessionRevalidator()`
|
|
632
|
-
(`FirewallRealtimeAuthenticator.ts
|
|
643
|
+
(`FirewallRealtimeAuthenticator.ts#buildSessionRevalidator`) : il relit la session BFF **par son id capturé au handshake**
|
|
633
644
|
et vérifie qu'elle est toujours vivante **et toujours celle du même utilisateur** — ce second point
|
|
634
645
|
attrape le changement de compte sur un navigateur partagé.
|
|
635
646
|
|
|
@@ -640,7 +651,7 @@ attrape le changement de compte sur un navigateur partagé.
|
|
|
640
651
|
> immédiate, fermer la socket explicitement côté serveur.
|
|
641
652
|
> 2. **Best-effort si la session est illisible au handshake.** Si l'id ou le store ne sont pas
|
|
642
653
|
> accessibles, `buildSessionRevalidator()` renvoie `null` ; `UserRealtimeToken.isValid()`
|
|
643
|
-
> (`UserRealtimeToken.ts
|
|
654
|
+
> (`UserRealtimeToken.ts#UserRealtimeToken.isValid`) répond alors toujours `true`. La connexion **est** inscrite au
|
|
644
655
|
> registre (le token expose bien `isValid`) mais ne sera **jamais** révoquée. Choix assumé pour
|
|
645
656
|
> éviter les faux refus — à connaître, car cela ne se voit pas.
|
|
646
657
|
> 3. **Les identités sans `isValid` ne sont pas révocables du tout** : anonyme, JWT, clé API. Pour
|
|
@@ -656,12 +667,12 @@ pas** borné.
|
|
|
656
667
|
Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion
|
|
657
668
|
peut abonner jusqu'à l'OOM — un déni de service mémoire déclenché par **un seul** client.
|
|
658
669
|
|
|
659
|
-
`RealtimeController.startChannel()` (`RealtimeController.ts
|
|
660
|
-
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts
|
|
670
|
+
`RealtimeController.startChannel()` (`RealtimeController.ts#RealtimeController.startChannel`) refuse au-delà de
|
|
671
|
+
`limits.maxChannelsPerConnection` (`realtime/nodefony/config/config.ts#limitsSchema`), défaut **256**,
|
|
661
672
|
`null` pour illimité. Points prouvés par `realtimeChannelCap.attack.test.ts` :
|
|
662
673
|
|
|
663
674
|
- le plafond est actif **même sans `RealtimeService`** — le hub porte le défaut 256
|
|
664
|
-
(`RealtimeHub.ts
|
|
675
|
+
(`RealtimeHub.ts#RealtimeHub.#slowConsumerBytes`), il n'y a pas de fenêtre où la garde n'existerait pas ;
|
|
665
676
|
- au-delà, le hub **n'est jamais appelé** : le canal n'est pas ouvert, aucun provider ne démarre ;
|
|
666
677
|
- le refus est **observable** (`realtime:denied`, motif `limit`) ;
|
|
667
678
|
- un re-`subscribe` d'un canal déjà tenu **ne consomme pas de slot** (l'idempotence est vérifiée
|
|
@@ -676,16 +687,16 @@ amplificateur.
|
|
|
676
687
|
|
|
677
688
|
Un onglet en arrière-plan, un mobile en zone blanche, une fenêtre TCP pleine : la file d'envoi
|
|
678
689
|
grossit sans borne, et le multiplexage concentre le risque (une socket lente bloque tous ses
|
|
679
|
-
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts
|
|
690
|
+
canaux). `WsConnectionTransport.send()` (`WsConnectionTransport.ts#WsConnectionTransport.send`) applique deux seuils :
|
|
680
691
|
|
|
681
|
-
| `bufferedAmount`
|
|
682
|
-
|
|
|
683
|
-
| > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts
|
|
684
|
-
| `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut
|
|
692
|
+
| `bufferedAmount` | Action |
|
|
693
|
+
| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
|
|
694
|
+
| > `websocket.maxBackpressure` — 4 MiB par défaut (`http`, `http/nodefony/config/config.ts#httpConfigSchema`) | politique `websocket.backpressurePolicy` : `drop` (défaut) jette la frame — canaux d'état, le prochain snapshot la remplace |
|
|
695
|
+
| `websocket.backpressureCloseAfterDrops` drops CONSÉCUTIFS — 1000 par défaut | `close(1013)` « Try Again Later » ; le client se reconnecte et resynchronise |
|
|
685
696
|
|
|
686
697
|
### Taille des messages entrants
|
|
687
698
|
|
|
688
|
-
Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts
|
|
699
|
+
Portée par `@nodefony/http` : `websocket.maxPayload` (`http/nodefony/config/config.ts#websocketSchema`), défaut
|
|
689
700
|
**1 MiB**, au-delà fermeture RFC 6455 `1009` « Message Too Big ». C'est un durcissement par rapport
|
|
690
701
|
au défaut de la librairie `ws`.
|
|
691
702
|
|
|
@@ -703,7 +714,7 @@ au défaut de la librairie `ws`.
|
|
|
703
714
|
|
|
704
715
|
Seules les clés à **effet de sécurité** figurent ici ; le catalogue complet est dans
|
|
705
716
|
[`configuration.md`](./configuration.md).
|
|
706
|
-
Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/config.ts
|
|
717
|
+
Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/config.ts#realtimeConfigSchema`).
|
|
707
718
|
|
|
708
719
|
| Clé | Défaut | Effet de sécurité |
|
|
709
720
|
| ------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
|
|
@@ -728,9 +739,9 @@ Source unique des défauts : `realtimeConfigSchema` (`realtime/nodefony/config/c
|
|
|
728
739
|
Cette section existe pour éviter une confiance mal placée. Chaque point est vérifié au code.
|
|
729
740
|
|
|
730
741
|
**1. Il n'y a pas de frontière de canal entre modules.** Un nom de canal est une chaîne dans un
|
|
731
|
-
registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts
|
|
742
|
+
registre global (`#channelPolicies` du hub singleton, `RealtimeHub.ts#RealtimeHub.#channelPolicies`). Rien n'empêche le
|
|
732
743
|
controller du module A de servir un canal au nom d'un canal du module B, ni de déclarer une policy
|
|
733
|
-
plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts
|
|
744
|
+
plus faible sur ce même nom — la déclaration la plus récente écrase (`RealtimeHub.ts#RealtimeHub.#admitFromBackplane`, idempotent
|
|
734
745
|
par écrasement). En pratique, l'exploitation exige que le controller fautif accepte le nom (sa
|
|
735
746
|
factory doit renvoyer un provider), donc un override permissif de `createRealtimeChannel` ; et les
|
|
736
747
|
namespaces réservés restent couverts par le plancher système **si** le verrou est posé. Une garde
|
|
@@ -757,7 +768,7 @@ distincts.
|
|
|
757
768
|
confidentialité et l'intégrité sont déléguées à TLS — `wss://` n'est pas optionnel en production.
|
|
758
769
|
|
|
759
770
|
**6. `enabled: false` désarme aussi les gardes du module.** Le service devient inerte
|
|
760
|
-
(`RealtimeService.init()`, `RealtimeService.ts
|
|
771
|
+
(`RealtimeService.init()`, `RealtimeService.ts#RealtimeService.init`) : ni garde d'origine, ni plafond de canaux posés
|
|
761
772
|
depuis la config. Le hub conserve son défaut de 256 canaux, mais l'allowlist d'origines configurée
|
|
762
773
|
n'est pas appliquée.
|
|
763
774
|
|
|
@@ -777,7 +788,7 @@ n'est pas appliquée.
|
|
|
777
788
|
## 📡 Observabilité — Studio
|
|
778
789
|
|
|
779
790
|
- **Santé de la socket** : `/nodefony/realtime/api/health`, alimenté par `RealtimeHub.probe()`
|
|
780
|
-
(`RealtimeHub.ts
|
|
791
|
+
(`RealtimeHub.ts#RealtimeHub.probe`) — canaux, abonnés, fan-out, connexions, back-pressure et carte d'identité
|
|
781
792
|
du backplane. Un `drops` qui grimpe signale des clients en souffrance ; un `slowConsumers` non nul
|
|
782
793
|
précède souvent une fermeture `1013`. Même snapshot en flux sur le canal `nodefony:socket`
|
|
783
794
|
(namespace réservé : `ROLE_NODEFONY_ADMIN`).
|