CVE-2026-40110
Description
Jupyter Server is the backend for Jupyter web applications. In versions 2.17.0 and earlier, the Origin header validation uses Python's re.match() to check incoming origins against the allow_origin_pat configuration value. Because re.match() only anchors at the start of the string and does not require a full match, a pattern intended to match only a trusted domain (e.g., trusted.example.com) will also match any origin that begins with that domain followed by additional characters (e.g., trusted.example.com.evil.com). An attacker who controls such a domain can bypass the CORS origin restriction and make cross-origin requests to the Jupyter Server API from an untrusted site. This issue has been fixed in version 2.18.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
jupyter-serverPyPI | < 2.18.0 | 2.18.0 |
Affected products
2- Range: <= 2.17.0
Patches
349b34392feaaMove check origin into a util function and add it to websocket (#1630)
6 files changed · +136 −40
jupyter_server/auth/login.py+2 −1 modified@@ -10,6 +10,7 @@ from tornado.escape import url_escape from ..base.handlers import JupyterHandler +from ..utils import origin_matches_pat from .decorator import allow_unauthenticated from .security import passwd_check, set_password @@ -73,7 +74,7 @@ def _redirect_safe(self, url, default=None): if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = self._origin_matches_pat(origin) + allow = origin_matches_pat(self.allow_origin_pat, origin) if not allow: # not allowed, use default self.log.warning("Not allowing login redirect to %r" % url)
jupyter_server/base/handlers.py+4 −24 modified@@ -36,6 +36,7 @@ from jupyter_server.utils import ( ensure_async, filefind, + origin_matches_pat, url_escape, url_is_absolute, url_path_join, @@ -383,27 +384,6 @@ def allow_credentials(self) -> bool: """Whether to set Access-Control-Allow-Credentials""" return cast("bool", self.settings.get("allow_credentials", False)) - def _origin_matches_pat(self, origin: str) -> bool: - """Check whether origin matches ``allow_origin_pat`` using full-string matching. - - Uses ``re.fullmatch`` so the pattern must cover the entire origin string, - preventing prefix-bypass attacks (GHSA-24qx-w28j-9m6p/CVE-2026-40110). - Emits a warning in case a user relied on prefix-style patterns. - """ - if not self.allow_origin_pat: - return False - if re.fullmatch(self.allow_origin_pat, origin): - return True - if re.match(self.allow_origin_pat, origin): - warnings.warn( - f"allow_origin_pat {self.allow_origin_pat!r} only matched the request origin as a prefix. " - "This has been replaced with a full string match. " - "Update your pattern if you need to prefix-match the origin (e.g. append '.*')", - UserWarning, - stacklevel=2, - ) - return False - def set_default_headers(self) -> None: """Add CORS headers, if defined""" super().set_default_headers() @@ -418,7 +398,7 @@ def set_cors_headers(self) -> None: self.set_header("Access-Control-Allow-Origin", self.allow_origin) elif self.allow_origin_pat: origin = self.get_origin() - if origin and self._origin_matches_pat(origin): + if origin and origin_matches_pat(self.allow_origin_pat, origin): self.set_header("Access-Control-Allow-Origin", origin) elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get( "headers", {} @@ -488,7 +468,7 @@ def check_origin(self, origin_to_satisfy_tornado: str = "") -> bool: if self.allow_origin: allow = bool(self.allow_origin == origin) elif self.allow_origin_pat: - allow = self._origin_matches_pat(origin) + allow = origin_matches_pat(self.allow_origin_pat, origin) else: # No CORS headers deny the request allow = False @@ -533,7 +513,7 @@ def check_referer(self) -> bool: if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = self._origin_matches_pat(origin) + allow = origin_matches_pat(self.allow_origin_pat, origin) else: # No CORS settings, deny the request allow = False
jupyter_server/base/websocket.py+2 −2 modified@@ -8,7 +8,7 @@ from tornado.iostream import IOStream from jupyter_server.base.handlers import JupyterHandler -from jupyter_server.utils import JupyterServerAuthWarning +from jupyter_server.utils import JupyterServerAuthWarning, origin_matches_pat # ping interval for keeping websockets alive (30 seconds) WS_PING_INTERVAL = 30000 @@ -71,7 +71,7 @@ def check_origin(self, origin: Optional[str] = None) -> bool: if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = self._origin_matches_pat(origin) + allow = origin_matches_pat(self.allow_origin_pat, origin) else: # No CORS headers deny the request allow = False
jupyter_server/utils.py+23 −0 modified@@ -7,6 +7,7 @@ import errno import importlib.util import os +import re import socket import sys import warnings @@ -42,6 +43,28 @@ ensure_async = _ensure_async +def origin_matches_pat(allow_origin_pat: str, origin: str) -> bool: + """Check whether origin matches ``allow_origin_pat`` using full-string matching. + + Uses ``re.fullmatch`` so the pattern must cover the entire origin string, + preventing prefix-bypass attacks (GHSA-24qx-w28j-9m6p/CVE-2026-40110). + Emits a warning in case a user relied on prefix-style patterns. + """ + if not allow_origin_pat: + return False + if re.fullmatch(allow_origin_pat, origin): + return True + if re.match(allow_origin_pat, origin): + warnings.warn( + f"allow_origin_pat {allow_origin_pat!r} only matched the request origin as a prefix. " + "This has been replaced with a full string match. " + "Update your pattern if you need to prefix-match the origin (e.g. append '.*')", + UserWarning, + stacklevel=3, + ) + return False + + def url_path_join(*pieces: str) -> str: """Join components of url into a relative url
tests/base/test_allow_origin_pat.py+19 −13 modified@@ -7,6 +7,7 @@ to match only "https://trusted.example.com". """ +import warnings from unittest.mock import MagicMock import pytest @@ -45,7 +46,8 @@ def _make_handler(jp_serverapp, origin=None, host="localhost:8888", referer=None @pytest.mark.parametrize("origin", BYPASS_ORIGINS) def test_check_origin_rejects_bypass(jp_serverapp, origin): - assert not _make_handler(jp_serverapp, origin=origin, host="other.host:8888").check_origin() + with pytest.warns(UserWarning, match="allow_origin_pat.*"): + assert not _make_handler(jp_serverapp, origin=origin, host="other.host:8888").check_origin() def test_check_origin_allows_trusted(jp_serverapp): @@ -55,29 +57,32 @@ def test_check_origin_allows_trusted(jp_serverapp): @pytest.mark.parametrize("origin", BYPASS_ORIGINS) def test_check_referer_rejects_bypass(jp_serverapp, origin): handler = _make_handler(jp_serverapp, host="other.host:8888", referer=f"{origin}/page") - assert not handler.check_referer() + with pytest.warns(UserWarning, match="allow_origin_pat.*"): + assert not handler.check_referer() @pytest.mark.parametrize("origin", BYPASS_ORIGINS) def test_set_cors_headers_rejects_bypass(jp_serverapp, origin): - handler = _make_handler(jp_serverapp, origin=origin) - handler.set_cors_headers() - assert handler._headers.get("Access-Control-Allow-Origin") != origin + with pytest.warns(UserWarning, match="allow_origin_pat.*"): + handler = _make_handler(jp_serverapp, origin=origin) + handler.set_cors_headers() + assert handler._headers.get("Access-Control-Allow-Origin") != origin @pytest.mark.parametrize("attacker_origin", BYPASS_ORIGINS) def test_login_redirect_safe_rejects_bypass(jp_serverapp, attacker_origin): request = HTTPRequest("GET", headers=HTTPHeaders({"Host": "localhost:8888"})) request.connection = MagicMock() - handler = LoginHandler(jp_serverapp.web_app, request) - handler.settings["allow_origin"] = "" - handler.settings["allow_origin_pat"] = TRUSTED_PAT + with pytest.warns(UserWarning, match="allow_origin_pat.*"): + handler = LoginHandler(jp_serverapp.web_app, request) + handler.settings["allow_origin"] = "" + handler.settings["allow_origin_pat"] = TRUSTED_PAT - redirected_to = [] - handler.redirect = lambda url, *a, **kw: redirected_to.append(url) - handler._redirect_safe(f"{attacker_origin}/capture", default=jp_serverapp.base_url) + redirected_to = [] + handler.redirect = lambda url, *a, **kw: redirected_to.append(url) + handler._redirect_safe(f"{attacker_origin}/capture", default=jp_serverapp.base_url) - assert redirected_to[0] != f"{attacker_origin}/capture" + assert redirected_to[0] != f"{attacker_origin}/capture" @pytest.mark.parametrize("attacker_origin", BYPASS_ORIGINS) @@ -96,7 +101,8 @@ class TestWSHandler(WebSocketMixin, JupyterHandler): handler.settings["allow_origin_pat"] = TRUSTED_PAT handler.skip_check_origin = lambda: False - assert not handler.check_origin(attacker_origin) + with pytest.warns(UserWarning, match="allow_origin_pat.*"): + assert not handler.check_origin(attacker_origin) def test_truncated_pattern_warns_and_blocks(jp_serverapp):
tests/test_utils.py+86 −0 modified@@ -15,6 +15,7 @@ check_version, filefind, is_namespace_package, + origin_matches_pat, path2url, run_sync_in_loop, samefile_simple, @@ -164,3 +165,88 @@ def test_filefind(tmp_path, filename, result): else: with pytest.raises(result): filefind(filename, [str(a), str(b)]) + + +TRUSTED_PAT = r"https://trusted\.example\.com" + + +@pytest.mark.parametrize( + "origin", + [ + "https://trusted.example.com", + # pattern is the full origin string + ], +) +def test_origin_matches_pat_accepts_exact(origin): + assert origin_matches_pat(TRUSTED_PAT, origin) is True + + +@pytest.mark.parametrize( + "origin", + [ + # suffix-bypass: pre-CVE-2026-40110 prefix matching would have allowed these + "https://trusted.example.com.evil.com", + "https://trusted.example.comedy", + "https://trusted.example.com:9999", + "https://trusted.example.com/path", + # newline injection — must not be allowed via $-anchor or otherwise + "https://trusted.example.com\nhttps://evil.com", + ], +) +def test_origin_matches_pat_rejects_full_match_failures(origin): + # These all match `re.match` (prefix) but NOT `re.fullmatch`, so the helper + # warns and returns False. + with pytest.warns(UserWarning, match="only matched the request origin as a prefix"): + assert origin_matches_pat(TRUSTED_PAT, origin) is False + + +@pytest.mark.parametrize( + "origin", + [ + "http://trusted.example.com", + "https://other.example.com", + "https://trusted.example.co", + "", + ], +) +def test_origin_matches_pat_rejects_non_match(origin): + # No prefix match either, so no warning. + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert origin_matches_pat(TRUSTED_PAT, origin) is False + + +def test_origin_matches_pat_empty_pattern_rejects_all(): + # Empty pattern must never allow any origin (empty-string regex would otherwise + # fullmatch only the empty string, but the early-return guard makes this explicit). + assert origin_matches_pat("", "") is False + assert origin_matches_pat("", "https://anything") is False + + +def test_origin_matches_pat_respects_user_anchors(): + # Patterns that already include ^ and $ should still work (fullmatch is idempotent + # with explicit anchors). + assert origin_matches_pat(r"^https://trusted\.example\.com$", "https://trusted.example.com") + assert not origin_matches_pat( + r"^https://trusted\.example\.com$", "https://trusted.example.com.evil" + ) + + +def test_origin_matches_pat_alternation(): + pat = r"https://a\.com|https://b\.com" + assert origin_matches_pat(pat, "https://a.com") is True + assert origin_matches_pat(pat, "https://b.com") is True + # alternation must not be exploitable as a prefix: + with pytest.warns(UserWarning, match="only matched the request origin as a prefix"): + assert origin_matches_pat(pat, "https://a.comevil") is False + + +def test_origin_matches_pat_unescaped_dot_is_a_footgun(): + # Documented footgun: an operator who forgets to escape `.` writes a regex that + # treats it as a wildcard. The helper has no way to detect this — it is the + # caller's responsibility to escape literal characters in `allow_origin_pat`. + # This test pins the current behavior so a future change that auto-escapes or + # warns about unescaped dots is a deliberate, reviewed decision. + bad_pattern = r"https://trusted.example.com" # dots NOT escaped + assert origin_matches_pat(bad_pattern, "https://trustedxexamplexcom") is True + assert origin_matches_pat(bad_pattern, "https://trusted-example-com") is True
057869a327c4Fix allow_origin_pat to do full matching instead of prefix matching
4 files changed · +138 −6
jupyter_server/auth/login.py+1 −1 modified@@ -68,7 +68,7 @@ def _redirect_safe(self, url, default=None): if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(re.match(self.allow_origin_pat, origin)) + allow = self._origin_matches_pat(origin) if not allow: # not allowed, use default self.log.warning("Not allowing login redirect to %r" % url)
jupyter_server/base/handlers.py+24 −3 modified@@ -383,6 +383,27 @@ def allow_credentials(self) -> bool: """Whether to set Access-Control-Allow-Credentials""" return cast("bool", self.settings.get("allow_credentials", False)) + def _origin_matches_pat(self, origin: str) -> bool: + """Check whether origin matches ``allow_origin_pat`` using full-string matching. + + Uses ``re.fullmatch`` so the pattern must cover the entire origin string, + preventing prefix-bypass attacks (GHSA-24qx-w28j-9m6p/CVE-2026-40110). + Emits a warning in case a user relied on prefix-style patterns. + """ + if not self.allow_origin_pat: + return False + if re.fullmatch(self.allow_origin_pat, origin): + return True + if re.match(self.allow_origin_pat, origin): + warnings.warn( + f"allow_origin_pat {self.allow_origin_pat!r} only matched the request origin as a prefix. " + "This has been replaced with a full string match. " + "Update your pattern if you need to prefix-match the origin (e.g. append '.*')", + UserWarning, + stacklevel=2, + ) + return False + def set_default_headers(self) -> None: """Add CORS headers, if defined""" super().set_default_headers() @@ -397,7 +418,7 @@ def set_cors_headers(self) -> None: self.set_header("Access-Control-Allow-Origin", self.allow_origin) elif self.allow_origin_pat: origin = self.get_origin() - if origin and re.match(self.allow_origin_pat, origin): + if origin and self._origin_matches_pat(origin): self.set_header("Access-Control-Allow-Origin", origin) elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get( "headers", {} @@ -467,7 +488,7 @@ def check_origin(self, origin_to_satisfy_tornado: str = "") -> bool: if self.allow_origin: allow = bool(self.allow_origin == origin) elif self.allow_origin_pat: - allow = bool(re.match(self.allow_origin_pat, origin)) + allow = self._origin_matches_pat(origin) else: # No CORS headers deny the request allow = False @@ -512,7 +533,7 @@ def check_referer(self) -> bool: if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(re.match(self.allow_origin_pat, origin)) + allow = self._origin_matches_pat(origin) else: # No CORS settings, deny the request allow = False
jupyter_server/base/websocket.py+1 −2 modified@@ -1,6 +1,5 @@ """Base websocket classes.""" -import re import warnings from typing import Optional, no_type_check from urllib.parse import urlparse @@ -72,7 +71,7 @@ def check_origin(self, origin: Optional[str] = None) -> bool: if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(re.match(self.allow_origin_pat, origin)) + allow = self._origin_matches_pat(origin) else: # No CORS headers deny the request allow = False
tests/base/test_allow_origin_pat.py+112 −0 added@@ -0,0 +1,112 @@ +"""Tests for allow_origin_pat origin validation using re.fullmatch() + +(GHSA-24qx-w28j-9m6p and CVE-2026-40110) + +allow_origin_pat must match the full origin string. re.match() only anchors at the +start, allowing "https://trusted.example.com.evil.com" to bypass a pattern intended +to match only "https://trusted.example.com". +""" + +from unittest.mock import MagicMock + +import pytest +from tornado.httpserver import HTTPRequest +from tornado.httputil import HTTPHeaders + +from jupyter_server.auth.login import LoginHandler +from jupyter_server.base.handlers import JupyterHandler +from jupyter_server.base.websocket import WebSocketMixin + +TRUSTED_PAT = r"https://trusted\.example\.com" +TRUSTED_ORIGIN = "https://trusted.example.com" + +BYPASS_ORIGINS = [ + "https://trusted.example.com.evil.com", + "https://trusted.example.comedy", + "https://trusted.example.com:9999", +] + + +def _make_handler(jp_serverapp, origin=None, host="localhost:8888", referer=None): + headers = {"Host": host} + if origin: + headers["Origin"] = origin + if referer: + headers["Referer"] = referer + request = HTTPRequest("GET", headers=HTTPHeaders(headers)) + request.connection = MagicMock() + handler = JupyterHandler(jp_serverapp.web_app, request) + handler.settings["allow_origin"] = "" + handler.settings["allow_origin_pat"] = TRUSTED_PAT + # skip_check_origin() requires async auth context; bypass it to reach pattern matching + handler.skip_check_origin = lambda: False + return handler + + +@pytest.mark.parametrize("origin", BYPASS_ORIGINS) +def test_check_origin_rejects_bypass(jp_serverapp, origin): + assert not _make_handler(jp_serverapp, origin=origin, host="other.host:8888").check_origin() + + +def test_check_origin_allows_trusted(jp_serverapp): + assert _make_handler(jp_serverapp, origin=TRUSTED_ORIGIN, host="other.host:8888").check_origin() + + +@pytest.mark.parametrize("origin", BYPASS_ORIGINS) +def test_check_referer_rejects_bypass(jp_serverapp, origin): + handler = _make_handler(jp_serverapp, host="other.host:8888", referer=f"{origin}/page") + assert not handler.check_referer() + + +@pytest.mark.parametrize("origin", BYPASS_ORIGINS) +def test_set_cors_headers_rejects_bypass(jp_serverapp, origin): + handler = _make_handler(jp_serverapp, origin=origin) + handler.set_cors_headers() + assert handler._headers.get("Access-Control-Allow-Origin") != origin + + +@pytest.mark.parametrize("attacker_origin", BYPASS_ORIGINS) +def test_login_redirect_safe_rejects_bypass(jp_serverapp, attacker_origin): + request = HTTPRequest("GET", headers=HTTPHeaders({"Host": "localhost:8888"})) + request.connection = MagicMock() + handler = LoginHandler(jp_serverapp.web_app, request) + handler.settings["allow_origin"] = "" + handler.settings["allow_origin_pat"] = TRUSTED_PAT + + redirected_to = [] + handler.redirect = lambda url, *a, **kw: redirected_to.append(url) + handler._redirect_safe(f"{attacker_origin}/capture", default=jp_serverapp.base_url) + + assert redirected_to[0] != f"{attacker_origin}/capture" + + +@pytest.mark.parametrize("attacker_origin", BYPASS_ORIGINS) +def test_websocket_check_origin_rejects_bypass(jp_serverapp, attacker_origin): + request = HTTPRequest( + "GET", + headers=HTTPHeaders({"Origin": attacker_origin, "Host": "other.host:8888"}), + ) + request.connection = MagicMock() + + class TestWSHandler(WebSocketMixin, JupyterHandler): + pass + + handler = TestWSHandler(jp_serverapp.web_app, request) + handler.settings["allow_origin"] = "" + handler.settings["allow_origin_pat"] = TRUSTED_PAT + handler.skip_check_origin = lambda: False + + assert not handler.check_origin(attacker_origin) + + +def test_truncated_pattern_warns_and_blocks(jp_serverapp): + """Prefix-only pattern (e.g. 'https://trusted') emits UserWarning and blocks the request.""" + handler = _make_handler( + jp_serverapp, origin="https://trusted.example.com", host="other.host:8888" + ) + handler.settings["allow_origin_pat"] = r"https://trusted" + + with pytest.warns(UserWarning, match="only matched the request origin as a prefix"): + result = handler.check_origin() + + assert not result
3ecb4e0184b7Fix allow_origin_pat property to properly parse regex (#603)
3 files changed · +6 −5
jupyter_server/auth/login.py+1 −1 modified@@ -53,7 +53,7 @@ def _redirect_safe(self, url, default=None): if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(self.allow_origin_pat.match(origin)) + allow = bool(re.match(self.allow_origin_pat, origin)) if not allow: # not allowed, use default self.log.warning("Not allowing login redirect to %r" % url)
jupyter_server/base/handlers.py+3 −3 modified@@ -309,7 +309,7 @@ def set_default_headers(self): self.set_header("Access-Control-Allow-Origin", self.allow_origin) elif self.allow_origin_pat: origin = self.get_origin() - if origin and self.allow_origin_pat.match(origin): + if origin and re.match(self.allow_origin_pat, origin): self.set_header("Access-Control-Allow-Origin", origin) elif self.token_authenticated and "Access-Control-Allow-Origin" not in self.settings.get( "headers", {} @@ -382,7 +382,7 @@ def check_origin(self, origin_to_satisfy_tornado=""): if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(self.allow_origin_pat.match(origin)) + allow = bool(re.match(self.allow_origin_pat, origin)) else: # No CORS headers deny the request allow = False @@ -427,7 +427,7 @@ def check_referer(self): if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(self.allow_origin_pat.match(origin)) + allow = bool(re.match(self.allow_origin_pat, origin)) else: # No CORS settings, deny the request allow = False
jupyter_server/base/zmqhandlers.py+2 −1 modified@@ -3,6 +3,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json +import re import struct import sys from urllib.parse import urlparse @@ -139,7 +140,7 @@ def check_origin(self, origin=None): if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: - allow = bool(self.allow_origin_pat.match(origin)) + allow = bool(re.match(self.allow_origin_pat, origin)) else: # No CORS headers deny the request allow = False
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/jupyter-server/jupyter_server/commit/057869a327c46730afede3eab0ca2d2e3e74aceanvdPatchWEB
- github.com/jupyter-server/jupyter_server/commit/49b34392feaa97735b3b777e3baf8f22f2a14ed8nvdPatchWEB
- github.com/jupyter-server/jupyter_server/security/advisories/GHSA-24qx-w28j-9m6pnvdPatchVendor AdvisoryMitigationWEB
- github.com/advisories/GHSA-24qx-w28j-9m6pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-40110ghsaADVISORY
- github.com/jupyter-server/jupyter_server/pull/603nvdIssue TrackingWEB
News mentions
0No linked articles in our index yet.