CVE-2026-40864

CVSS 5.4 · Medium Patched
Type
Vulnerability research
Published
Reading time
8 min read
Author
rootzero
Lire en français
Lire en français →

CVE-2026-40864: XSRF bypass in JupyterHub through Sec-Fetch-Mode: no-cors requests

JupyterHub 4.1.0 to 5.4.4 skipped its XSRF check for any no-cors request, so a page on another origin could submit Hub forms for a logged-in user.

For your decision

Impact
A hostile page can trigger certain Hub actions using the victim’s session.
Conditions
The victim must be signed in. The scenario depends on the cookies, request mode and configuration described in the write-up.
Recommended action
Upgrade to JupyterHub 5.4.5 or later and review the affected authenticated workflows.

Advisory facts

Package
pypi · jupyterhub
Affected versions
≥ 4.1.0, < 5.4.5
Fixed in
5.4.5
CVSS
5.4 Medium
Weakness
CWE-352
Access needed
No authentication
Advisory published
Credit
rootzero

Investigation summary

From 4.1.0, JupyterHub used the Sec-Fetch-Mode header to decide whether a cookie-authenticated request needed an XSRF token, and exempted every no-cors request whatever its method or origin. A page on another origin could send credentialed form-encoded POSTs to /hub/spawn or /hub/accept-share, starting the victim’s server or making the victim accept a share of the attacker’s server. Fixed in 5.4.5.

The PoC that did not work

We examined the decision made by _needs_check_xsrf() when a browser sends an authenticated POST to the Hub. The function looked at Sec-Fetch-Mode and exempted no-cors from the _xsrf token without comparing it with Sec-Fetch-Site. We connected that branch to two actions accepting form bodies: spawning a server and accepting a share. Our private report preceded the fix drafted on 13 April 2026.

Our public PoC initially described a cross-site HTML form as if it carried Sec-Fetch-Mode: no-cors. That was wrong: the browser sends it as navigate, and the vulnerable code would require a token on that path. No browser response from a corrected replay is retained in our dossier. Reviewing the code, Fetch specification and patch points to a different route: a form-encoded fetch() in no-cors mode. It reaches the exemption, provided the cookie policy lets the session travel from the chosen origin. This correction changes the practical scope of the story; we do not bury it behind the vendor advisory. JupyterHub released 5.4.5 on 28 April and published CVE-2026-40864 on 5 May.

JupyterHub accepts state-changing requests on /hub/ routes such as spawn and accept-share. A successful XSRF attempt needs the Hub session cookie as well as a request mode that the token check mistakenly exempts.

The Hub itself serves a handful of HTML pages under /hub/, and several of them accept form submissions that change state: /hub/spawn starts a server, and /hub/accept-share (added with the server sharing feature) gives the current user access to someone else’s server. These pages rely on the browser’s session cookie, jupyterhub-hub-login, so they need protection against cross-site request forgery. JupyterHub implements that protection with a signed _xsrf token, and since version 4.1.0 it also reads the Fetch Metadata request headers to decide when the token can be skipped.

What Fetch Metadata actually said

Fetch Metadata headers are a good signal, provided each header is used for the question it answers. Sec-Fetch-Site says where a request comes from relative to the target (same-origin, same-site, cross-site, none). Sec-Fetch-Mode says how the request was made: navigate for a document navigation, cors for a fetch() subject to CORS, no-cors for subresources and fetch() calls in no-cors mode, same-origin, or websocket.

The question behind this research was whether JupyterHub’s list of exempt Fetch Metadata states contained one that does not imply a same-origin request. Reading _needs_check_xsrf() answered it: the function looks only at Sec-Fetch-Mode, never at Sec-Fetch-Site, and it exempts no-cors unconditionally. Any origin can produce a no-cors request, and the mode does not restrict the method to GET: a no-cors request may be a POST with a form-encoded body. The remaining step was to map the gate to endpoints that accept such a body and change state, which led to /hub/spawn and /hub/accept-share.

Root cause

The Hub’s BaseHandler.prepare() calls check_xsrf_cookie() for every method outside GET, HEAD and OPTIONS. That function first asks _needs_check_xsrf() whether a check is needed at all. In 5.4.4, the answer was:

# 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

The method condition only applies to navigate and unspecified. For websocket and no-cors, the function returns False before the method is considered, so a POST in no-cors mode reaches the handler with no token at all. The comment shows the intent: a no-cors request cannot read the response, so it was treated as harmless. That reasoning holds for confidentiality and fails for CSRF, whose whole point is the side effect of a request whose response the attacker never sees. The result is CWE-352, cross-site request forgery.

The two handlers behind the gate act on form data. SpawnHandler.post() builds spawn options from self.request.body_arguments and starts the server; AcceptShareHandler.post() reads a code argument, looks up the matching ShareCode and calls share_code.exchange(user.orm_user). Neither has a second line of defence.

Exploitation

In our public write-up and proof of concept, we described the trigger as an auto-submitting cross-origin <form method="POST">, stating that the browser sends such a submission as Sec-Fetch-Mode: no-cors. The Fetch specification and the code disagree with that wording. A form submission is a navigation, so the browser sends Sec-Fetch-Mode: navigate, and the vulnerable code does check the token for a POST navigation (the empty _xsrf field in the PoC form should yield a 403 on this code path; we have not retained a browser replay). The request that reaches the exemption is a fetch() call in no-cors mode carrying the same form-encoded body, which is what the advisory and the fix address. A no-cors fetch is restricted to GET, HEAD and POST with CORS-safelisted headers, and application/x-www-form-urlencoded is one of them:

