CVE-2023-39968: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: jupyter-server is the backend for Jupyter web applications. Open Redirect Vulnerability. Maliciously crafted login links to known Jupyter Servers can cause successful login or an already logged-in session to be redirected to arbitrary sites, which should be restricted to Jupyter Server-served URLs. This issue has been addressed in commit `29036259` which is included in release 2.7.2. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
CVSS v3 base score: 6.1
Published: 2023-08-28T21:15Z
Repository: https://github.com/jupyter-server/jupyter_server
Language: Python
Files changed: jupyter_server/auth/login.py, tests/auth/test_login.py
Commit date: 2023-08-17 20:27:27 -0700
Commit message: Merge pull request from GHSA-r726-vmfq-j9j3

Co-authored-by: Zachary Sailer <zsailer@apple.com>
Diff: diff --git a/jupyter_server/auth/login.py b/jupyter_server/auth/login.py
index 5b6f171c38..2105bab3b8 100644
--- a/jupyter_server/auth/login.py
+++ b/jupyter_server/auth/login.py
@@ -39,16 +39,29 @@ class LoginFormHandler(JupyterHandler):
             default = self.base_url
         # protect chrome users from mishandling unescaped backslashes.
         # \ is not valid in urls, but some browsers treat it as /
         # instead of %5C, causing `\\` to behave as `//`
         url = url.replace("\\", "%5C")
+        # urllib and browsers interpret extra '/' in the scheme separator (`scheme:///host/path`)
+        # differently.
+        # urllib gives scheme=scheme, netloc='', path='/host/path', while
+        # browsers get scheme=scheme, netloc='host', path='/path'
+        # so make sure ':///*' collapses to '://' by splitting and stripping any additional leading slash
+        # don't allow any kind of `:/` shenanigans by splitting on ':' only
+        # and replacing `:/*` with exactly `://`
+        if ":" in url:
+            scheme, _, rest = url.partition(":")
+            url = f"{scheme}://{rest.lstrip('/')}"
         parsed = urlparse(url)
-        if parsed.netloc or not (parsed.path + "/").startswith(self.base_url):
+        # full url may be `//host/path` (empty scheme == same scheme as request)
+        # or `https://host/path`
+        # or even `https:///host/path` (invalid, but accepted and ambiguously interpreted)
+        if (parsed.scheme or parsed.netloc) or not (parsed.path + "/").startswith(self.base_url):
             # require that next_url be absolute path within our path
             allow = False
             # OR pass our cross-origin check
-            if parsed.netloc:
+            if parsed.scheme or parsed.netloc:
                 # if full URL, run our cross-origin check:
                 origin = f"{parsed.scheme}://{parsed.netloc}"
                 origin = origin.lower()
                 if self.allow_origin:
                     allow = self.allow_origin == origin
diff --git a/tests/auth/test_login.py b/tests/auth/test_login.py
index 2f6c377caf..d91ec5ad06 100644
--- a/tests/auth/test_login.py
+++ b/tests/auth/test_login.py
@@ -90,10 +90,11 @@ def login(jp_serverapp, http_server_client, jp_base_url, login_headers):
         r"\\tree",
         "//some-host",
         "//host{base_url}tree",
         "https://google.com",
         "/absolute/not/base_url",
+        "https:///a%40b/extra/slash",
     ),
 )
 async def test_next_bad(login, jp_base_url, bad_next):
     bad_next = bad_next.format(base_url=jp_base_url)
     resp = await login(bad_next)

Vulnerable code:         if parsed.netloc or not (parsed.path + "/").startswith(self.base_url):
            if parsed.netloc:
Fixed code:         # urllib and browsers interpret extra '/' in the scheme separator (`scheme:///host/path`)
        # differently.
        # urllib gives scheme=scheme, netloc='', path='/host/path', while
        # browsers get scheme=scheme, netloc='host', path='/path'
        # so make sure ':///*' collapses to '://' by splitting and stripping any additional leading slash
        # don't allow any kind of `:/` shenanigans by splitting on ':' only
        # and replacing `:/*` with exactly `://`
        if ":" in url:
            scheme, _, rest = url.partition(":")
            url = f"{scheme}://{rest.lstrip('/')}"
        # full url may be `//host/path` (empty scheme == same scheme as request)
        # or `https://host/path`
        # or even `https:///host/path` (invalid, but accepted and ambiguously interpreted)
        if (parsed.scheme or parsed.netloc) or not (parsed.path + "/").startswith(self.base_url):
            if parsed.scheme or parsed.netloc:
        "https:///a%40b/extra/slash",