Skip to content

Commit 7e1d3f0

Browse files
committed
[Web] fix redirect issue
1 parent 64868c6 commit 7e1d3f0

2 files changed

Lines changed: 122 additions & 1 deletion

File tree

packages/tentacles/Services/Interfaces/web_interface/security.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,27 @@ def _prepare_response_extra_headers(include_security_headers):
6666
return response_extra_headers
6767

6868

69+
def _decoded_redirect_has_controls_or_del(candidate: str) -> bool:
70+
"""
71+
Percent-decoded redirects must not contain C0 ASCII controls (tabs, NUL, …) or DEL.
72+
%-encoded slashes/tabs/etc. fool naive urljoin/urlparse parity checks vs user agents.
73+
"""
74+
decoded_for_scan = urllib.parse.unquote(
75+
candidate, encoding='utf-8', errors='replace'
76+
)
77+
for character in decoded_for_scan:
78+
code_point = ord(character)
79+
if code_point < 32 or code_point == 127:
80+
return True
81+
return False
82+
83+
6984
def is_safe_redirect_url(target) -> bool:
7085
"""
7186
True if target is usable with flask.redirect without open-redirect risk:
7287
same host and scheme as the current request, or absent/blank (caller supplies
73-
a default destination).
88+
a default destination); rejects percent-encoded or literal C0 controls / DEL
89+
(e.g. ``%09``) which clients may normalize differently than urllib.
7490
"""
7591
if target is None:
7692
return True
@@ -82,6 +98,8 @@ def is_safe_redirect_url(target) -> bool:
8298
# Only allow absolute paths starting with exactly one slash
8399
if not re.match(r'^/[^/]', target):
84100
return False
101+
if _decoded_redirect_has_controls_or_del(stripped):
102+
return False
85103
ref_url = urllib.parse.urlparse(flask.request.host_url)
86104
test_url = urllib.parse.urlparse(urllib.parse.urljoin(flask.request.host_url, stripped))
87105
return test_url.scheme in ('http', 'https') and ref_url.netloc == test_url.netloc
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Drakkar-Software OctoBot-Tentacles
2+
# Copyright (c) Drakkar-Software, All rights reserved.
3+
#
4+
# This library is free software; you can redistribute it and/or
5+
# modify it under the terms of the GNU Lesser General Public
6+
# License as published by the Free Software Foundation; either
7+
# version 3.0 of the License, or (at your option) any later version.
8+
#
9+
# This library is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12+
# Lesser General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Lesser General Public
15+
# License along with this library.
16+
17+
import flask
18+
19+
import tentacles.Services.Interfaces.web_interface.security as security
20+
21+
_FLASK_APP = flask.Flask(__name__)
22+
23+
24+
def _https_request_at_safe_example():
25+
return _FLASK_APP.test_request_context(
26+
"/",
27+
environ_base={
28+
"HTTP_HOST": "safe.example",
29+
"wsgi.url_scheme": "https",
30+
},
31+
)
32+
33+
34+
class TestIsSafeRedirectUrl:
35+
def test_none_is_safe(self):
36+
assert security.is_safe_redirect_url(None) is True
37+
38+
def test_empty_and_whitespace_only_are_safe(self):
39+
assert security.is_safe_redirect_url("") is True
40+
assert security.is_safe_redirect_url(" ") is True
41+
42+
def test_non_string_is_unsafe(self):
43+
assert security.is_safe_redirect_url(42) is False
44+
assert security.is_safe_redirect_url(["/"]) is False
45+
46+
def test_single_slash_path_is_unsafe(self):
47+
with _https_request_at_safe_example():
48+
assert security.is_safe_redirect_url("/") is False
49+
50+
def test_double_leading_slash_is_unsafe(self):
51+
with _https_request_at_safe_example():
52+
assert security.is_safe_redirect_url("//evil.test/") is False
53+
54+
def test_absolute_path_same_host_is_safe(self):
55+
with _https_request_at_safe_example():
56+
assert security.is_safe_redirect_url("/dashboard") is True
57+
assert security.is_safe_redirect_url("/a/b") is True
58+
59+
def test_encoded_tab_octets_are_unsafe(self):
60+
with _https_request_at_safe_example():
61+
assert security.is_safe_redirect_url("/%09/%09/%09/evil.test") is False
62+
63+
def test_full_url_string_is_unsafe(self):
64+
with _https_request_at_safe_example():
65+
assert security.is_safe_redirect_url("https://evil.test/") is False
66+
67+
def test_leading_whitespace_fails_regex_on_raw_target(self):
68+
"""Regex is applied to ``target``, not stripped form (caller may pass unstripped values)."""
69+
with _https_request_at_safe_example():
70+
assert security.is_safe_redirect_url(" /ok") is False
71+
72+
73+
class TestRedirectTargetOr:
74+
def test_returns_default_for_none_non_string_or_blank(self):
75+
default_path = "/default"
76+
assert security.redirect_target_or(None, default_path) == default_path
77+
assert security.redirect_target_or(123, default_path) == default_path
78+
assert security.redirect_target_or("", default_path) == default_path
79+
assert security.redirect_target_or(" \t ", default_path) == default_path
80+
81+
def test_returns_safe_stripped_target(self):
82+
with _https_request_at_safe_example():
83+
assert security.redirect_target_or("/panel", "/home") == "/panel"
84+
assert security.redirect_target_or(" /panel ", "/home") == "/panel"
85+
86+
def test_returns_default_when_redirect_would_be_unsafe(self):
87+
with _https_request_at_safe_example():
88+
fallback = "/home"
89+
assert security.redirect_target_or("//evil", fallback) == fallback
90+
assert security.redirect_target_or("/", fallback) == fallback
91+
92+
93+
class TestIsSafeUrl:
94+
def test_delegates_to_same_rules_as_is_safe_redirect_url(self):
95+
with _https_request_at_safe_example():
96+
for candidate in (
97+
None,
98+
"/safe",
99+
"/",
100+
"//bad",
101+
"https://other/",
102+
):
103+
assert security.is_safe_url(candidate) == security.is_safe_redirect_url(candidate)

0 commit comments

Comments
 (0)