Nodefony 10.0.0-beta.6
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Sécurité du temps réel — qui entre, qui écoute, jusqu'à quand

stable@nodefony/realtimemis à jour 2026-07-19

Une WebSocket n’est pas une requête, c’est une porte qui reste ouverte. Nodefony la garde avec quatre verrous : l’origine à l’entrée, l’identité au handshake, l’autorisation par frame, la révocation dans la durée. Cette page dit ce que chaque verrou bloque et où il s’arrête — une protection partielle y est annoncée comme partielle.

📍 Documentation › Realtime › Sécurité

🧠 Le modèle mental — un transporteur, un décideur#

La règle qui explique tout le reste : @nodefony/realtime ne décide jamais d’une autorisation. Il transporte des déclarations (« ce canal exige ROLE_ADMIN ») jusqu’à un décideur, et @nodefony/security est ce décideur. Le module realtime ignore la hiérarchie de rôles, ignore la session, ignore l’utilisateur réel.

Conséquence directe, à garder en tête pendant toute la lecture : sans @nodefony/security câblé, aucune politique de canal n’est appliquée. Le transport fonctionne, les déclarations sont collectées, personne ne les fait respecter.

Les étapes 1 et 2 appartiennent à @nodefony/http et @nodefony/security : la requête d’upgrade est une requête HTTP, elle traverse le pipeline normal avant d’atteindre le realtime. Les étapes 3, 4 et le verrou de frame sont portés par ce module.

📖 Lexique#

Terme Sens
Handshake La requête HTTP Upgrade: websocket qui ouvre la connexion. Tout le contrôle d’identité s’y joue.
Frame Un message JSON-RPC 2.0 circulant une fois la porte ouverte (subscribe, api.request, notification).
Canal (channel) Un flux nommé auquel on s’abonne (orders:feed, nodefony:syslog) ; le hub le diffuse à tous ses abonnés.
CSWSH Cross-Site WebSocket Hijacking : un site tiers ouvre une WS vers ton app, avec le cookie de la victime.
Origin En-tête RFC 6455 §10.2 disant d’où vient la page qui ouvre la socket. Seule preuve d’origine disponible.
Token realtime IRealtimeToken — carte d’identité de la connexion, figée au handshake, lue en O(1) à chaque frame.
Verrou de frame FrameAuthorizer — fonction sync qui accepte ou refuse une frame. Posée par security au boot.
Policy de canal IChannelPolicy — exigences déclarées sur un canal (authenticated, roles, scopes).
Namespace réservé Le préfixe nodefony:, qui marque les canaux de la plateforme (nodefony:syslog, nodefony:audit…) et porte un plancher d’autorisation que la config ne peut pas descendre.
Zero Trust Aucune identité n’est supposée : un visiteur porte toujours un token, anonyme par défaut.
Fail-closed / loud En cas de doute on refuse ; toute dégradation de sécurité est annoncée (WARNING), jamais silencieuse.
Backplane Le bus qui propage les publications entre pods (Redis, IPC cluster).
BFF Backend-For-Frontend : la session serveur (cookie opaque) qui porte l’identité web.

🔐 Qu’est-ce que ça défend, concrètement ?#

Une WebSocket authentifiée est une cible de choix : elle porte une identité, vit longtemps et diffuse en continu.

Attaque Ce qui la bloque Où
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
É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
É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
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
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
DoS mémoire par abonnements — une connexion ouvre des milliers de canaux Plafond de canaux par connexion (256 par défaut) limits.maxChannelsPerConnection
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
Oracle d’autorisation — sonder les canaux pour cartographier les droits Motif de refus générique (forbidden), jamais le détail realtime:denied

❗ Important

Les lignes 2 à 5 supposent @nodefony/security chargé avec au moins une zone protégée. Sans cela, le verrou de frame n’est jamais posé et ces défenses sont inertes — c’est la condition détaillée au verrou 3, le point le plus important de la page.

🎯 La vision Nodefony — une seule politique, deux transports#

HTTP et WebSocket vivent dans le même contexte : la sécurité en hérite, on n’écrit pas deux politiques.

Le compromis assumé : l’identité est figée au handshake, les frames lisent un cache O(1). C’est ce qui rend le temps réel tenable, et ce qui crée le besoin d’un tick de révocation (verrou 4).

🚀 Démarrage rapide#

Objectif : dans une app générée par nodefony create app, exposer un canal orders:feed réservé aux administrateurs, et voir ce que reçoit un client non autorisé.

1. Déclarer la zone et activer l’origine stricte#

// nodefony/config/security.ts — la zone du handshake et la hiérarchie de rôles
import type { ISecurityConfigInput } from "@nodefony/security";

export const securityConfig = () =>
  ({
    areas: {
      // Zone protégée : le handshake WS de /rt passe par le MÊME contrôle que
      // le HTTP. `realtime` vaut true par défaut → ne pas l'écrire suffit.
      app: {
        pattern: "^/rt",
        authenticators: ["session"],
      },
    },
    roleHierarchy: {
      ROLE_ADMIN: ["ROLE_USER"],
    },
  }) satisfies ISecurityConfigInput;
// nodefony/config/realtime.ts — la barrière d'origine et la garde anti-OOM
import type { IRealtimeConfigInput } from "@nodefony/realtime";

export const realtimeConfig = () =>
  ({
    csrf: {
      checkOrigin: {
        // Deuxième barrière d'origine : allowlist EXACTE (pas de wildcard).
        enabled: true,
        allowList: ["https://app.exemple.com", "https://127.0.0.1:5152"],
        // Un client sans en-tête Origin (mobile natif, script) est refusé.
        allowMissingOrigin: false,
      },
    },
    limits: {
      // Garde anti-OOM : au-delà, le subscribe est refusé (pas de close).
      maxChannelsPerConnection: 64,
    },
  }) satisfies IRealtimeConfigInput;

2. Le controller : un canal libre, un canal gardé#

// nodefony/controllers/OrdersRealtimeController.ts — compile tel quel
import { controller, route } from "@nodefony/framework";
import { Context } from "@nodefony/http";
import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
import type { RealtimePublish } from "@nodefony/realtime";

@controller("/rt")
class OrdersRealtimeController extends RealtimeController {
  constructor(context: Context) {
    super("OrdersRealtimeController", context);
  }

