Repository: corydolphin/flask-cors

Issue:
CVE-2024-6844
Hi, I hope you are aware of [CVE-2024-6844](https://www.cve.org/CVERecord?id=CVE-2024-6844).  I am trying to justify using work-time to contribute a fix through our Open Source Office, but I didn't see a clear issue so I wanted to make you aware of the issue.

## Claimed Exploit

Target domain for CORs could be spoofed by registering a similar domain with spaces where + is supposed to be (or vice versa).

## How to reproduce

Run one service on `http://spoof.local:8080/service+path` and another local server on `http://spoof.local:8080/service path`.  Configure CORS to allow one but not the other.  Manipulate requested domain to get authorization on incorrect domain.

## How it affects me

My team uses flask-cors indirectly because we need to run `moto[server]` to mock AWS S3.  We register a test bucket and file `s3://test-bucket/sample.csv`.  Because `moto` is running a Flask service on an ephemeral port, we can then start spark within our Python process and have it connect to that mocked S3, allowing us to run our full Glue script as a local test (without containers).

I have a contractor switching to using testcontainers, but think it would be good to fix the underlying issue.

Hints from issue comments:
@GhamHenderson: +1 on this, -> my issue is flagging this exploit along with 2 others also. :) https://github.com/corydolphin/flask-cors/issues/384@LewisCowlesMotive: @GhamHenderson perhaps adding the CVE names to that issue would help it's find-ability?@GhamHenderson: @LewisCowlesMotive Yes, good idea. apologies added CVE names in the comments! :) @adrianosela: Hi @danizen - if you would like to you could give the code in this PR a shot^

Solution patch:
diff --git a/flask_cors/extension.py b/flask_cors/extension.py
index 4591557..434f65e 100644
--- a/flask_cors/extension.py
+++ b/flask_cors/extension.py
@@ -1,5 +1,5 @@
 import logging
-from urllib.parse import unquote_plus
+from urllib.parse import unquote
 
 from flask import request
 
@@ -188,7 +188,7 @@ def make_after_request_function(resources):
         if resp.headers is not None and resp.headers.get(ACL_ORIGIN):
             LOG.debug("CORS have been already evaluated, skipping")
             return resp
-        normalized_path = unquote_plus(request.path)
+        normalized_path = unquote(request.path)
         for res_regex, res_options in resources:
             if try_match_pattern(normalized_path, res_regex, caseSensitive=True):
                 LOG.debug(


Test patch:
diff --git a/tests/extension/test_app_extension.py b/tests/extension/test_app_extension.py
index a4b2606..59432e6 100644
--- a/tests/extension/test_app_extension.py
+++ b/tests/extension/test_app_extension.py
@@ -378,5 +378,61 @@ class AppExtensionBadRegexp(FlaskCorsTestCase):
             self.assertEqual(resp.status_code, 200)
 
 
+class AppExtensionPlusInPath(FlaskCorsTestCase):
+    '''
+        Regression test for CVE-2024-6844:
+        Ensures that we correctly differentiate '+' from ' ' in URL paths.
+    '''
+
+    def setUp(self):
+        self.app = Flask(__name__)
+        CORS(self.app, resources={
+            r'/service\+path': {'origins': ['http://foo.com']},
+            r'/service path': {'origins': ['http://bar.com']},
+        })
+
+        @self.app.route('/service+path')
+        def plus_path():
+            return 'plus'
+
+        @self.app.route('/service path')
+        def space_path():
+            return 'space'
+
+        self.client = self.app.test_client()
+
+    def test_plus_path_origin_allowed(self):
+        '''
+        Ensure that CORS matches + literally and allows the correct origin
+        '''
+        response = self.client.get('/service+path', headers={'Origin': 'http://foo.com'})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.headers.get(ACL_ORIGIN), 'http://foo.com')
+
+    def test_space_path_origin_allowed(self):
+        '''
+        Ensure that CORS treats /service path differently and allows correct origin
+        '''
+        response = self.client.get('/service%20path', headers={'Origin': 'http://bar.com'})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.headers.get(ACL_ORIGIN), 'http://bar.com')
+
+    def test_plus_path_rejects_other_origin(self):
+        '''
+        Origin not allowed for + path should be rejected
+        '''
+        response = self.client.get('/service+path', headers={'Origin': 'http://bar.com'})
+        self.assertEqual(response.status_code, 200)
+        self.assertIsNone(response.headers.get(ACL_ORIGIN))
+
+    def test_space_path_rejects_other_origin(self):
+        '''
+        Origin not allowed for space path should be rejected
+        '''
+        response = self.client.get('/service%20path', headers={'Origin': 'http://foo.com'})
+        self.assertEqual(response.status_code, 200)
+        self.assertIsNone(response.headers.get(ACL_ORIGIN))
+
+
 if __name__ == "__main__":
     unittest.main()