CVE-2021-23393: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: This affects the package Flask-Unchained before 0.9.0. When using the the _validate_redirect_url function, it is possible to bypass URL validation and redirect a user to an arbitrary URL by providing multiple back slashes such as \\\evil.com/path. This vulnerability is only exploitable if an alternative WSGI server other than Werkzeug is used, or the default behaviour of Werkzeug is modified using 'autocorrect_location_header=False.
CWE description: A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
CVSS v3 base score: 5.4
Published: 2021-06-11T00:15Z
Repository: https://github.com/briancappello/flask-unchained
Language: Python
Files changed: flask_unchained/bundles/controller/utils.py, requirements.txt, tests/bundles/controller/test_utils.py
Commit date: 2021-05-27 14:39:02 -0400
Commit message: add tests for validate_redirect_url

Diff: diff --git a/flask_unchained/bundles/controller/utils.py b/flask_unchained/bundles/controller/utils.py
index c9946d9..cf6a672 100644
--- a/flask_unchained/bundles/controller/utils.py
+++ b/flask_unchained/bundles/controller/utils.py
@@ -183,11 +183,11 @@ def method_name_to_url(method_name) -> str:
     return '/' + kebab_case(method_name).strip('-')
 
 
 def encode_non_url_reserved_characters(url):
     # safe url reserved characters: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
-    return urlquote(url, safe=":/?#[]@!$&'()*+,;=")
+    return urlquote(url, safe=":/?#[]@!$&'()*+,;=<>")
 
 
 # modified from flask_security.utils.get_post_action_redirect
 def redirect(where: Optional[str] = None,
              default: Optional[str] = None,
diff --git a/requirements.txt b/requirements.txt
index e9c16cf..9d779e3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,10 +16,11 @@ flask-login==0.5.0
 flask-marshmallow==0.14.0
 flask_migrate==2.7.0
 flask-oauthlib==0.9.5
 flask-principal==0.4.0
 flask-session==0.3.2
+flask-sqlalchemy==2.5.1
 flask-sqlalchemy-unchained==0.7.4
 flask_wtf==0.14.3
 graphene==2.1.8
 graphene-sqlalchemy==2.3.0
 graphql-core>=2.3.2,<3
diff --git a/tests/bundles/controller/test_utils.py b/tests/bundles/controller/test_utils.py
index ca11469..2f74e61 100644
--- a/tests/bundles/controller/test_utils.py
+++ b/tests/bundles/controller/test_utils.py
@@ -188,26 +188,42 @@ class TestMethodNameToUrl:
 
 class TestValidateRedirectUrl:
     def test_it_fails_on_garbage(self):
         assert _validate_redirect_url(None) is False
         assert _validate_redirect_url(' ') is False
+        assert _validate_redirect_url('///evil.com') is False
+        assert _validate_redirect_url('\\\\\\evil.com') is False
+        assert _validate_redirect_url('\x00evil.com') is False
 
     def test_it_fails_with_invalid_netloc(self, app, monkeypatch):
         with app.test_request_context():
             monkeypatch.setattr('flask.request.host_url', 'http://example.com')
             assert _validate_redirect_url('http://fail.com') is False
             monkeypatch.undo()
 
-    @pytest.mark.options(EXTERNAL_SERVER_NAME='works.com')
+    def test_it_requires_same_scheme(self, app, monkeypatch):
+        with app.test_request_context():
+            monkeypatch.setattr('flask.request.host_url', 'https://example.com')
+            assert _validate_redirect_url('http://example.com/foo') is False
+            monkeypatch.undo()
+
+    @pytest.mark.options(EXTERNAL_SERVER_NAME='http://works.com')
     def test_it_works_with_external_server_name(self, app, monkeypatch):
         with app.test_request_context():
             monkeypatch.setattr('flask.request.host_url', 'http://example.com')
             assert _validate_redirect_url('http://works.com') is True
             monkeypatch.undo()
 
+    @pytest.mark.options(EXTERNAL_SERVER_NAME='https://works.com')
+    def test_it_requires_same_external_server_name_scheme(self, app, monkeypatch):
+        with app.test_request_context():
+            monkeypatch.setattr('flask.request.host_url', 'http://example.com')
+            assert _validate_redirect_url('http://works.com') is False
+            monkeypatch.undo()
+
     def test_it_works_with_explicit_external_host(self, app, monkeypatch):
         with app.test_request_context():
             monkeypatch.setattr('flask.request.host_url', 'http://example.com')
             result = _validate_redirect_url('http://works.com',
-                                            _external_host='works.com')
+                                            _external_host='http://works.com')
             assert result is True
             monkeypatch.undo()

Vulnerable code:     return urlquote(url, safe=":/?#[]@!$&'()*+,;=")
    @pytest.mark.options(EXTERNAL_SERVER_NAME='works.com')
                                            _external_host='works.com')
Fixed code:     return urlquote(url, safe=":/?#[]@!$&'()*+,;=<>")
flask-sqlalchemy==2.5.1
        assert _validate_redirect_url('///evil.com') is False
        assert _validate_redirect_url('\\\\\\evil.com') is False
        assert _validate_redirect_url('\x00evil.com') is False
    def test_it_requires_same_scheme(self, app, monkeypatch):
        with app.test_request_context():
            monkeypatch.setattr('flask.request.host_url', 'https://example.com')
            assert _validate_redirect_url('http://example.com/foo') is False
            monkeypatch.undo()

    @pytest.mark.options(EXTERNAL_SERVER_NAME='http://works.com')
    @pytest.mark.options(EXTERNAL_SERVER_NAME='https://works.com')
    def test_it_requires_same_external_server_name_scheme(self, app, monkeypatch):
        with app.test_request_context():
            monkeypatch.setattr('flask.request.host_url', 'http://example.com')
            assert _validate_redirect_url('http://works.com') is False
            monkeypatch.undo()

                                            _external_host='http://works.com')