Skip to content

Commit e39bb04

Browse files
authored
A (hopeful) fix for possible open-redirect. (#489)
While this is only an issue if the application sets the Werkzeug response variable: autocorrect_location_header = False - it none the less poses a small security concern. pyupgrade and black changed again .. sigh... pin read the docs sphinx versions. Closes: #486
1 parent 25161c0 commit e39bb04

File tree

10 files changed

+125
-13
lines changed

10 files changed

+125
-13
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ default_language_version:
44
python: python3.8
55
repos:
66
- repo: https://github.com/pre-commit/pre-commit-hooks
7-
rev: v3.4.0
7+
rev: v4.0.1
88
hooks:
99
- id: trailing-whitespace
1010
- id: end-of-file-fixer
@@ -14,16 +14,16 @@ repos:
1414
- id: check-merge-conflict
1515
- id: fix-byte-order-marker
1616
- repo: https://github.com/asottile/pyupgrade
17-
rev: v2.14.0
17+
rev: v2.19.0
1818
hooks:
1919
- id: pyupgrade
2020
args: [--py36-plus]
2121
- repo: https://github.com/psf/black
22-
rev: 21.5b0
22+
rev: 21.5b1
2323
hooks:
2424
- id: black
2525
- repo: https://gitlab.com/pycqa/flake8
26-
rev: 3.9.1
26+
rev: 3.9.2
2727
hooks:
2828
- id: flake8
2929
additional_dependencies:

docs/configuration.rst

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,59 @@ These configuration keys are used globally across all features.
221221
.. py:data:: SECURITY_REDIRECT_ALLOW_SUBDOMAINS
222222
223223
If ``True`` then subdomains (and the root domain) of the top-level host set
224-
by Flask's ``SERVER_NAME`` configuration will be allowed as post-login redirect targets.
224+
by Flask's ``SERVER_NAME`` configuration will be allowed as post-view redirect targets.
225225
This is beneficial if you wish to place your authentiation on one subdomain and
226226
authenticated content on another, for example ``auth.domain.tld`` and ``app.domain.tld``.
227227

228228
Default: ``False``.
229229

230230
.. versionadded:: 4.0.0
231231

232+
.. py:data:: SECURITY_REDIRECT_VALIDATE_MODE
233+
234+
These 2 configuration options attempt to solve a open-redirect vulnerability
235+
that can be exploited if an application sets the Werkzeug response option
236+
``autocorrect_location_header = False`` (it is ``True`` by default).
237+
For numerous views (e.g. /login) Flask-Security allows callers to specify
238+
a redirect upon successful completion (via the ?next parameter). So it is
239+
possible for a user to be tricked into logging in to a legitimate site
240+
and then redirected to a malicious site. Flask-Security attempts to
241+
verify that redirects are always relative to overcome this security concern.
242+
FS uses the standard Python library urlsplit() to parse the URL and verify
243+
that the ``netloc`` hasn't been altered.
244+
However, many browsers actually accept URLs that should be considered
245+
relative and perform various stripping and conversion that can cause them
246+
to be interpreted as absolute. A trivial example of this is:
247+
248+
.. line-block::
249+
/login?next=%20///github.com
250+
251+
This will pass the urlsplit() test that it is relative - but many browsers
252+
will simply strip off the space and interpret it as an absolute URL!
253+
With the default configuration of Werkzeug this isn't an issue since it by
254+
default modifies the Location Header to with the request ``netloc``. However
255+
if the application sets the Werkzeug response option
256+
``autocorrect_location_header = False`` this will allow a redirect outside of
257+
the application.
258+
259+
Setting this to ``"regex"`` will force the URL to be matched using the
260+
pattern specified below. If a match occurs the URL is considered 'absolute'
261+
and will be rejected.
262+
263+
Default: ``None``
264+
265+
.. versionadded:: 4.0.2
266+
267+
.. py:data:: SECURITY_REDIRECT_VALIDATE_RE
268+
269+
This regex handles known patterns that can be exploited. Basically,
270+
don't allow control characters or white-space followed by slashes (or
271+
back slashes).
272+
273+
Default: ``r"^/{4,}|\\{3,}|[\s\000-\037][/\\]{2,}"``
274+
275+
.. versionadded:: 4.0.2
276+
232277
.. py:data:: SECURITY_CSRF_PROTECT_MECHANISMS
233278
234279
Authentication mechanisms that require CSRF protection.
@@ -606,13 +651,17 @@ Login/Logout
606651

