CVE-2026-40864
CVSS 5.4 · Medium PatchedCVE-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
- Product
- JupyterHub
- 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.
The cookie and request mode that decide the result
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
- Upgrade to JupyterHub 5.4.5 or later, preferably 5.4.6 or later so that same-origin
no-corsGETrequests keep working in single-user servers. - Until then, follow the advisory and drop requests carrying
Sec-Fetch-Mode: no-corsat 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; }
- As defence in depth, reject
POSTrequests to/hub/whoseSec-Fetch-Siteiscross-site, after checking that no authenticator callback legitimately receives cross-site posts (a SAML POST binding does). - In proxy logs, look for
POST /hub/spawnandPOST /hub/accept-sharewithSec-Fetch-Mode: no-cors, or with anOriginthat 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
- Vendor advisory · CVE-2026-40864 github.com/jupyterhub/jupyterhub/security/advisories/GHSA-m68r-v472-jgq9
- NVD entry · CVE-2026-40864 nvd.nist.gov/vuln/detail/CVE-2026-40864
- CVE.org record · CVE-2026-40864 www.cve.org/CVERecord?id=CVE-2026-40864
- MITRE CWE-352 cwe.mitre.org/data/definitions/352.html
- Project repository github.com/jupyterhub/jupyterhub
- 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.