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