  @route("orders-ws", {
    path: "/orders",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async realtime(message: string | Buffer | null): Promise<void> {
    this.handleRealtime(message);
  }

  // Canal PUBLIC : aucune policy déclarée → libre pour tout connecté.
  @RealtimeChannel("orders:public")
  publicFeed(channel: string, publish: RealtimePublish): () => void {
    const timer = setInterval(() => publish(channel, { ts: Date.now() }), 2000);
    return () => clearInterval(timer);
  }

  // Canal GARDÉ : la policy est une DÉCLARATION, transportée jusqu'à
  // @nodefony/security qui l'évalue (hiérarchie de rôles comprise).
  @RealtimeChannel("orders:feed", {
    authenticated: true,
    roles: ["ROLE_ADMIN"],
  })
  adminFeed(channel: string, publish: RealtimePublish): () => void {
    const timer = setInterval(() => publish(channel, { pending: 3 }), 1000);
    return () => clearInterval(timer);
  }
}

export default OrdersRealtimeController;

3. Ce qu’observe le client refusé#

Un ROLE_USER connecté demande les deux canaux. Le premier marche, le second est refusé — et le refus est visible, jamais un silence.

// frontend — le client isomorphe du core
import { NodefonySocket } from "nodefony/client";

const socket = new NodefonySocket({ url: "wss://app.exemple.com/rt/orders" });

// Refus poussés par le serveur — première classe côté client.
socket.onDenied((denied) => {
  // { channel: "orders:feed", reason: "forbidden" }
  // Motif GÉNÉRIQUE : jamais « il te manque ROLE_ADMIN » (pas d'oracle).
  console.warn("canal refusé", denied.channel, denied.reason);
});

await socket.connect();
socket.subscribe("orders:public"); // ✅ ticks reçus
socket.subscribe("orders:feed"); // ❌ aucun tick, un realtime:denied à la place

Ce qu’on observe côté serveur : un log WARNING « WS realtime frame refused by authorizer » et une entrée d’audit category: "ws", action: "frame.denied", outcome: "denied", avec l’acteur et le canal visé. Le canal n’est jamais ouvert : le provider ne démarre pas, le hub n’est même pas appelé.

⚠️ Attention

Ce démarrage rapide ne protège orders:feed que si @nodefony/security est chargé et que la zone app reste protégée. Retire le module, ou passe la zone en realtime: false, et le canal redevient ouvert à tout connecté — le serveur le signalera par un WARNING au boot, mais il ne refusera pas de démarrer.

🛡️ Verrou 1 — l’origine : bloquer le CSWSH#

Le navigateur n’applique pas CORS aux WebSockets. N’importe quelle page peut ouvrir une socket vers ton domaine, et le navigateur y joindra les cookies de la victime. L’en-tête Origin (RFC 6455 §10.2) est la seule information exploitable côté serveur.

Nodefony pose deux barrières successives, à deux étages différents.

Barrière Étage Défaut Origin absent Refus
Contrôle same-origin @nodefony/http actif (allowedOrigins: false) accepté 403 (handshake)
Allowlist stricte @nodefony/realtime inactif (enabled: false) configurable close 4003

Barrière 1 — transport, active sans rien faire. HttpKernel.checkWebsocketOrigin() (http-kernel.ts#HttpKernel.checkWebsocketOrigin) exige que l’Origin du handshake corresponde au Host servi, avec tolérance loopback en développement et une allowlist optionnelle (allowedOrigins, http/nodefony/config/config.ts#websocketSchema) acceptant le hostname exact ou un wildcard à un label. Une requête sans Origin est acceptée : un attaquant non-navigateur n’a pas besoin de CSWSH.

Barrière 2 — module realtime, opt-in et plus stricte. RealtimeHub.checkOrigin() (RealtimeHub.ts#RealtimeHub.checkOrigin) consulte une garde compilée une fois au boot par buildOriginGuard() (RealtimeService.ts#buildOriginGuard) depuis checkOriginSchema (realtime/nodefony/config/config.ts#checkOriginSchema). Trois différences comptent :

  1. Match exact scheme + host + port, aucun wildcard. Durcissement volontaire par rapport au CORS Access-Control-Allow-Origin: *.
  2. Origin absent → refusé par défaut (allowMissingOrigin: false). C’est le seul moyen de fermer la porte aux clients non-navigateur.
  3. Fail-closed : enabled: true avec une allowList vide refuse tout. Une erreur de configuration ferme la porte, elle ne l’ouvre pas.

Le refus est journalisé (WARNING, avec l’origine reçue) puis la socket est fermée en 4003 « origin not allowed » — plage 4000-4999 réservée aux applications (RFC 6455 §7.4.2). Aucune frame n’est jamais traitée.

🛑 Prudence

Ce que l’Origin ne prouve pas. C’est une déclaration du navigateur, pas une preuve cryptographique : un client non-navigateur peut l’inventer. L’Origin protège les victimes de navigateur (CSWSH), pas contre un attaquant qui contrôle son propre client. La défense contre ce dernier, c’est l’authentification — verrou suivant.

🔐 Verrou 2 — l’identité : une seule fois, au handshake#

Le pipeline exact#

RealtimeController.onHandshake() (RealtimeController.ts#RealtimeController.onHandshake) exécute, une fois par connexion :

  1. Construction d’un DTO neutre IRealtimeHandshake par buildHandshakeFromContext() (RealtimeController.ts#buildHandshakeFromContext) — headers, cookies aplatis, url, origin, sous-protocoles. Aucune dépendance à @nodefony/security dans le contrat.
  2. Contrôle d’origine (verrou 1).
  3. Résolution de l’authenticator par RealtimeHub.resolveAuthenticator() (RealtimeHub.ts#RealtimeHub.resolveAuthenticator) : les matchers sont testés dans l’ordre d’enregistrement, le premier qui matche capture.
  4. authenticator.authenticate(handshake) — async autorisé (on est en cold path, une fois par connexion : lire un store est acceptable ici, jamais par frame), et borné par authenticateTimeoutMs (10 s par défaut, admissionBudget.ts#AdmissionBudget.authenticateWithin). Au-delà, la socket est fermée en 1013 « try again later » : le client recule puis revient, et une identité arrivée après coup est ignorée. Ni onFailure ni 4001 — une base en panne n’est pas un refus d’identité, et un code définitif arrêterait un client légitime à chaque panne. Compteur authenticateTimedOutTotal, un WARNING par fenêtre de 10 s. Un client parti pendant l’authentification n’est jamais inscrit au hub.
  5. Pose du token sur la WeakMap peer → token via RealtimeHub.setTokenForPeer() (RealtimeHub.ts#RealtimeHub.setTokenForPeer), avant l’envoi du welcome : le lookup est garanti dès la première frame.

Un throw de authenticate() ferme la socket en 4001 « unauthorized », après un log WARNING nommant l’authenticator fautif. Le hook onFailure est invoqué dans un try/catch : un hook d’audit défectueux ne peut pas empêcher la fermeture.

Les matchers — quelle porte, quel vigile#

RealtimeHub.useAuthenticator() (RealtimeHub.ts#RealtimeHub.useAuthenticator) associe un sélecteur à une stratégie. compileMatcher() (RealtimeHub.ts#compileMatcher) le compile une fois :

@nodefony/security enregistre ces matchers automatiquement dans Firewall.#wireRealtime() (firewall.ts#Firewall.#wireRealtime) : une instance d’authenticator par zone protégée, car le hub dédoublonne par identité d’instance et une instance partagée n’enregistrerait que le premier matcher.

Zero Trust — il y a toujours un token#

RealtimeHub.getTokenForPeer() (RealtimeHub.ts#RealtimeHub.getTokenForPeer) ne renvoie jamais null : à défaut de token posé, c’est ANONYMOUS_REALTIME_TOKEN (AnonymousRealtimeToken.ts#ANONYMOUS_REALTIME_TOKEN), singleton gelé, isAuthenticated() === false, roles: ["ROLE_ANONYMOUS"]. Le code consommateur (verrou, audit) n’a jamais à écrire « et s’il n’y a pas de token ? ».

Situation au handshake Token posé Connexion
Aucun authenticator enregistré ANONYMOUS_REALTIME_TOKEN ouverte
Aucun matcher ne capture l’URL ANONYMOUS_REALTIME_TOKEN ouverte
Matcher trouvé mais supports() renvoie false ANONYMOUS_REALTIME_TOKEN ouverte
authenticate() réussit le token de l’authenticator ouverte
authenticate() throw aucun close 4001

⚠️ Attention

La troisième ligne est un comportement à comprendre : un authenticator qui matche mais ne supporte pas le handshake fait retomber la connexion en anonyme, sans fermeture. C’est le cas de FirewallRealtimeAuthenticator.supports() (FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.supports) quand aucune identité authentifiée n’est en ALS. Ce n’est pas un trou parce que le firewall HTTP a déjà refusé l’anonyme en amont sur une zone protégée (Zero Trust appliqué à la requête d’upgrade). Sur une route WS hors zone, en revanche, la connexion anonyme aboutit : la protection y repose entièrement sur les policies de canal.

Le welcome transporte ensuite l’identité résolue (type, authenticated, userIdentifier, roles, scopes) : le client sait qui il est sans appeler une route. Vue « sur soi » — aucun secret, aucun claim d’un tiers.

🧑‍⚖️ Verrou 3 — l’autorisation par frame#

C’est le verrou le plus important et celui dont les limites doivent être les mieux comprises.

Et si le module de sécurité n’est pas chargé du tout ?#

La question mérite d’être posée avant la mécanique, parce que la réponse a longtemps été mauvaise. Sans @nodefony/security, personne ne pose de décideur : le hub laissait alors passer tout, y compris les canaux qui décrivent l’intérieur du serveur — journaux, requêtes de base, métriques, supervision. Un tableau de bord anonyme lisait les journaux du pod.

Le hub applique donc son propre plancher, qui ne dépend d’aucun module : tant qu’aucun verrou n’est posé, une connexion cliente ne peut pas s’abonner à un canal de plateforme.

Un seul territoire porte cette marque : nodefony:. Tout ce que la plateforme expose sur la socket y vit, et rien d’autre n’y entre.

Canal Ce qu’il expose
nodefony:audit journal d’audit
nodefony:syslog journaux du serveur
nodefony:orm:health · nodefony:orm:flow · nodefony:orm:rich requêtes et santé des bases
nodefony:dashboard · nodefony:supervision · nodefony:debugbar · nodefony:socket métriques et supervision
nodefony:kernel:ping · nodefony:kernel:gc contrôle du pod

Le raisonnement tient en une phrase : sans module de sécurité, aucune identité n’existe, donc personne ne peut prouver qu’il a le droit de lire ces canaux — le seul état sûr est le refus. Les canaux applicatifs (chat:, le vôtre) ne sont pas concernés : une application sans authentification continue de fonctionner.

Trois conséquences pratiques :

Dès qu’un module de sécurité pose son verrou, ce plancher s’efface : c’est le verrou qui décide, avec les rôles — ce que le hub, seul, ne sait pas faire.

ℹ️ Note

Un service du serveur qui s’abonne à ses propres journaux n’est pas concerné : il ne passe pas par la porte des connexions clientes. Le plancher vise le réseau, pas le code local.

La chaîne complète#

Le verrou est strictement synchrone — FrameAuthorizer (RealtimeHub.ts#FrameAuthorizer). Un await par frame coûterait une microtask et sérialiserait le pipeline RPC de la connexion. Il ne lit que de la mémoire : le token déjà résolu, et la cible de la frame.

Les trois surfaces gardées#

buildFrameAuthorizer() (frameAuthorizer.ts#buildFrameAuthorizer) ne garde que ce qui atteint des données :

Frame Contrôle appliqué
api.request {path} Re-match de zone HTTP : zone protégée + anonyme → refus
subscribe {channel} Plancher système, puis policy métier déclarée sur le canal
notification method = canal inbound Même politique que subscribe (on ne pousse pas sur un canal protégé)
action @RealtimeAction Authentifié par défaut ; rôle/scope si déclarés ; ouverte seulement si { authenticated: false } est écrit
ping, unsubscribe passent — pas de surface de données

Deux détails évitent des faux refus : authorizeApiRequest() (frameAuthorizer.ts#authorizeApiRequest) laisse passer une frame au path invalide (le handler renverra -32602 — le verrou ne duplique pas la validation), et authorizeChannel() (frameAuthorizer.ts#authorizeChannel) laisse passer un channel non-chaîne (startChannel ignore de toute façon un canal absent).

Le plancher système — les namespaces réservés#

Certains namespaces exposent l’intérieur du pod. Ils portent une politique par défaut que la config peut resserrer, jamais desserrer.

Cible Politique par défaut
nodefony:audit authentifié + ROLE_NODEFONY_ADMIN (frameAuthorizer.ts#SECURITY_CHANNEL_POLICY)
tout le reste de nodefony: authentifié + ROLE_NODEFONY_ADMIN (frameAuthorizer.ts#SYSTEM_CHANNEL_POLICY)
tout canal contenant :health ou :stats authentifié + ROLE_NODEFONY_ADMIN (frameAuthorizer.ts#matchSystemPolicy)
tout le reste libre, sauf policy déclarée

Le journal d’audit est un canal précis, pas un namespace : sa règle est posée devant celle du territoire (premier match gagnant), et elle n’existe que si le hub réserve bien ce territoire.

Trois durcissements méritent d’être connus :

Le canal du journal d’audit (nodefony:audit) est enregistré comme canal système sur le hub (RealtimeHub.registerSystemChannel(), RealtimeHub.ts#RealtimeHub.registerSystemChannel) : il devient servable par n’importe quel endpoint realtime, sans qu’aucun controller ne le connaisse — et il est gardé par sa règle dédiée. Son enregistrement est couplé à la pose du verrou (même condition), donc il n’existe jamais de canal d’audit non gardé.

La policy métier — déclarer sur le canal#

@RealtimeChannel(name, policy) (realtimeDecorators.ts#RealtimeChannel) et @RealtimeInbound(name, policy) (realtimeDecorators.ts#RealtimeInbound) attachent un IChannelPolicy au nom du canal. Les trois axes sont cumulatifs (ET) ; un axe absent n’impose rien :

Axe Sens Évalué par
authenticated token non anonyme satisfies() (frameAuthorizer.ts#satisfies)
roles un des rôles suffit, hiérarchie comprise Firewall.hasRole() (firewall.ts#Firewall.hasRole)
scopes un des scopes suffit — axe API (JWT, clé API), une session BFF n’en porte pas comparaison directe

Une policy vide n’est pas enregistrée : definePolicy() (realtimeDecorators.ts#definePolicy) ignore un objet sans contrainte — le canal reste libre, le registre reste vide. Les déclarations sont inscrites au hub au démarrage (channelPolicyRegistry.ts#registerControllerPolicies, à onKernelReady) : un exemplaire qui n’a encore vu aucune poignée de main a déjà ses politiques — celui qui n’émet que des webhooks aussi. Un contrôleur échappé au démarrage s’inscrit à sa première poignée de main (RealtimeHub.registerChannelPolicy(), RealtimeHub.ts#RealtimeHub.registerChannelPolicy, idempotent) ; le décideur les relit par RealtimeService.resolveChannelPolicy() (RealtimeService.ts#RealtimeService.resolveChannelPolicy).

⚠️ Une policy est attachée à un nom EXACT — les canaux dérivés n’héritent pas#

@RealtimeChannel("chat:room", { roles }) garde chat:room, et rien d’autre. Le registre est indexé par le nom, à la lettre : un canal dérivé — chat:room:1000 (cadence), orm:queries@1234 (forage), chat:room:<identifiant> — ne matche pas. Il est servi par la fabrique dynamique du contrôleur (createRealtimeChannel), et resolveChannelPolicy() rend null pour lui : aucune contrainte.

C’est le piège le plus facile à ne pas voir, parce que tout a l’air correct : la déclaration est là, le canal fonctionne, et le refus attendu n’arrive jamais.

Le serveur le dit désormais. Au premier canal servi dynamiquement sans politique, alors que le module en déclare pour d’autres canaux (hors canaux de plateforme nodefony:, que leur plancher garde déjà), un avertissement est journalisé une fois (RealtimeHub.noticeUnguardedDynamicChannel(), RealtimeHub.ts#RealtimeHub.noticeUnguardedDynamicChannel) :

le canal "chat:room:1000" n'a aucune règle d'accès : tout client connecté peut s'y abonner.
Il est produit par createRealtimeChannel(), et une règle déclarée sur un nom exact ne couvre pas
ses variantes (suffixe de cadence, identifiant). […]

Deux façons de fermer, selon l’intention :

Le nom exact l’emporte sur un motif ; entre deux motifs, le plus spécifique gagne. Un * dans un nom souscrit est refusé : il désigne un motif de politique, jamais un canal.

📬 Le canal personnel user:<id> — fourni, rien à déclarer#

Livrer un message à une personne — sur tous ses onglets, sur tous les serveurs, et à personne d’autre — est une brique du framework. L’application n’écrit ni canal, ni règle, ni déclaration de diffusion :

// côté serveur — un service, un job, un contrôleur
getRealtimeHub<ChatContract>().publishToUser(bob.id, message);

// côté navigateur — cet onglet s'abonne quand son écran écoute
useEffect(() => chat.onUser((m) => addPrivate(m)), []);

La forme du message se déclare dans le contrat, par la clé gabarit du canal personnel :

interface ChatContract extends IRealtimeContract {
  serverToClient: {
    [channel: `user:${string}`]: { from: string; text: string };
  };
}

Ce que le framework garantit, écrit une fois :

Garantie Comment
Toi seul Politique intégrée : connexion authentifiée, admission par égalité de chaîne avec user:<ton id> (userChannel.ts#isOwnUserChannel). Jamais un découpage : l’identité 4 n’entre pas sur user:42.
Aucun oracle Le verrou passe avant la fabrique, et la règle ne lit que l’identité du demandeur, jamais une base : user:999 (inexistant) et user:bob (pas le tien) reçoivent le même forbidden.
Tous les serveurs Dès qu’un bus est branché, le préfixe user: est diffusé par le framework (RealtimeHub.ts#RealtimeHub.setBackplane).
Sans sécurité chargée La règle s’applique quand même : allow ne fait que retirer des abonnés.
Jamais d’office Rien n’est ouvert à la connexion : une application qui n’appelle jamais onUser ne paie rien. Mesuré : 494 octets par onglet abonné, envoi ~60 ns.

L’id est l’identifiant stable du compte (RealtimeIdentity.id), jamais le login : un login libéré se réattribue, et le nouveau compte recevrait les messages de l’ancien. Un appelant sans compte local (<émetteur>#<sujet>, connexion d’un fournisseur d’identité) a lui aussi son canal ; un anonyme n’en a pas — il n’a pas d’identifiant qui survive à une reconnexion. Le support anonyme passe par un salon privé de l’application (support:<conversation> + allow, ci-dessous).

Le prix assumé : un compte supprimé garde son abonnement jusqu’à son éjection explicite. Et une publication sans abonné est perdue sans erreur — c’est une diffusion, pas une boîte aux lettres : l’historique d’une conversation vit dans la base de l’application.

Surcharger la règle : une politique que l’application écrit sur user:… (nom exact ou motif) l’emporte sur la règle intégrée — c’est ainsi qu’on ouvre user:public à tous. Une telle déclaration remplace la garantie « toi seul » : à n’écrire qu’en connaissance de cause.

🪪 allow — admettre un abonné par SON identité#

Les trois axes décrivent une classe d’identité : « un administrateur », « un service qui porte metrics:read ». Ils ne savent pas dire « ce compte, et lui seul, sur ce canal » — la boîte aux lettres user:42, la salle d’une partie, le tableau de bord d’une seule usine. C’est le rôle d’allow : une règle qui reçoit le canal demandé et l’identité de l’abonné, et répond oui ou non. Elle se joue au subscribe, une fois, dans le contrôleur (RealtimeController.ts#RealtimeController.startChannel) ; la règle elle-même est un contrat du cœur (channelAdmission.ts#IChannelAdmission), isomorphe : rien n’y dépend de la socket.

L’exemple ci-dessous écrit à la main la règle de user:* pour montrer la mécanique. Pour le canal personnel lui-même, ne rien écrire : le framework le fournit (section précédente), et une politique écrite ainsi remplacerait la sienne.

La forme courte — une flèche, pour une règle qui ne lit rien :

// nodefony/controllers/InboxRealtimeController.ts
import { controller, route } from "@nodefony/framework";
import { Context } from "@nodefony/http";
import { RealtimeChannel, RealtimeController } from "@nodefony/realtime";
import type { RealtimePublish } from "@nodefony/realtime";

@controller("/rt")
class InboxRealtimeController extends RealtimeController {
  constructor(context: Context) {
    super("InboxRealtimeController", context);
  }

  @route("inbox-ws", {
    path: "/inbox",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async realtime(message: string | Buffer | null): Promise<void> {
    this.handleRealtime(message);
  }

  // La règle du MOTIF : `user:42` n'est ouvert qu'au compte 42.
  @RealtimeChannel("user:*", {
    allow: (channel, who) => channel === `user:${who.id}`,
  })
  inbox(): () => void {
    return () => {};
  }

  // Un producteur décoré ne sert que son nom exact : les canaux `user:<id>`
  // sont servis par la fabrique dynamique.
  override createRealtimeChannel(
    channel: string,
    publish: RealtimePublish,
  ): (() => void) | null {
    if (!channel.startsWith("user:")) return null;
    publish(channel, { unread: 0 });
    return () => {};
  }
}

export default InboxRealtimeController;

Le compte 42 reçoit realtime:subscribed pour user:42 ; le compte 43 et l’anonyme reçoivent realtime:denied, motif forbidden — prouvé de bout en bout, vrai client et vrai verrou, par realtimeChannelAuth.e2e.test.ts.

La forme longue — une classe, dès que la règle lit les données de l’application. Ses services lui arrivent par le constructeur, comme à tout service ; elle se déclare dans @services([…]) de son module, et le décorateur la nomme :

// nodefony/services/RoomAdmission.ts
import { Inject, Module, Service, injectable } from "nodefony";
import type { IChannelAdmission, ISubscriberIdentity } from "nodefony";
import type RoomMembersService from "./RoomMembersService";

@injectable()
class RoomAdmission extends Service implements IChannelAdmission {
  constructor(
    module: Module,
    @Inject("RoomMembersService") private readonly members: RoomMembersService,
  ) {
    super(
      "roomAdmission",
      module.container ?? undefined,
      module.notificationsCenter,
    );
  }

  // `room:<partie>` n'est ouvert qu'aux joueurs de la partie.
  async allow(channel: string, who: ISubscriberIdentity): Promise<boolean> {
    if (who.id === null) return false;
    return this.members.isPlayer(channel.slice("room:".length), who.id);
  }
}

export default RoomAdmission;

// Dans le contrôleur :
//   @RealtimeChannel("room:*", { allow: RoomAdmission })

Le démarrage résout la classe une fois et journalise « canal room:*, admission RoomAdmission, résolue » (channelPolicyRegistry.ts#createAdmissionResolver). Une dépendance absente, une portée autre que singleton, une classe sans méthode allow font échouer le démarrage en nommant la classe et le canal — jamais un refus silencieux de 100 % des abonnés en production. Une seule instance sert tous les abonnés en même temps : elle ne garde aucun état par abonné.

Un module npm livre sa politique de la même façon : la classe vit dans le module, déclarée dans ses @services([…]), son contrôleur la nomme. L’application qui l’installe n’écrit rien.

Le contrat, en six règles :

Règle Pourquoi
Ses arguments, et rien d’autre ni contexte de requête, ni session : à l’émission d’un webhook il n’y en a pas. Une règle qui les lirait passerait tous ses tests sur la socket, puis refuserait tout ailleurs
who.id, jamais le login un e-mail change, un login libéré se réattribue ; id est l’identifiant stable du compte, null pour l’anonyme
Seul true admet 1, "yes", un objet refusent — une réponse ambiguë ne vaut pas un oui
L’anonyme est refusé d’office sans même appeler la règle, sauf anonymous: true dans la politique ; un authentificateur qui ne fournit pas d’identité d’admission (getIdentity) n’entre sur aucun canal à allow
Elle s’ajoute aux trois axes authenticated, roles, scopes d’abord (le verrou), puis allow (l’abonnement). Une politique plus spécifique remplace celle du motif EN ENTIER, allow compris : @RealtimeChannel("user:public", { authenticated: false }) ouvre ce canal-là
Une fois par abonnement, jamais par trame admis, les trames entrantes du canal passent sans la rappeler ; une trame entrante sur un canal à allow SANS abonnement préalable est refusée

Le realtime:welcome annonce un canal à allow sur le seul verdict du verrou : la règle n’est jamais jouée à la poignée de main — elle coûterait un appel par canal déclaré et par connexion, pour annoncer un motif dont aucun nom concret n’est connu.

Quand la règle est lente ou en panne — la base qu’elle lit ne répond plus :

Situation L’abonné reçoit Le serveur
la règle lève, ou sa promesse est rejetée realtime:denied forbidden (fermé par défaut) un WARNING par fenêtre de 10 s
aucune réponse dans allowTimeoutMs (5 s) unavailable allowTimedOutTotal ; un verdict arrivé après coup est ignoré, jamais une admission
plus de maxPendingAllowsPerConnection (8) appels en vol sur la connexion rien : la demande attend son tour — le rejeu de tous les abonnements au welcome n’en perd aucun
plus de maxPendingAllowsTotal (512) appels en vol sur l’exemplaire unavailable, sans appel allowSaturatedTotal
50 délais dépassés en 5 s unavailable et retryAfterMs, sans appel, pendant 5 s (coupe-circuit) allowSaturatedTotal

Un délai dépassé n’est pas un refus de droit : le client réessaie unavailable au plus trois fois, avec un recul exponentiel à gigue complète, après retryAfterMs quand il est donné. forbidden et limit ne se réessaient jamais. Le tout reste borné : 300 passerelles qui rejouent 10 000 abonnements contre une base bloquée déclenchent environ 1 600 appels de la règle, jamais plus de 512 à la fois, et la tempête s’éteint en moins d’une minute (admissionStorm.e2e.test.ts). Réglages : configuration.md ; compteurs : observabilite.md.

Le prix assumé — une réponse retenue. Une admission ne se périme pas seule : un membre retiré d’une salle reste abonné tant que sa connexion vit, la règle n’est pas rejouée. Pour couper tout de suite, on retire son appartenance (là où la règle la lit), PUIS on l’éjecte : getRealtimeHub().evictUser(canal, compte) coupe tous ses onglets, sur tous les serveurs (realtime:closed { reason: "evicted" }). L’éjection seule ne suffit pas — son navigateur rejoue subscribe au rechargement, et c’est la règle qui tranche alors. Détail : canaux à membres. Fermer la connexion entière, c’est la révocation (verrou 4).

room:* et pattern: "room:" — deux grammaires, une même famille. Le décorateur écrit un motif ("room:*") ; la configuration de @nodefony/security écrit un préfixe (realtimeChannels: [{ pattern: "room:", roles }]). Ils couvrent les mêmes canaux. La règle de configuration est jugée par le verrou AVANT la politique du décorateur et la remplace de ce côté — c’est ainsi qu’une exploitation durcit un namespace sans toucher au code ; elle ne porte jamais d’allow, qui reste déclaré sur le décorateur et s’applique toujours à l’abonnement.

⚠️ La condition d’activation — le point critique#

Le verrou n’existe que si quelqu’un le pose. Deux conditions doivent être vraies en même temps :

  1. @nodefony/security est chargé, et
  2. au moins une zone a security: true et realtime: true.

C’est exactement le test de Firewall.#wireRealtime() (firewall.ts#Firewall.#wireRealtime) : sans zone qualifiante, wired reste faux, setFrameAuthorizer n’est jamais appelé, et aucune policy de canal n’est évaluée — ni métier, ni système. nodefony:syslog redevient un canal ordinaire.

Deuxième subtilité : beforeDispatch n’est branché sur une connexion que si le verrou est déjà posé au moment de son handshake (RealtimeController.ts#RealtimeController.onHandshake, via RealtimeHub.hasFrameAuthorizer() — RealtimeHub.ts#RealtimeHub.hasFrameAuthorizer). Choix de performance délibéré (un hub non sécurisé garde un coût nul par frame), mais avec une conséquence : une connexion ouverte avant la pose du verrou n’est jamais gardée, et ce jusqu’à sa fermeture. En fonctionnement normal le firewall se construit au boot, avant tout trafic ; le cas ne se présente qu’en appelant setFrameAuthorizer() à chaud.

Le refus de dégrader en silence. Quand des policies sont déclarées sans décideur câblé, RealtimeHub.hasUnenforcedChannelPolicies() (RealtimeHub.ts#RealtimeHub.hasUnenforcedChannelPolicies) renvoie true et le controller émet un WARNING explicite, une seule fois par process (RealtimeController.ts#RealtimeController.onHandshake) :

Realtime channel policies declared but NO frame authorizer is wired —
these policies are NOT enforced (a protected channel is currently open).
Load @nodefony/security with a realtime zone to enforce them.

C’est le comportement prouvé par realtimeUnenforcedPolicy.attack.test.ts : une policy déclarée sans frameAuthorizer est détectée comme inerte, et l’ajout du frameAuthorizer éteint l’alerte. Le test ne prouve pas que le canal est fermé — il prouve que le framework le dit.

🛑 Prudence

Portée exacte de cette alerte. Elle se déclenche uniquement s’il existe au moins une policy déclarée par un décorateur. Une application qui n’utilise aucun @RealtimeChannel avec policy, mais qui expose des canaux de namespace réservé (nodefony:syslog, nodefony:orm:health…), ne déclenche rien : le registre #channelPolicies reste vide, la condition est fausse, et les planchers système restent pourtant non appliqués. La détection couvre le risque métier, pas le risque plateforme.

Ce que le refus laisse voir#

Le refus doit être observable sans devenir un oracle. Nodefony tranche ainsi :

Type de frame refusée Réponse au client
Requête (avec id) -32001 "unauthorized" — JsonRpcPeer.receive() (JsonRpcPeer.ts#JsonRpcPeer.receive)
Notification (subscribe, inbound) realtime:denied { channel, reason: "forbidden" } (RealtimeController.ts#RealtimeController.onHandshake)
Dépassement du plafond de canaux realtime:denied { channel, reason: "limit" }
Canal sans aucun producteur realtime:denied { channel, reason: "unknown" }

Le message est générique dans les trois cas : jamais « il te manque ROLE_ADMIN », jamais le nom de la zone. Sans realtime:denied, une notification refusée serait droppée en silence (elle n’a pas de canal de réponse) et le client se croirait abonné. Aucun abonnement ne reste sans réponse : un canal que personne ne produit — nom mal orthographié, module non chargé — rend unknown, qui n’ouvre aucun oracle (un canal gardé est tranché en amont, donc forbidden qu’il existe ou non). Le motif limit est volontairement distinct de forbidden : une borne de ressource n’est pas un secret, et les confondre enverrait un développeur chercher un problème de droits.

Côté serveur, chaque refus alimente le journal d’audit via le rapporteur onDeny (FrameDenyReporter, frameAuthorizer.ts#FrameDenyReporter), tiré uniquement sur refus — le chemin autorisé n’alloue rien. L’entrée porte category: "ws", action: "frame.denied", l’acteur, la ressource et une raison machine stable (zone_protected ou channel_policy).

⏳ Verrou 4 — la révocation : une socket survit à sa session#

Le problème est structurel : l’identité est figée au handshake, le verrou est sync, donc une frame ne peut pas relire la session. Un administrateur qui se déconnecte garderait ses flux nodefony:syslog et nodefony:audit tant que sa socket vit.

Nodefony ferme l’écart par deux mécanismes de granularité différente.

Surface Re-validation Fenêtre d’exposition Où
api.request (data plane) à chaque frame nulle RealtimeController.invokeApiRequest() (RealtimeController.ts#RealtimeController.invokeApiRequest)
subscribe / flux de canal périodique, 30 s ≤ 30 s RealtimeHub.revalidateRevocable() (RealtimeHub.ts#RealtimeHub.revalidateRevocable)

Sur api.request, token.isValid() est appelé avant l’exécution de l’action ; identité périmée ou changée → -32000 avec status: 401, et le client bascule sur un fetch HTTP porteur du cookie courant. Une erreur de re-validation vaut refus (fail-closed).

Sur les canaux, le hub n’inscrit au registre de révocation que les connexions dont le token porte isValid (RealtimeController.ts#RealtimeController.onHandshake). Une connexion anonyme n’y entre jamais, coût nul. Toute connexion authentifiée par le pare-feu y entre : sa session est relue, ou, pour un jeton porteur (jwt, external-jwt, apikey), son expiration (exp), la révocation de son jti et la révocation en masse de son identité (invalidBefore). RealtimeHub.registerRevocable() (RealtimeHub.ts#RealtimeHub.registerRevocable) démarre un setInterval unref au premier inscrit et l’arrête dès que le registre se vide : zéro timer au repos. Période : REVOCATION_REVALIDATE_MS (RealtimeHub.ts#channelFamily), 30 s, alignée sur le heartbeat WS.

Ce que realtimeRevocation.attack.test.ts prouve exactement :

Cas Comportement prouvé
isValid() renvoie false close(4001, "session revoked")
status() rend expired (jeton porteur) close(4006, "token expired")
status() rend revoked close(4001, "session revoked"), même si exp est passé
isValid() renvoie true socket intacte (contrôle positif, pas de faux refus)
isValid() throw (store indisponible) socket fermée — fail-closed, parité avec api.request
déjà révoqué retiré du registre avant le close → aucun re-close
déconnexion propre unregisterRevocable → plus jamais re-validé
registre vide tick no-op, aucun crash

La source de vérité côté security est buildSessionRevalidator() (FirewallRealtimeAuthenticator.ts#buildSessionRevalidator) : il relit la session BFF par son id capturé au handshake et vérifie qu’elle est toujours vivante et toujours celle du même utilisateur — ce second point attrape le changement de compte sur un navigateur partagé.

Le code de fermeture dit la cause (realtime/closeCodes.ts, ADR-0021 §S11). Un jeton porteur sait pourquoi il est mort : IRealtimeToken.status() rend expired ou revoked, et la révocation l’emporte — la liste de refus est interrogée avant de conclure à l’expiration.

Cause sur une socket OUVERTE Code Client 10.0
jeton porteur arrivé à exp 4006 (TOKEN_EXPIRED_CLOSE_CODE) s’arrête (définitif)
jeton porteur révoqué (jti, invalidBefore), expiré ou non 4001 (IDENTITY_DEAD_CLOSE_CODE) s’arrête
session expirée ou détruite (la cause n’est pas connaissable) 4001 s’arrête
droits changés, identité vivante 4005 (IDENTITY_REFRESH_CLOSE_CODE) revient aussitôt
refus à la poignée de main, quel qu’il soit 401 uniforme → 1008 s’arrête
authentification sans réponse dans authenticateTimeoutMs 1013 (TRY_AGAIN_LATER_CLOSE_CODE) revient, avec recul
client qui ne vide plus sa file (contre-pression) 1013 revient, avec recul

❗ Important

Jamais de 4006 à la poignée de main. Un refus distinct y dirait à qui tient un jeton volé qu’il a été émis par l’application : un jeton expiré présenté à l’entrée reçoit le même 401 que n’importe quel autre refus.

Recette — un jeton d’une heure sur une socket de sept jours. La socket vit plus longtemps que son jeton : au bout d’une heure, elle est fermée en 4006, et le client 10.0 ne revient pas seul. C’est à l’application de recréer sa socket avec un jeton neuf avant exp — par exemple obtenir un nouveau jeton cinq minutes avant l’échéance, ouvrir la nouvelle socket, puis fermer l’ancienne. Un client qui saura obtenir un jeton frais de lui-même viendra dans une version 10.x, sans que le serveur change.

Un changement de rôles coupe sur-le-champ, sans attendre le tick. Quand une mise à jour du compte touche ses rôles, le hub ferme toutes les sockets de cette identité avec le code 4005 (realtime/closeCodes.ts#IDENTITY_REFRESH_CLOSE_CODE, RealtimeHub.ts#RealtimeHub.#revocation). À la différence de 4001 (identité morte, définitif), 4005 est reconnectable : le client se reconnecte seul et reçoit ses nouveaux rôles dans l’accueil. Les flux SSE du même compte suivent la même règle. Aucune lecture de base par tick ni par frame : le coût n’existe qu’à la mise à jour qui touche roles.

🛑 Prudence

Trois limites à connaître.

  1. Fenêtre de 30 s. Entre le logout et le tick suivant, les flux continuent. Pour une coupure immédiate, fermer la socket explicitement côté serveur. (Un changement de rôles, lui, coupe immédiatement — ci-dessus.)
  2. Une identité qu’on ne pourra pas re-valider ferme la socket. Si la session est illisible au handshake (ni id ni store exploitable), buildSessionRevalidator() (FirewallRealtimeAuthenticator.ts#buildSessionRevalidator) rend un revalidateur qui répond toujours false : la socket est fermée en 4001 au premier tick, et un WARNING le dit. Même règle pour un jeton porteur qui n’a ni exp, ni de quoi interroger le store de révocation (jti, iat). Un refus visible plutôt qu’une connexion que rien ne pourrait plus couper.
  3. Un jeton porteur n’est jugé sur sa signature qu’à l’ouverture. Le tick relit exp, jti et invalidBefore, jamais la clé ni l’émetteur : une clé retirée du jeu publié par l’émetteur laisse la socket vivre jusqu’à exp, et un émetteur retiré de resourceServer.issuers ne compte qu’à la connexion suivante — la liste ne change qu’au redémarrage, qui ferme les sockets. Une connexion anonyme, elle, n’est pas révocable du tout.

🚧 Plafonds anti-abus — borner une connexion#

Une connexion ouverte est une ressource. Trois bornes existent ; il faut aussi savoir ce qui n’est pas borné. Les appels en vol de la règle allow ont leurs propres plafonds, par connexion et par exemplaire : voir allow — quand la règle est lente.

Canaux par connexion#

Chaque canal ouvert coûte un provider, un ticker et une entrée de Map. Sans borne, une connexion peut abonner jusqu’à l’OOM — un déni de service mémoire déclenché par un seul client.

RealtimeController.startChannel() (RealtimeController.ts#RealtimeController.startChannel) refuse au-delà de limits.maxChannelsPerConnection (realtime/nodefony/config/config.ts#limitsSchema), défaut 256, null pour illimité. Points prouvés par realtimeChannelCap.attack.test.ts :

C’est une garde, pas une bride : sous le seuil, le multiplexage N-canaux reste entier. Le log de refus est en DEBUG et non WARNING — un log par subscribe refusé sous flood serait lui-même un amplificateur.

Back-pressure — le client qui ne lit pas#

Un onglet en arrière-plan, un mobile en zone blanche, une fenêtre TCP pleine : la file d’envoi grossit sans borne, et le multiplexage concentre le risque (une socket lente bloque tous ses canaux). WsConnectionTransport.send() (WsConnectionTransport.ts#WsConnectionTransport.send) applique deux seuils :

bufferedAmount Action
> 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
websocket.backpressureCloseAfterDrops drops CONSÉCUTIFS — 1000 par défaut close(1013) « Try Again Later » ; le client se reconnecte et resynchronise

Taille des messages entrants#

Portée par @nodefony/http : websocket.maxPayload (http/nodefony/config/config.ts#websocketSchema), défaut 1 MiB, au-delà fermeture RFC 6455 1009 « Message Too Big ». C’est un durcissement par rapport au défaut de la librairie ws.

Ce qui n’est PAS borné#

Absent Conséquence
Limite de fréquence des frames entrantes Un client authentifié peut inonder le peer ; seul le coût CPU le freine
Limite de connexions par IP ou par utilisateur Rien n’empêche N sockets par client au niveau du module
Plafond global de canaux du process Le plafond est par connexion ; M connexions × 256 canaux reste possible
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)

⚙️ Configuration de sécurité#

Seules les clés à effet de sécurité figurent ici ; le catalogue complet est dans configuration.md. Source unique des défauts : realtimeConfigSchema (realtime/nodefony/config/config.ts#realtimeConfigSchema).

Clé Défaut Effet de sécurité
csrf.checkOrigin.enabled false Active l’allowlist d’origines à l’upgrade
csrf.checkOrigin.allowList [] Origines acceptées, match exact. Vide + activé = tout refusé
csrf.checkOrigin.allowMissingOrigin false Accepter une upgrade sans Origin (clients non-navigateur)
limits.maxChannelsPerConnection 256 Plafond de canaux par connexion ; null = illimité
slowConsumer.bytes 1 MiB Seuil de comptage des consommateurs lents dans la sonde
backplane.namespace dérivé Cloison du pub/sub cross-pod
backplane.secret absent Scelle les messages du transport partagé (HMAC) ; absent = bus ouvert + WARNING au boot
enabled true false = module inerte : aucun câblage hub depuis la config
areas.<nom>.realtime (security) true La zone couvre aussi les frames WS. false = opt-out explicite
realtimeChannels (security) [] Règles de canal par préfixe, placées avant les défauts système

💡 Astuce

backplane.namespace est une clé de sécurité, pas de confort. Le numéro de base Redis ne cloisonne pas le pub/sub : deux déploiements de la même app sur un Redis mutualisé, sans namespace, échangent leurs fan-outs. Le poser dès que staging et production partagent un Redis.

🚫 Ce que ce module ne défend pas#

Cette section existe pour éviter une confiance mal placée. Chaque point est vérifié au code.

1. Il n’y a pas de frontière de canal entre modules. Un nom de canal est une chaîne dans un registre global (#channelPolicies du hub singleton, RealtimeHub.ts#RealtimeHub.#channelPolicies). Rien n’empêche le controller du module A de servir un canal au nom d’un canal du module B, ni de déclarer une policy plus faible sur ce même nom — la déclaration la plus récente écrase (RealtimeHub.ts#RealtimeHub.#admitFromBackplane, idempotent par écrasement). En pratique, l’exploitation exige que le controller fautif accepte le nom (sa factory doit renvoyer un provider), donc un override permissif de createRealtimeChannel ; et les namespaces réservés restent couverts par le plancher système si le verrou est posé. Une garde d’appartenance par module est un manque connu et assumé, pas une protection existante.

2. Le backplane n’authentifie l’émetteur que si tu poses un secret. Ce qui ENTRE par le bus est filtré par canal — seul un canal déclaré broadcast est réinjecté (RealtimeHub.#admitFromBackplane), donc syslog:, nodefony:audit et les canaux d’observabilité restent hors d’atteinte depuis le transport. Mais l’identité de l’émetteur n’est vérifiée que lorsque backplane.secret est posé : les messages sont alors scellés (HMAC-SHA256, envelope.ts) et un message non scellé ou altéré est ignoré. Sans secret, quiconque écrit dans le Redis publie sur les canaux broadcast de tous les pods — le boot l’annonce en WARNING. Le rejeu d’un message scellé intact reste possible : la sémantique du transport est at-most-once, sans anti-rejeu.

3. L’autorisation d’un canal n’est vérifiée qu’au subscribe. Une fois abonné, le fan-out ne re-consulte pas la policy : un changement de rôle en cours de connexion ne coupe pas le flux. Seule la révocation de session (verrou 4, fenêtre 30 s) ferme la socket.

4. Le contenu publié n’est pas filtré par abonné. Le hub diffuse la charge telle quelle à tous les abonnés du canal ; masquer partiellement un flux impose au provider de publier sur des canaux distincts.

5. Pas de limitation de débit ni de quota de connexions, et pas de chiffrement propre : la confidentialité et l’intégrité sont déléguées à TLS — wss:// n’est pas optionnel en production.

6. enabled: false désarme aussi les gardes du module. Le service devient inerte (RealtimeService.init(), RealtimeService.ts#RealtimeService.init) : ni garde d’origine, ni plafond de canaux posés depuis la config. Le hub conserve son défaut de 256 canaux, mais l’allowlist d’origines configurée n’est pas appliquée.

📜 Normes appliquées#

Norme Application
RFC 6455 §10.2 (Origin) Contrôle d’origine à l’upgrade, aux deux étages
RFC 6455 §7.4.2 (codes 4000-4999) 4001 unauthorized / session revoked, 4003 origin not allowed, 4006 token expired
RFC 6455 §7.4.1 / registre IANA 1008 Policy Violation (origine transport), 1009 Message Too Big, 1013 Try Again Later (contre-pression, authentification sans réponse)
RFC 6455 §4.1 Sec-WebSocket-Protocol normalisé en liste dans le DTO de handshake
RFC 9110 §7.2 Comparaison du Host par le matcher d’authenticator
JSON-RPC 2.0 §5.1 -32001 (plage Server error) pour une requête refusée, -32602 pour des params invalides
OWASP WSTG-CLNT-10 Anti-CSWSH : validation d’Origin obligatoire, les navigateurs n’appliquent pas CORS aux WS
Zero Trust / OWASP A01 Zone protégée + anonyme = refus ; token toujours présent, jamais null

📡 Observabilité — Studio#

⚠️ Pièges#

Symptôme Cause Correction
Le canal ROLE_ADMIN est servi à tout le monde, sans erreur Aucune zone security: true + realtime: true → verrou de frame jamais posé Déclarer une zone protégée couvrant la route WS ; chercher le WARNING « policies … NOT enforced »
nodefony:syslog accessible en anonyme, et aucun WARNING L’alerte ne couvre que les policies déclarées ; un plancher système non appliqué reste muet Vérifier que le verrou est posé (log « Realtime data plane locked ») — ne pas se fier au silence
L’allowlist d’origines est configurée mais tout passe csrf.checkOrigin.enabled reste false (défaut) Poser enabled: true ; vérifier allowList non vide, sinon tout est refusé
Le client mobile natif ne se connecte plus après activation de l’origine allowMissingOrigin: false refuse les clients sans Origin Passer à true uniquement si un credential fort est vérifié (JWT signé, clé API)
Un matcher d’authenticator ne se déclenche jamais Le matcher est comparé au path, pas à l’URL absolue ; un pattern chaîne est un préfixe ancré Écrire "/rt/" et non "rt" ni "wss://host/rt"
Deux zones, un seul authenticator enregistré Le hub dédoublonne par identité d’instance — une instance partagée n’enregistre que le premier matcher Une instance d’authenticator par zone
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
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)
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)
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
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
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 sealRejectedTotal
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

🧪 Tests & couverture#

L’inventaire chiffré (cas comptés, fichiers, répartition unitaires / attaque / intégration / E2E) est rendu par la carte de tests de la page, régénérée depuis le dépôt — aucun chiffre n’est figé dans ce texte.

Tests d’attaque — ce sont eux qui font foi sur cette page :

Fichier Ce qu’il prouve
realtimeUnenforcedPolicy.attack.test.ts Une policy déclarée sans frameAuthorizer est détectée comme inerte (fail-loud). Ne prouve pas que le canal est fermé.
realtimeRevocation.attack.test.ts Fermeture 4001 d’une session révoquée, contrôle positif, fail-closed sur erreur, pas de re-close
realtimeChannelCap.attack.test.ts Le plafond refuse sans appeler le hub, le refus est observable, l’idempotence ne consomme pas de slot
backplaneInjection.attack.test.ts Un pair ne peut pas pousser sur un canal non broadcast (rejet compté) ; sur bus scellé, un message forgé, altéré ou repointé est ignoré

Unitaires : RealtimeHubSecurity.test.ts couvre les quatre seams (garde d’origine, matchers et ordre de capture, mapping peer → token avec repli anonyme, verrou de frame et son retrait), le registre de policies et l’absence d’allocation quand aucun seam n’est utilisé.

E2E : realtimeChannelAuth.e2e.test.ts exerce la matrice identité × canal (anonyme / user / admin / service × canal libre, authentifié, rôle, scope, système) avec le vrai client isomorphe, le vrai controller et le vrai verrou de @nodefony/security reliés par un câble loopback — on observe le tick reçu ou le realtime:denied, jamais un booléen interne. realtimeFirewallWiring.e2e.test.ts couvre la jonction firewall → hub.

Ce qui manque : pas de banc de charge dédié à la sécurité (surcoût du verrou sous flood), et aucun test ne couvre le rejeu d’un message scellé — cohérent avec le fait que ce vecteur n’est pas défendu (sémantique at-most-once).

Outils : nodefony-security-review, nodefony-load-test, nodefony-check-memory-health. Couverture chiffrée : npm run coverage dans le module.

🔗 Pour aller plus loin#