InfoSec Write-ups

A collection of write-ups from the best hackers in the world on topics ranging from bug bounties…

Follow publication

Finding vulnerabilities on flask-cors library

Stock image just for the preview image

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_plusfunction, 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__

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in InfoSec Write-ups

A collection of write-ups from the best hackers in the world on topics ranging from bug bounties and CTFs to vulnhub machines, hardware challenges and real life encounters. Subscribe to our weekly newsletter for the coolest infosec updates: https://weekly.infosecwriteups.com/

No responses yet

Write a response