CVE-2026-40864

CVSS 5.4 · Moyenne Corrigé
Type
Recherche de vulnérabilités
Publié le
Durée de lecture
9 min de lecture
Auteur
rootzero
Read in English
Read in English →

CVE-2026-40864 : contournement XSRF dans JupyterHub via les requêtes Sec-Fetch-Mode: no-cors

JupyterHub 4.1.0 à 5.4.4 sautait le contrôle XSRF pour toute requête no-cors : une page d’une autre origine pouvait soumettre les formulaires du Hub.

Pour décider

Impact
Une page hostile peut provoquer certaines actions du Hub avec la session de la victime.
Conditions
La victime doit être connectée. Le scénario dépend des cookies, du mode de requête et de la configuration décrits dans l’analyse.
Action recommandée
Passer à JupyterHub 5.4.5 ou ultérieur et vérifier les parcours authentifiés concernés.

Fiche de l’avis

Paquet
pypi · jupyterhub
Versions affectées
≥ 4.1.0, < 5.4.5
Corrigé dans
5.4.5
CVSS
5.4 Moyenne
Faiblesse
CWE-352
Accès requis
Sans authentification
Avis publié
Crédit
rootzero

Synthèse de l’enquête

Depuis la 4.1.0, JupyterHub s’appuyait sur l’en-tête Sec-Fetch-Mode pour décider si une requête authentifiée par cookie devait porter un jeton XSRF, et exemptait toute requête no-cors, quels que soient sa méthode et son origine. Une page d’une autre origine pouvait envoyer des POST authentifiés à /hub/spawn ou /hub/accept-share, démarrant le serveur de la victime ou lui faisant accepter le partage du serveur de l’attaquant. Corrigé en 5.4.5.

Le PoC qui ne passait pas

Nous avons examiné la décision prise par _needs_check_xsrf() quand un navigateur envoie un POST authentifié au Hub. La fonction regardait Sec-Fetch-Mode et dispensait no-cors du jeton _xsrf, sans confronter ce mode à Sec-Fetch-Site. Nous avons relié cette branche à deux actions qui acceptent un corps de formulaire : démarrer un serveur et accepter un partage. Le signalement privé est antérieur au correctif rédigé le 13 avril 2026.

Notre PoC public décrivait d’abord un formulaire HTML intersite comme s’il portait Sec-Fetch-Mode: no-cors. C’était une erreur : le navigateur l’envoie en mode navigate, et le code vulnérable exige alors un jeton. Le dossier ne conserve aucune réponse navigateur d’un rejeu corrigé. La relecture du code, de la spécification Fetch et du correctif désigne un autre chemin : un fetch() encodé comme un formulaire en mode no-cors. Il atteint l’exemption, à condition que la politique du cookie permette l’envoi de la session depuis l’origine utilisée. Cette correction change la portée pratique du récit ; nous ne la cachons pas derrière l’avis éditeur. JupyterHub a publié 5.4.5 le 28 avril, puis l’avis CVE-2026-40864 le 5 mai.

JupyterHub accepte des requêtes qui changent l’état sur des routes /hub/ comme spawn et accept-share. Une XSRF réussie exige le cookie de session du Hub et un mode de requête que le contrôle de jeton exempte à tort.

Le Hub sert lui-même quelques pages HTML sous /hub/, dont plusieurs acceptent des soumissions de formulaire qui modifient l’état : /hub/spawn démarre un serveur, et /hub/accept-share (introduit avec la fonction de partage de serveurs) donne à l’utilisateur courant l’accès au serveur de quelqu’un d’autre. Ces pages reposent sur le cookie de session du navigateur, jupyterhub-hub-login, et doivent donc être protégées contre la falsification de requêtes intersites. JupyterHub assure cette protection avec un jeton _xsrf signé et, depuis la version 4.1.0, consulte aussi les en-têtes de requête Fetch Metadata pour décider quand le jeton peut être omis.

Ce qu’indiquaient réellement les en-têtes Fetch Metadata

