feat(login): Enhance next URL validation to reject backslash open redirects
Co-authored-by: Agent Hero <agent.hero@neurocis.ai>
neurocis committed
Jun 2, 2026 at 21:55 UTC
f8730c0608cf6f66f620a8a574442d06373cff0a
2 files changed
+30
-2
helpers/api.py
+10
-2
@@ -1,7 +1,7 @@
1
from abc import abstractmethod
2
import json
3
import threading
4
-from urllib.parse import urlsplit
4
+from urllib.parse import urlsplit, unquote
5
from functools import wraps
6
from pathlib import Path
7
from typing import Union, Dict, Any
@@ -109,8 +109,16 @@ def is_safe_next_url(value: str | None) -> bool:
109
return False
110
if "\r" in value or "\n" in value:
111
return False
112
+ # Reject raw backslashes (browsers normalize `/\host` to `//host` -> external).
113
+ if "\\" in value:
114
+ return False
115
+
116
+ # Decode percent-escapes so encoded backslashes (e.g. `%5C`) are caught too.
117
+ decoded = unquote(value)
118
+ if "\\" in decoded:
119
+ return False
120
113
- parsed = urlsplit(value)
121
+ parsed = urlsplit(decoded)
122
if parsed.scheme or parsed.netloc:
123
return False
124
tests/test_http_auth_csrf.py
+20
@@ -171,3 +171,23 @@ def test_auth_redirect_includes_original_path_and_query(monkeypatch) -> None:
171
location = response.headers["Location"]
172
assert location.startswith("/login?next=")
173
assert "%2Fplugins%2Fa0_voqualizer%2Fwebui%2Fvoqualizer.html%3Fcontext%3DrlO1iMV7" in location
174
+
175
+
176
+def test_is_safe_next_url_rejects_backslash_open_redirects() -> None:
177
+ from helpers.api import is_safe_next_url
178
+
179
+ # Raw backslash forms
180
+ assert is_safe_next_url("/\\evil.example") is False
181
+ assert is_safe_next_url("\\/evil.example") is False
182
+ assert is_safe_next_url("/path\\evil") is False
183
+
184
+ # Percent-encoded backslash forms
185
+ assert is_safe_next_url("/%5Cevil.example") is False
186
+ assert is_safe_next_url("%5C/evil.example") is False
187
+ assert is_safe_next_url("/%5cevil.example") is False # lowercase hex
188
+
189
+ # Mixed / double-encoded edge
190
+ assert is_safe_next_url("/path/%5Cevil") is False
191
+
192
+ # Sanity: a legitimate relative path still passes
193
+ assert is_safe_next_url("/plugins/a0_voqualizer/webui/voqualizer.html") is True