607652
Default: ``"/verify"``
608653

654+
.. versionadded:: 3.4.0
655+
609656

610657
.. py:data:: SECURITY_VERIFY_TEMPLATE
611658
612659
Specifies the path to the template for the verify password page.
613660

614661
Default: ``"security/verify.html"``.
615662

663+
.. versionadded:: 3.4.0
664+
616665
.. py:data:: SECURITY_POST_VERIFY_URL
617666
618667
Specifies the default view to redirect to after a user successfully re-authenticates either via
@@ -622,6 +671,8 @@ Login/Logout
622671

623672
Default: ``None``.
624673

674+
.. versionadded:: 3.4.0
675+
625676
Registerable
626677
------------
627678
.. py:data:: SECURITY_REGISTERABLE

examples/fsqlalchemy1/tests/test_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ def create_fake_user(user_cls, email="[email protected]", userid=1, roles=None):
4141

4242
def create_fake_role(role_cls, name, permissions=None):
4343
if permissions:
44-
permissions = ",".join([p.strip() for p in permissions.split(",")])
44+
permissions = ",".join(p.strip() for p in permissions.split(","))
4545
return role_cls(name=name, permissions=permissions)

flask_security/core.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
:copyright: (c) 2012 by Matt Wright.
88
:copyright: (c) 2017 by CERN.
99
:copyright: (c) 2017 by ETH Zurich, Swiss Data Science Center.
10-
:copyright: (c) 2019-2020 by J. Christopher Wagner (jwag).
10+
:copyright: (c) 2019-2021 by J. Christopher Wagner (jwag).
1111
:license: MIT, see LICENSE for more details.
1212
"""
1313

1414
from datetime import datetime, timedelta
15+
import re
1516
import warnings
1617

1718
import pkg_resources
@@ -156,6 +157,8 @@
156157
"REDIRECT_HOST": None,
157158
"REDIRECT_BEHAVIOR": None,
158159
"REDIRECT_ALLOW_SUBDOMAINS": False,
160+
"REDIRECT_VALIDATE_MODE": None,
161+
"REDIRECT_VALIDATE_RE": r"^/{4,}|\\{3,}|[\s\000-\037][/\\]{2,}",
159162
"FORGOT_PASSWORD_TEMPLATE": "security/forgot_password.html",
160163
"LOGIN_USER_TEMPLATE": "security/login_user.html",
161164
"REGISTER_USER_TEMPLATE": "security/register_user.html",
@@ -593,6 +596,8 @@ def _get_state(app, datastore, anonymous_user=None, **kwargs):
593596
_unauthz_handler=default_unauthz_handler,
594597
)
595598
)
599+
if "redirect_validate_re" in kwargs:
600+
kwargs["_redirect_validate_re"] = re.compile(kwargs["redirect_validate_re"])
596601

597602
if "login_manager" not in kwargs:
598603
kwargs["login_manager"] = _get_login_manager(app, anonymous_user)

flask_security/datastore.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ def create_role(self, **kwargs):
319319
perms = ",".join(perms)
320320
elif isinstance(perms, str):
321321
# squash spaces.
322-
perms = ",".join([p.strip() for p in perms.split(",")])
322+
perms = ",".join(p.strip() for p in perms.split(","))
323323
kwargs["permissions"] = perms
324324

325325
role = self.role_model(**kwargs)

flask_security/decorators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ def create_post():
504504
def wrapper(fn):
505505
@wraps(fn)
506506
def decorated_view(*args, **kwargs):
507-
perm = Permission(*[RoleNeed(role) for role in roles])
507+
perm = Permission(*(RoleNeed(role) for role in roles))
508508
if perm.can():
509509
return fn(*args, **kwargs)
510510
if _security._unauthorized_callback:
@@ -578,7 +578,7 @@ def create_post():
578578
def wrapper(fn):
579579
@wraps(fn)
580580
def decorated_view(*args, **kwargs):
581-
perm = Permission(*[FsPermNeed(fsperm) for fsperm in fsperms])
581+
perm = Permission(*(FsPermNeed(fsperm) for fsperm in fsperms))
582582
if perm.can():
583583
return fn(*args, **kwargs)
584584
if _security._unauthorized_callback:

flask_security/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,34 @@ def url_for_security(endpoint, **values):
498498

499499

500500
def validate_redirect_url(url):
501+
"""Validate that the URL for redirect is relative.
502+
Allowing an absolute redirect is a security issue - a so-called open-redirect.
503+
Note that by default Werkzeug will always take this URL and make it relative
504+
when setting the Location header - but that behavior can be overridden.
505+
506+
The complexity here is that urlsplit() does pretty well, but browsers even today
507+
May 2021 are very lenient in what they accept as URLs - for example:
508+
next=\\\\github.com
509+
next=%5C%5C%5Cgithub.com
510+
next=/////github.com
511+
next=%20\\\\github.com
512+
next=%20///github.com
513+
next=%20//github.com
514+
next=%19////github.com - i.e. browser will strip control chars
515+
next=%E2%80%8A///github.com - doesn't redirect! That is a unicode thin space.
516+
517+
All will result in a null netloc and scheme from urlsplit - however many browsers
518+
will gladly strip off uninteresting characters and convert backslashes to forward
519+
slashes - and the cases above will actually cause a redirect to github.com
520+
Sigh.
521+
522+
Some articles claim that a relative url has to start with a '/' - but that isn't
523+
strictly true. From: https://datatracker.ietf.org/doc/html/rfc3986#section-5
524+
a relative path can start with a "//", "/", a non-colon, or be empty. So it seems
525+
that all the above URLs are valid.
526+
By the time we get the URL, it has been unencoded - so we can't really determine
527+
if it is 'valid' since it appears that '/'s can appear in the URL if escaped.
528+
"""
501529
if url is None or url.strip() == "":
502530
return False
503531
url_next = urlsplit(url)
@@ -515,6 +543,9 @@ def validate_redirect_url(url):
515543
return True
516544
else:
517545
return False
546+
if config_value("REDIRECT_VALIDATE_MODE") == "regex":
547+
matcher = _security._redirect_validate_re.match(url)
548+
return matcher is None
518549
return True
519550

520551

requirements/docs.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
Pallets-Sphinx-Themes
2-
Sphinx
3-
sphinx-issues
1+
Pallets-Sphinx-Themes~=2.0
2+
Sphinx~=4.0
3+
sphinx-issues~=1.2

tests/test_misc.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
hash_data,
5959
send_mail,
6060
uia_phone_mapper,
61+
validate_redirect_url,
6162
verify_hash,
6263
)
6364

@@ -1034,3 +1035,19 @@ def test_post_security_with_application_root_and_views(app, sqlalchemy_datastore
10341035
response = client.get("/logout")
10351036
assert response.status_code == 302
10361037
assert response.headers["Location"] == "http://localhost/post_logout"
1038+
1039+
1040+
@pytest.mark.settings(redirect_validate_mode="regex")
1041+
def test_validate_redirect(app, sqlalchemy_datastore):
1042+
"""
1043+
Test various possible URLs that urlsplit() shows as relative but
1044+
many browsers will interpret as absolute - and this have a
1045+
open-redirect vulnerability. Note this vulnerability only
1046+
is viable if the application sets autocorrect_location_header = False
1047+
"""
1048+
init_app_with_options(app, sqlalchemy_datastore)
1049+
with app.test_request_context("http://localhost:5001/login"):
1050+
assert not validate_redirect_url("\\\\\\github.com")
1051+
assert not validate_redirect_url(" //github.com")
1052+
assert not validate_redirect_url("\t//github.com")
1053+
assert not validate_redirect_url("//github.com") # this is normal urlsplit

tests/view_scaffold.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@ def create_user():
159159
add_user(user_datastore, test_acct, "password", ["admin"])
160160
print("Created User: {} with password {}".format(test_acct, "password"))
161161

162+
@app.after_request
163+
def allow_absolute_redirect(r):
164+
# This is JUST to test odd possible redirects that look relative but are
165+
# interpreted by browsers as absolute.
166+
# DON'T SET THIS IN YOUR APPLICATION!
167+
r.autocorrect_location_header = False
168+
return r
169+
162170
@user_registered.connect_via(app)
163171
def on_user_registered(myapp, user, confirm_token, **extra):
164172
flash(f"To confirm {user.email} - go to /confirm/{confirm_token}")

0 commit comments

Comments
 (0)