Skip to content

Commit 18808e1

Browse files
authored
Fix possible open-redirect with ALLOW_SUBDOMAIN option. (#1223)
As with the prior path-based open-redirect - the solution is to quote the hostname portion of the netloc. Note that because we can get a 'next' location either via a query param (where Werkzeug unescapes it) OR as part of the form where it ISN'T escaped - it is possible the resultant redirect URL will be double escaped. closes #1222
1 parent edcb5a5 commit 18808e1

4 files changed

Lines changed: 96 additions & 15 deletions

File tree

CHANGES.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ Flask-Security Changelog
33

44
Here you can see the full list of changes between each Flask-Security release.
55

6+
Version 5.8.1
7+
-------------
8+
9+
Released TBD
10+
11+
Fixes
12+
+++++
13+
- (:issue:`1222`) Possible open-redirect with ALLOW_SUBDOMAIN option.
14+
615
Version 5.8.0
716
-------------
817

flask_security/utils.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -763,14 +763,29 @@ def get_post_action_redirect(
763763
strictly true. From: https://datatracker.ietf.org/doc/html/rfc3986#section-5
764764
a relative path can start with a "//", "/", a non-colon, or be empty. So it seems
765765
that all the above URLs are valid.
766-
By the time we get the URL, it has been unencoded - so we can't really determine
766+
By the time we get the URL it may or may not have been unencoded - if part of a
767+
query string, then it has been, but if set in the form, not.
768+
This means we can't really determine
767769
if it is 'valid' since it appears that '/'s can appear in the URL if escaped.
768770
769771
The solution is to simply 'quote' the path.
772+
773+
netloc has a same issue: https://amazon.com\\.lp.com will cause many
774+
browsers to redirect to amazon.com
770775
"""
771776
rurl = propagate_next(find_redirect(config_key), next_loc)
772-
scheme, netloc, path, query, fragment = urlsplit(rurl)
773-
safe_url = urlunsplit((scheme, netloc, quote(path), query, fragment))
777+
778+
u = urlsplit(rurl)
779+
userinfo = ""
780+
if u.username or u.password:
781+
userinfo = f"{u.username}:{u.password}@"
782+
hostname = quote(u.hostname) if u.hostname else ""
783+
if u.port:
784+
netloc = f"{userinfo}{hostname}:{u.port}"
785+
else:
786+
netloc = f"{userinfo}{hostname}"
787+
788+
safe_url = urlunsplit((u.scheme, netloc, quote(u.path), u.query, u.fragment))
774789
return safe_url
775790

776791

tests/test_misc.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
uia_email_mapper,
7272
uia_phone_mapper,
7373
verify_hash,
74+
get_post_action_redirect,
7475
)
7576
from flask_security.core import _get_serializer
7677

@@ -1367,18 +1368,73 @@ def test_open_redirect(app, client, get_message):
13671368
("//github.com", ""),
13681369
("\t//github.com", "%09//github.com"),
13691370
]
1370-
for i, o in test_urls:
1371-
data = dict(email="matt@lp.com", password="password", next=i)
1372-
response = client.post("/login", data=data, follow_redirects=False)
1373-
if response.status_code in [302, 303]:
1374-
# this means it passed form validation but should have been quoted
1375-
assert check_location(app, response.location, o)
1376-
elif response.status_code == 200:
1377-
# should have failed form validation
1378-
assert get_message("INVALID_REDIRECT") in response.data
1379-
else:
1380-
raise AssertionError("Bad response code")
1381-
logout(client)
1371+
for nextloc in ["form", "query"]:
1372+
for i, o in test_urls:
1373+
if nextloc == "form":
1374+
data = dict(email="matt@lp.com", password="password", next=i)
1375+
response = client.post("/login", data=data, follow_redirects=False)
1376+
else:
1377+
data = dict(email="matt@lp.com", password="password")
1378+
response = client.post(
1379+
f"/login?next={i}", data=data, follow_redirects=False
1380+
)
1381+
if response.status_code in [302, 303]:
1382+
# this means it passed form validation but should have been quoted
1383+
assert check_location(app, response.location, o)
1384+
elif response.status_code == 200:
1385+
# should have failed form validation
1386+
assert get_message("INVALID_REDIRECT") in response.data
1387+
else:
1388+
raise AssertionError("Bad response code")
1389+
logout(client)
1390+
1391+
1392+
@pytest.mark.settings(redirect_allow_subdomains=True)
1393+
def test_open_redirect_subdomain(app, client, get_message):
1394+
# As above - netloc is also susceptible to crazy escaping
1395+
# The first URL below, if cut-n-pasted into Chrome will end up at amazon.com
1396+
# The difference in response between in-form and query string is that
1397+
# the query string is unescaped by Werkzeug but the form value isn't.
1398+
app.config["SERVER_NAME"] = "lp.com"
1399+
test_urls = [
1400+
("https://amazon.com\\.lp.com", "https://amazon.com%5C.lp.com", None),
1401+
(
1402+
"https://amazon.com%5C.lp.com",
1403+
"https://amazon.com%255C.lp.com",
1404+
"https://amazon.com%5C.lp.com",
1405+
),
1406+
(
1407+
"https://jwag:mypass@amazon.com\\.lp.com",
1408+
"https://jwag:mypass@amazon.com%5C.lp.com",
1409+
None,
1410+
),
1411+
]
1412+
for nextloc in ["form", "query"]:
1413+
for i, o, oq in test_urls:
1414+
if nextloc == "form":
1415+
data = dict(email="matt@lp.com", password="password", next=i)
1416+
response = client.post("/login", data=data, follow_redirects=False)
1417+
else:
1418+
data = dict(email="matt@lp.com", password="password")
1419+
response = client.post(
1420+
f"/login?next={i}", data=data, follow_redirects=False
1421+
)
1422+
if response.status_code in [302, 303]:
1423+
# this means it passed form validation but should have been quoted
1424+
assert check_location(
1425+
app, response.location, oq if oq and (nextloc == "query") else o
1426+
)
1427+
logout(client)
1428+
1429+
1430+
def test_get_post_action_redirect(app, client):
1431+
# test parts of get_post_action_redirect that are hard to get to via the client
1432+
# e.g. port
1433+
with app.test_request_context(base_url="https://lp.com:8080/"):
1434+
r = get_post_action_redirect(
1435+
"SECURITY_POST_LOGIN_VIEW", dict(next="https://lp.com:8080/myredirect")
1436+
)
1437+
assert r == "https://lp.com:8080/myredirect"
13821438

13831439

13841440
def test_kwargs():

tests/view_scaffold.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ class WebAuthn(Model, sqla.FsWebAuthnMixin):
161161
def create_app() -> Flask:
162162
# Use real templates - not test templates...
163163
app = Flask("view_scaffold", template_folder="../")
164+
app.config["SERVER_NAME"] = "localhost"
164165
app.config["DEBUG"] = True
165166
# SECRET_KEY generated using: secrets.token_urlsafe()
166167
app.config["SECRET_KEY"] = "pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw"

0 commit comments

Comments
 (0)