
JupyterHub XSRF bypass via cross-origin form POST (Sec-Fetch-Mode: no-cors) — CWE-352
Sec-Fetch-Mode: no-cors)Severity: Moderate
CWE: CWE-352 — Cross-Site Request Forgery (XSRF)
Affected: jupyterhub 4.1.0 ≤ version < 5.4.5 (patched in 5.4.5)
Advisory: GHSA-m68r-v472-jgq9
NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-40864
Credit: Romain Deperne
JupyterHub's XSRF protection (reworked in 4.1.0) used the Sec-Fetch-Mode request header to decide
whether a request was same-origin. It treated Sec-Fetch-Mode: no-cors as same-origin — but
no-cors is exactly what a browser sends for a cross-origin "simple" form submission. As a
result, cross-origin HTML form POSTs to the Hub's form endpoints (/hub/spawn, /hub/accept-share)
bypassed the XSRF check entirely.
The JSON API is unaffected (it requires a non-simple content type, which forces a CORS preflight). Only the HTML form endpoints are reachable this way.
The XSRF logic short-circuits to "trusted" for a set of Sec-Fetch-* states meant to capture
same-origin navigations. Sec-Fetch-Mode: no-cors was included in that trusted set. But no-cors
is the mode the browser assigns to a plain <form method=POST> posting to a different origin —
precisely the classic CSRF vector the token is supposed to stop. So any state-changing endpoint that
accepts a simple form body, and relies solely on this XSRF gate, is forgeable cross-origin.
Mapping that to real endpoints: /hub/spawn (start the victim's server) and /hub/accept-share
(make the victim accept a share of the attacker's server) are both form POSTs behind this gate.
/hub/spawn — an attacker page can spawn the victim's single-user server without consent
(resource consumption / unexpected state; the attacker does not gain access to that server)./hub/accept-share — when the attacker is a JupyterHub user permitted to share their own
server, they can force a victim to accept a share, giving the victim access to the attacker's
server (a setup step for further social-engineering / data-drop scenarios).Using Sec-Fetch-Mode as an origin oracle is unsound: no-cors does not imply same-origin. The fix
in 5.4.5 stops trusting no-cors as same-origin. Operators who cannot upgrade immediately can drop
requests carrying Sec-Fetch-Mode: no-cors at the reverse proxy.
poc/csrf_spawn.html — host it on any attacker origin and have a logged-in JupyterHub user open it.
The auto-submitting form issues a cross-origin POST to /hub/spawn (browser sends
Sec-Fetch-Mode: no-cors); vulnerable builds accept it with no valid _xsrf token and spawn the
victim's server. Point action at /hub/accept-share for the share-acceptance variant.
1. Edit TARGET-JUPYTERHUB in poc/csrf_spawn.html
2. Serve the file from an attacker origin (any static host)
3. Open it in a browser already authenticated to the target Hub
4. Observe the victim's server spawn with no XSRF token supplied
Disclosed responsibly. PoC published after the fix shipped.