Les en-têtes Fetch Metadata sont un bon signal, à condition d’utiliser chacun pour la question à laquelle il répond. Sec-Fetch-Site indique d’où vient une requête par rapport à sa cible (same-origin, same-site, cross-site, none). Sec-Fetch-Mode indique comment elle a été émise : navigate pour une navigation de document, cors pour un fetch() soumis à CORS, no-cors pour les sous-ressources et les appels fetch() en mode no-cors, same-origin ou websocket.

La question à l’origine de cette recherche était de savoir si la liste des états Fetch Metadata exemptés par JupyterHub en contenait un qui n’implique pas une requête de même origine. La lecture de _needs_check_xsrf() y a répondu : la fonction ne regarde que Sec-Fetch-Mode, jamais Sec-Fetch-Site, et exempte no-cors sans condition. N’importe quelle origine peut produire une requête no-cors, et ce mode ne limite pas la méthode à GET : une requête no-cors peut être un POST avec un corps encodé comme un formulaire. Restait à relier ce contrôle aux endpoints qui acceptent un tel corps et modifient l’état, ce qui a mené à /hub/spawn et /hub/accept-share.

Cause racine

BaseHandler.prepare() appelle check_xsrf_cookie() pour toute méthode autre que GET, HEAD et OPTIONS. Cette fonction demande d’abord à _needs_check_xsrf() si un contrôle est nécessaire. En 5.4.4, la réponse était :

# jupyterhub/_xsrf_utils.py (5.4.4)
def _needs_check_xsrf(handler):
    """Does the given cookie-authenticated request need to check xsrf?"""

    if getattr(handler, "_token_authenticated", False):
        return False

    fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified")
    if fetch_mode in {"websocket", "no-cors"} or (
        fetch_mode in {"navigate", "unspecified"}
        and handler.request.method.lower() in {"get", "head", "options"}
    ):
        # no xsrf check needed for regular page views or no-cors
        # or websockets after allow_websocket_cookie_auth passes
        ...
        return False
    else:
        return True

La condition sur la méthode ne s’applique qu’à navigate et unspecified. Pour websocket et no-cors, la fonction renvoie False avant même d’examiner la méthode : un POST en mode no-cors atteint donc le handler sans aucun jeton. Le commentaire montre l’intention : une requête no-cors ne peut pas lire la réponse, elle a donc été jugée inoffensive. Ce raisonnement vaut pour la confidentialité et échoue face au CSRF, dont tout l’intérêt réside dans l’effet de bord d’une requête dont l’attaquant ne voit jamais la réponse. Il en résulte une CWE-352, falsification de requête intersite.

Les deux handlers derrière ce contrôle agissent sur des données de formulaire. SpawnHandler.post() construit les options de démarrage à partir de self.request.body_arguments et lance le serveur ; AcceptShareHandler.post() lit un argument code, cherche le ShareCode correspondant et appelle share_code.exchange(user.orm_user). Aucun des deux ne dispose d’une seconde ligne de défense.

Exploitation

Dans notre rapport public et sa preuve de concept, nous décrivions le déclencheur comme un <form method="POST"> intersite soumis automatiquement, en indiquant que le navigateur envoie cette soumission avec Sec-Fetch-Mode: no-cors. La spécification Fetch et le code contredisent cette formulation. Une soumission de formulaire est une navigation : le navigateur envoie Sec-Fetch-Mode: navigate, et le code vulnérable vérifie bien le jeton pour un POST de navigation (le champ _xsrf vide du formulaire de la PoC devrait produire un 403 sur ce chemin ; nous ne conservons pas de rejeu navigateur). La requête qui atteint l’exemption est un appel fetch() en mode no-cors portant le même corps de formulaire, ce que traitent l’avis et le correctif. Un fetch() no-cors est limité à GET, HEAD et POST avec des en-têtes autorisés par CORS, et application/x-www-form-urlencoded en fait partie :

