diff --git a/flask_cors/core.py b/flask_cors/core.py index 8df343c..f1cd6a4 100644 --- a/flask_cors/core.py +++ b/flask_cors/core.py @@ -127,7 +127,7 @@ def get_cors_origins(options, request_origin): # If the value of the Origin header is a case-insensitive match # for any of the values in list of origins. # NOTE: Per RFC 1035 and RFC 4343 schemes and hostnames are case insensitive. - elif try_match_any_pattern(request_origin, origins, caseSensitive=False): + elif try_match_any_pattern(request_origin, origins, caseSensitive=False, enforceEndOfString=True): LOG.debug( "The request's Origin header matches. Sending CORS headers.", ) @@ -281,22 +281,25 @@ def re_fix(reg): return r".*" if reg == r"*" else reg -def try_match_any_pattern(inst, patterns, caseSensitive=True): - return any(try_match_pattern(inst, pattern, caseSensitive) for pattern in patterns) +def try_match_any_pattern(inst, patterns, caseSensitive=True, enforceEndOfString=False): + return any(try_match_pattern(inst, pattern, caseSensitive, enforceEndOfString) for pattern in patterns) -def try_match_pattern(value, pattern, caseSensitive=True): +def try_match_pattern(value, pattern, caseSensitive=True, enforceEndOfString=False): """ Safely attempts to match a pattern or string to a value. This function can be used to match request origins, headers, or paths. The value of caseSensitive should be set in accordance to the data being compared e.g. origins and headers are case insensitive whereas paths are case-sensitive + The value of enforceEndOfString should be true when using for origins """ if isinstance(pattern, RegexObject): return re.match(pattern, value) if probably_regex(pattern): flags = 0 if caseSensitive else re.IGNORECASE try: + if enforceEndOfString and not pattern.endswith(("$", "\\Z")): + pattern = pattern + "\\Z" return re.match(pattern, value, flags=flags) except re.error: return False diff --git a/flask_cors/decorator.py b/flask_cors/decorator.py index f8915b6..684f1a8 100644 --- a/flask_cors/decorator.py +++ b/flask_cors/decorator.py @@ -22,6 +22,11 @@ def cross_origin(*args, **kwargs): The origin(s) may be regular expressions, case-sensitive strings, or else an asterisk + .. note: + + When using regexes, ensure that you assert the position at the + end of the string + Default : '*' :type origins: list, string or regex diff --git a/flask_cors/extension.py b/flask_cors/extension.py index 434f65e..0b0f4be 100644 --- a/flask_cors/extension.py +++ b/flask_cors/extension.py @@ -56,10 +56,15 @@ class CORS: .. note:: - origins must include the schema and the port (if not port 80), + origins must include the scheme and the port (if different than default), e.g., `CORS(app, origins=["http://localhost:8000", "https://example.com"])`. + when using regexes, escape needed characters and assert end of string + e.g., + `CORS(app, origins=[r"https://.*\\.example\\.com\\Z"]) + And not `r"https://.*\\.example\\.com"` which would allow `https://a.example.com.attacker.com` + Default : '*' :type origins: list, string or regex diff --git a/tests/decorator/test_origins.py b/tests/decorator/test_origins.py index 5e7e20a..6f9fa28 100644 --- a/tests/decorator/test_origins.py +++ b/tests/decorator/test_origins.py @@ -57,22 +57,22 @@ def test_set(): return 'Welcome!' @self.app.route('/test_subdomain_regex') - @cross_origin(origins=r"http?://\w*\.?example\.com:?\d*/?.*") + @cross_origin(origins=r"https?://\w*\.?example\.com:?\d*\Z") def test_subdomain_regex(): return '' @self.app.route('/test_compiled_subdomain_regex') - @cross_origin(origins=re.compile(r"http?://\w*\.?example\.com:?\d*/?.*")) + @cross_origin(origins=re.compile(r"https?://\w*\.?example\.com:?\d*\Z")) def test_compiled_subdomain_regex(): return '' @self.app.route('/test_regex_list') - @cross_origin(origins=[r".*.example.com", r".*.otherexample.com"]) + @cross_origin(origins=[r"https?://.*\.example\.com\Z", r"https?://.*\.otherexample.com\Z"]) def test_regex_list(): return '' @self.app.route('/test_regex_mixed_list') - @cross_origin(origins=["http://example.com", r".*.otherexample.com"]) + @cross_origin(origins=["http://example.com", r"https?://.*\.otherexample\.com\Z"]) def test_regex_mixed_list(): return '' @@ -146,6 +146,8 @@ def test_set_serialized(self): def test_not_matching_origins(self): for resp in self.iter_responses('/test_list',origin="http://bazz.com"): self.assertFalse(ACL_ORIGIN in resp.headers) + for resp in self.iter_responses('/test_list',origin="http://bar.com.attacker.com"): + self.assertFalse(ACL_ORIGIN in resp.headers) def test_subdomain_regex(self): for sub in letters: @@ -164,10 +166,18 @@ def test_compiled_subdomain_regex(self): def test_regex_list(self): for parent in 'example.com', 'otherexample.com': for sub in letters: - domain = "http://{}.{}.com".format(sub, parent) + domain = "http://{}.{}".format(sub, parent) for resp in self.iter_responses('/test_regex_list', headers={'origin': domain}): self.assertEqual(domain, resp.headers.get(ACL_ORIGIN)) + + def test_regex_list_unallowed_origin(self): + for parent in 'example.com', 'otherexample.com': + for sub in letters: + domain = "http://{}.{}.com.attacker.com".format(sub, parent) + for resp in self.iter_responses('/test_regex_list', + headers={'origin': domain}): + self.assertFalse(ACL_ORIGIN in resp.headers) def test_regex_mixed_list(self): ''' @@ -198,6 +208,8 @@ def test_multiple_protocols(self): logging.getLogger('flask_cors').level = logging.DEBUG resp = self.get('test_multiple_protocols', origin='https://example.com') self.assertEqual('https://example.com', resp.headers.get(ACL_ORIGIN)) + resp = self.options('test_multiple_protocols', origin='https://example.com.attacker.com') + self.assertFalse(ACL_ORIGIN in resp.headers) if __name__ == "__main__": diff --git a/tests/extension/test_app_extension.py b/tests/extension/test_app_extension.py index 59432e6..975aa0a 100644 --- a/tests/extension/test_app_extension.py +++ b/tests/extension/test_app_extension.py @@ -28,19 +28,19 @@ def setUp(self): 'origins': {"http://foo.com", "http://bar.com"} }, r'/test_subdomain_regex': { - 'origins': r"http?://\w*\.?example\.com:?\d*/?.*" + 'origins': r"https?://\w*\.?example\.com:?\d*\Z" }, r'/test_regex_list': { - 'origins': [r".*.example.com", r".*.otherexample.com"] + 'origins': [r"http://.*\.example\.com\Z", r"http://.*\.otherexample.com\Z"] }, r'/test_regex_mixed_list': { - 'origins': ["http://example.com", r".*.otherexample.com"] + 'origins': ["http://example.com", r"http://.*\.otherexample\.com\Z"] }, r'/test_send_wildcard_with_origin' : { 'send_wildcard':True }, re.compile(r'/test_compiled_subdomain_\w*'): { - 'origins': re.compile(r"http://example\d+.com") + 'origins': re.compile(r"http://example\d+.com\Z") }, r'/test_defaults':{} }) @@ -136,7 +136,7 @@ def test_compiled_subdomain_regex(self): def test_regex_list(self): for parent in 'example.com', 'otherexample.com': for sub in letters: - domain = "http://{}.{}.com".format(sub, parent) + domain = "http://{}.{}".format(sub, parent) for resp in self.iter_responses('/test_regex_list', headers={'origin': domain}): self.assertEqual(domain, resp.headers.get(ACL_ORIGIN))