// served from an origin other than the Hub
fetch("https://hub.example.org/hub/spawn", {
  method: "POST",
  mode: "no-cors",
  credentials: "include",
  body: new URLSearchParams({}),
});

The browser then sends:

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=<victim session>; jupyterhub-session-id=<id>
Sec-Fetch-Mode: no-cors
Sec-Fetch-Site: cross-site
Sec-Fetch-Dest: empty

_needs_check_xsrf() sees no-cors and returns False, the cookie authenticates the victim, and SpawnHandler starts their server with empty form options. The response is opaque to the attacking page, which does not matter. For the share variant, the attacker, a Hub user allowed to share their own server, creates a share code and sends new URLSearchParams({ code: "<share code>" }) to /hub/accept-share. The handler exchanges the code for the victim, who now has access to the attacker’s server.

The JSON API under /hub/api/ is out of reach: it requires a non-simple content type, which a no-cors request cannot carry.

Impact

The vendor vector is AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:L (5.4). The attacker needs no account for the spawn variant; the victim must be logged in to the Hub and visit a page the attacker controls. There is no confidentiality impact: the attacker never gains access to the spawned server. Integrity and availability are low: a server starts without consent and consumes resources, or a share is accepted that the victim never chose. The share variant does require the attacker to be a Hub user with permission to share, and its value is as a setup step: the victim’s account now opens the attacker’s environment, where notebooks or files they did not expect can be waiting.

Browser cookie policy narrows the practical attacker origins. JupyterHub sets no SameSite attribute on its cookies by default; Chromium-based browsers then treat them as Lax, and current third-party cookie protections in other browsers also withhold or partition cookies on cross-site subresource requests. A request from a page on the same site, for instance another subdomain of the domain that hosts the Hub, does not hit those restrictions and still produces Sec-Fetch-Mode: no-cors. The flaw remains real because the server-side gate did not test the origin at all.

The fix

JupyterHub 5.4.5 was released on 2026-04-28 as a security release, with the advisory published seven days later. The fix, commit 9c5ec27 (“don’t skip xsrf check for no-cors”), removes no-cors from the exempt set:

     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"}
     ):

After 5.4.5, any no-cors request goes through the token check. That also caught legitimate cases: _needs_check_xsrf() is shared with the HubOAuth code used by single-user servers, where cookie authentication applies to GET requests too, so same-origin images and similar subresources lost their cookie authentication. A follow-up, PR #5380 (“allow no-cors GETs from same-origin”), backported to 5.4.6 on 2026-05-05, restores those requests and, for the first time, consults 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

A state-changing method now always needs a valid token, whatever the Fetch Metadata headers say, and the only no-cors exemption left is a read-only request whose Sec-Fetch-Site is same-origin. That combination addresses the class of mistake as a whole: the origin question is now answered by the header designed for it.

Detection and mitigation

  1. Upgrade to JupyterHub 5.4.5 or later, preferably 5.4.6 or later so that same-origin no-cors GET requests keep working in single-user servers.
  2. Until then, follow the advisory and drop requests carrying Sec-Fetch-Mode: no-cors at the reverse proxy in front of the Hub. To limit side effects, the rule can be restricted to state-changing methods, which is enough for this flaw:
# nginx, http context
map "$request_method:$http_sec_fetch_mode" $jh_block_nocors {
    default 0;
    "~^(POST|PUT|PATCH|DELETE):no-cors$" 1;
}
# in the location that proxies JupyterHub
if ($jh_block_nocors) { return 403; }
  1. As defence in depth, reject POST requests to /hub/ whose Sec-Fetch-Site is cross-site, after checking that no authenticator callback legitimately receives cross-site posts (a SAML POST binding does).
  2. In proxy logs, look for POST /hub/spawn and POST /hub/accept-share with Sec-Fetch-Mode: no-cors, or with an Origin that is not the Hub’s own. Correlate server starts in the Hub logs with user activity, and review the shares users have accepted if the deployment enables sharing.

Timeline

Date Event
Before 2026-04-13 Private report to the maintainers through a GitHub security advisory
2026-04-13 Fix authored (9c5ec27, “don’t skip xsrf check for no-cors”)
2026-04-28 Fix merged and JupyterHub 5.4.5 released
2026-05-05 Advisory GHSA-m68r-v472-jgq9 published, CVE-2026-40864 assigned
2026-05-05 JupyterHub 5.4.6 released with the same-origin no-cors GET follow-up (PR #5380)
After the release of 5.4.5 Proof of concept published

References

  1. Vendor advisory · CVE-2026-40864 github.com/jupyterhub/jupyterhub/security/advisories/GHSA-m68r-v472-jgq9
  2. NVD entry · CVE-2026-40864 nvd.nist.gov/vuln/detail/CVE-2026-40864
  3. CVE.org record · CVE-2026-40864 www.cve.org/CVERecord?id=CVE-2026-40864
  4. MITRE CWE-352 cwe.mitre.org/data/definitions/352.html
  5. Project repository github.com/jupyterhub/jupyterhub
  6. Proof of concept and reproduction notes github.com/romain-deperne/CVE-2026-40864

Cite this advisory

rootzero. “CVE-2026-40864: XSRF bypass in JupyterHub through Sec-Fetch-Mode: no-cors requests”. Vulnerability research, rootzero, 5 May 2026. https://rootzero.eu/en/research/cve/cve-2026-40864-jupyterhub-xsrf-bypass/

#Vulnerability research

Have your code audited before someone else does.

Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.