// servi depuis une origine autre que le Hub
fetch("https://hub.example.org/hub/spawn", {
  method: "POST",
  mode: "no-cors",
  credentials: "include",
  body: new URLSearchParams({}),
});

Le navigateur envoie alors :

POST /hub/spawn HTTP/1.1
Host: hub.example.org
Origin: https://attacker.example
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Cookie: jupyterhub-hub-login=<session de la victime>; jupyterhub-session-id=<id>
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site
Sec-Fetch-Dest: empty

_needs_check_xsrf() voit no-cors et renvoie False, le cookie authentifie la victime, et SpawnHandler démarre son serveur avec des options de formulaire vides. La réponse reste opaque pour la page attaquante, ce qui n’a aucune importance. Pour la variante du partage, l’attaquant, utilisateur du Hub autorisé à partager son propre serveur, crée un code de partage et envoie new URLSearchParams({ code: "<code de partage>" }) à /hub/accept-share. Le handler échange le code au nom de la victime, qui a désormais accès au serveur de l’attaquant.

L’API JSON sous /hub/api/ reste hors d’atteinte : elle exige un type de contenu non simple, qu’une requête no-cors ne peut pas porter.

Impact

Le vecteur de l’éditeur est AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L (5,4). L’attaquant n’a besoin d’aucun compte pour la variante du démarrage ; la victime doit être connectée au Hub et visiter une page qu’il contrôle. L’impact sur la confidentialité est nul : l’attaquant n’obtient jamais l’accès au serveur démarré. Intégrité et disponibilité sont faibles : un serveur démarre sans consentement et consomme des ressources, ou un partage est accepté sans que la victime l’ait choisi. La variante du partage exige que l’attaquant soit un utilisateur du Hub autorisé à partager, et sert surtout de préparation : le compte de la victime ouvre désormais l’environnement de l’attaquant, où peuvent l’attendre des notebooks ou des fichiers inattendus.

La politique des navigateurs en matière de cookies réduit les origines d’attaque praticables. JupyterHub ne pose pas d’attribut SameSite sur ses cookies par défaut ; les navigateurs basés sur Chromium les traitent alors comme Lax, et les protections actuelles contre les cookies tiers des autres navigateurs retiennent ou cloisonnent aussi les cookies sur les requêtes de sous-ressources intersites. Une requête émise depuis une page du même site, par exemple un autre sous-domaine du domaine qui héberge le Hub, échappe à ces restrictions et produit toujours Sec-Fetch-Mode: no-cors. Le défaut reste réel, car le contrôle côté serveur ne vérifiait pas du tout l’origine.

Correctif

JupyterHub 5.4.5 est sorti le 28/04/2026 comme version de sécurité, l’avis étant publié sept jours plus tard. Le correctif, commit 9c5ec27 (« don’t skip xsrf check for no-cors »), retire no-cors de l’ensemble exempté :

     fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified")
