You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2026/10/06 19:45:24 UTC
(superset) 18/23: reject a next containing stripped control characters
This is an automated email from the ASF dual-hosted git repository.
madhushreeag pushed a commit to branch madhu-one-time-login-tokens
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 2afdbf1ab06a4fadfeec61c82a3a223c2abf2264
Author: madhushree agarwal <ma...@apple.com>
AuthorDate: Tue Oct 6 10:53:04 2026 -0700
reject a next containing stripped control characters
---
superset/security/login_token.py | 27 +++++++++++++++++-----
.../security/login_token_api_tests.py | 8 +++++++
tests/unit_tests/security/login_token_test.py | 11 ++++++---
3 files changed, 37 insertions(+), 9 deletions(-)
diff --git a/superset/security/login_token.py b/superset/security/login_token.py
index 8a8bf0dda65..e83dbb06c84 100644
--- a/superset/security/login_token.py
+++ b/superset/security/login_token.py
@@ -88,8 +88,8 @@ LoginTokenIdentityResolver = Callable[..., LoginTokenUserInfo | None]
# Characters a WHATWG URL parser strips before resolving, plus their
# percent-encoded forms, which some browsers also remove when following a
-# Location header. Normalizing them first stops `/\tx` or `/%09x` smuggling a
-# different target past the checks below.
+# Location header. Their presence is grounds for rejection, not something to
+# normalize away -- see :func:`is_safe_next_path`.
_URL_STRIPPED_CONTROL_CHARS = re.compile(r"[\t\n\r]|%09|%0[ADad]")
@@ -107,18 +107,33 @@ def is_safe_next_path(url: str) -> bool:
Requiring a relative path avoids the question entirely: the parent
application always knows the path it wants, the browser resolves it against
Superset's own origin, and there is no host to compare.
+
+ A value carrying a character a URL parser strips is **rejected outright**
+ rather than stripped and accepted. Accepting it would mean the value this
+ returns ``True`` for is not the value the caller redirects to, and that gap
+ is exploitable in both directions: a ``CR``/``LF`` survives into
+ ``redirect()``, which Werkzeug refuses with a ``ValueError`` -- a 500 raised
+ after the token has already been burned -- while anything else differing
+ from its normalized form is a request to send the browser somewhere other
+ than what was asked for. Rejecting keeps the checked value and the
+ redirected value identical.
"""
if not url or not url.strip():
return False
- normalized = _URL_STRIPPED_CONTROL_CHARS.sub("", url.strip())
+ candidate = url.strip()
+ if _URL_STRIPPED_CONTROL_CHARS.search(candidate):
+ return False
+
# Browsers treat backslashes as forward slashes in special schemes, so
- # `/\evil.com` would resolve as the protocol-relative `//evil.com`.
- normalized = normalized.replace("\\", "/")
+ # `/\evil.com` would resolve as the protocol-relative `//evil.com`. Folded
+ # only to decide, never to rewrite: a value needing the fold is rejected by
+ # the check below rather than redirected to in its folded form.
+ folded = candidate.replace("\\", "/")
# A single leading slash, and nothing that could be read as a host or a
# scheme. `//host`, `https://host` and `mailto:x` are all rejected.
- return normalized.startswith("/") and not normalized.startswith("//")
+ return folded.startswith("/") and not folded.startswith("//")
def is_enabled() -> bool:
diff --git a/tests/integration_tests/security/login_token_api_tests.py b/tests/integration_tests/security/login_token_api_tests.py
index a7e3ce4f671..8cfe5981857 100644
--- a/tests/integration_tests/security/login_token_api_tests.py
+++ b/tests/integration_tests/security/login_token_api_tests.py
@@ -425,6 +425,14 @@ class TestLoginTokenApi(SupersetTestCase):
"//evil.example.com/",
"/\\evil.example.com/",
"javascript:alert(1)",
+ # Encoded CR/LF. These must reject rather than normalize: a
+ # surviving newline reaches `redirect()`, which Werkzeug refuses
+ # with a ValueError, and @safe turns that into a 500 -- after the
+ # token has been burned and the session cookie set, so the parent
+ # application cannot retry.
+ "%2Fdashboard%2F1%2F%0D%0AX-Injected:+yes",
+ "%2F%0Adashboard%2F",
+ "%2F%09dashboard",
):
with self.subTest(next=requested_next):
token = self._mint()
diff --git a/tests/unit_tests/security/login_token_test.py b/tests/unit_tests/security/login_token_test.py
index 707e63ce22b..300a192a37c 100644
--- a/tests/unit_tests/security/login_token_test.py
+++ b/tests/unit_tests/security/login_token_test.py
@@ -347,9 +347,6 @@ def test_consume_burn_survives_a_later_rollback(
"/sqllab/",
"/dashboard/list/?pageIndex=0",
"/dashboard/1/#anchor",
- # Stripped control characters leave a plain relative path, which is
- # what a browser resolves this to.
- "/\tdashboard",
],
)
def test_is_safe_next_path_accepts_relative_paths(path: str) -> None:
@@ -371,8 +368,16 @@ def test_is_safe_next_path_accepts_relative_paths(path: str) -> None:
"/\\evil.example.com/",
"\\\\evil.example.com/",
# Control characters a URL parser strips, raw and percent-encoded.
+ # Rejected outright rather than stripped: the value checked must be the
+ # value redirected to. A surviving CR/LF reaches `redirect()`, which
+ # Werkzeug refuses with a ValueError -- a 500 after the token is burned.
"/\t/evil.example.com",
"/%09/evil.example.com",
+ "/\tdashboard",
+ "/dashboard/1/\r\nX-Injected: yes",
+ "/\ndashboard/",
+ "/%0Adashboard/",
+ "/%0D%0Adashboard/",
# Schemes that never start with a slash.
"javascript:alert(1)",
"data:text/html,<script>alert(1)</script>",