-    if fetch_mode in {"websocket", "no-cors"} or (
+    if fetch_mode == "websocket" or (
         fetch_mode in {"navigate", "unspecified"}
         and handler.request.method.lower() in {"get", "head", "options"}
     ):

À partir de la 5.4.5, toute requête no-cors passe par la vérification du jeton. Cela a aussi touché des cas légitimes : _needs_check_xsrf() est partagée avec le code HubOAuth des serveurs individuels, où l’authentification par cookie s’applique aussi aux requêtes GET, si bien que les images et sous-ressources similaires de même origine ont perdu leur authentification par cookie. Une suite, la PR #5380 (« allow no-cors GETs from same-origin »), reportée en 5.4.6 le 05/05/2026, rétablit ces requêtes et consulte, pour la première fois, Sec-Fetch-Site :

# jupyterhub/_xsrf_utils.py (5.4.6)
fetch_mode = handler.request.headers.get("Sec-Fetch-Mode", "unspecified")
fetch_site = handler.request.headers.get("Sec-Fetch-Site", "unspecified")
method = handler.request.method.lower()
read_only = method in {"get", "head", "options"}

if (
    fetch_mode == "websocket"
    or (fetch_mode in {"navigate", "unspecified"} and read_only)
    or (
        # same-origin no-cors GET requests
        fetch_mode == "no-cors"
        and fetch_site == "same-origin"
        and read_only
    )
):
    ...
    return False

Une méthode qui modifie l’état exige désormais toujours un jeton valide, quels que soient les en-têtes Fetch Metadata, et la seule exemption no-cors restante concerne une requête en lecture seule dont le Sec-Fetch-Site vaut same-origin. Cette combinaison traite la classe d’erreur dans son ensemble : la question de l’origine est désormais tranchée par l’en-tête conçu pour cela.

Détection et mitigation

  1. Mettre à jour vers JupyterHub 5.4.5 ou une version ultérieure, de préférence 5.4.6 ou plus, pour que les requêtes GET no-cors de même origine continuent de fonctionner dans les serveurs individuels.
  2. En attendant, suivre l’avis et rejeter au niveau du reverse proxy placé devant le Hub les requêtes portant Sec-Fetch-Mode: no-cors. Pour limiter les effets de bord, la règle peut se restreindre aux méthodes qui modifient l’état, ce qui suffit pour ce défaut :
# nginx, contexte http
map "$request_method:$http_sec_fetch_mode" $jh_block_nocors {
    default 0;
    "~^(POST|PUT|PATCH|DELETE):no-cors$" 1;
}
# dans la location qui relaie JupyterHub
if ($jh_block_nocors) { return 403; }
  1. En défense en profondeur, rejeter les requêtes POST vers /hub/ dont le Sec-Fetch-Site vaut cross-site, après avoir vérifié qu’aucun callback d’authentification ne reçoit légitimement de POST intersite (c’est le cas du binding SAML POST).
  2. Dans les journaux du proxy, rechercher les POST /hub/spawn et POST /hub/accept-share portant Sec-Fetch-Mode: no-cors, ou un Origin différent de celui du Hub. Rapprocher les démarrages de serveurs visibles dans les journaux du Hub de l’activité des utilisateurs, et passer en revue les partages acceptés si le déploiement active le partage.

Chronologie

Date Événement
Avant le 13/04/2026 Signalement privé aux mainteneurs via un avis de sécurité GitHub
13/04/2026 Rédaction du correctif (9c5ec27, « don’t skip xsrf check for no-cors »)
28/04/2026 Intégration du correctif et publication de JupyterHub 5.4.5
05/05/2026 Publication de l’avis GHSA-m68r-v472-jgq9, attribution de CVE-2026-40864
05/05/2026 Publication de JupyterHub 5.4.6 avec la suite sur les GET no-cors de même origine (PR #5380)
Après la sortie de la 5.4.5 Publication de la preuve de concept

Références

  1. Avis de l’éditeur · CVE-2026-40864 github.com/jupyterhub/jupyterhub/security/advisories/GHSA-m68r-v472-jgq9
  2. Fiche NVD · CVE-2026-40864 nvd.nist.gov/vuln/detail/CVE-2026-40864
  3. Fiche CVE.org · CVE-2026-40864 www.cve.org/CVERecord?id=CVE-2026-40864
  4. MITRE CWE-352 cwe.mitre.org/data/definitions/352.html
  5. Dépôt du projet github.com/jupyterhub/jupyterhub
  6. Preuve de concept et notes de reproduction github.com/romain-deperne/CVE-2026-40864

Citer cet avis

rootzero. « CVE-2026-40864 : contournement XSRF dans JupyterHub via les requêtes Sec-Fetch-Mode: no-cors ». Recherche de vulnérabilités, rootzero, 5 mai 2026. https://rootzero.eu/research/cve/cve-2026-40864-jupyterhub-xsrf-bypass/

#Recherche de vulnérabilités

Faites auditer votre code avant qu’un autre ne le fasse.

Votre équipe doit décider quoi corriger avant une livraison ou évaluer une dépendance critique. Nous définissons une mission autour de cette décision, avec des preuves et des priorités de correction expliquées.