Skip to content

Commit f86570f

Browse files
author
Porcupiney Hairs
committed
WIP: Python: CORS Bypass
This PR adds a query to detect a Cross Origin Resource Sharing(CORS) policy bypass due to an incorrect check. This PR attempts to detect the vulnerability pattern found in CVE-2022-3457 ```python if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']: origin = request.headers.get('Origin', None) if origin and not origin.startswith(request.base): raise cherrypy.HTTPError(403, 'Unexpected Origin header') ``` In this case, a value obtained from a header is compared using `startswith` call. This comparision is easily bypassed resulting in a CORS bypass. Given that similar bugs have been found in other languages as well, I think this PR would be a great addition to the exisitng python query pack. The databases for CVE-2022-3457 can be downloaded from ``` https://filetransfer.io/data-package/i4Mfepls#link https://file.io/V67T4SSgmExF ```
1 parent ffab199 commit f86570f

File tree

7 files changed

+174
-0
lines changed

7 files changed

+174
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import cherrypy
2+
3+
def bad():
4+
request = cherrypy.request
5+
validCors = "domain.com"
6+
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
7+
origin = request.headers.get('Origin', None)
8+
if origin.startswith(validCors):
9+
print("Origin Valid")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!DOCTYPE qhelp PUBLIC "-//Semmle//qhelp//EN" "qhelp.dtd">
2+
<qhelp>
3+
<overview>
4+
<p>Cross-origin resource sharing policy may be bypassed due to incorrect checks like the <code>string.startswith</code> call.</p>
5+
</overview>
6+
<recommendation>
7+
<p>Use a more stronger check to test for CORS policy bypass.</p>
8+
</recommendation>
9+
10+
<example>
11+
<p>Most Python frameworks provide a mechanism for testing origins and performing CORS checks.
12+
For example, consider the code snippet below, <code>origin</code> is compared using a <code>
13+
startswith</code> call against a list of whitelisted origins. This check can be bypassed
14+
easily by origin like <code>domain.com.baddomain.com</code>
15+
</p>
16+
<sample src="CorsBad.py" />
17+
<p>This can be prevented by comparing the origin in a manner shown below.
18+
</p>
19+
<sample src="CorsGood.py" />
20+
21+
</example>
22+
23+
<references>
24+
<li>PortsSwigger : <a href="https://portswigger.net/web-security/cors"></a>Cross-origin resource
25+
sharing (CORS)</li>
26+
<li>Related CVE: <a href="https://github.com/advisories/GHSA-824x-jcxf-hpfg">CVE-2022-3457</a>.</li>
27+
</references>
28+
</qhelp>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @name Cross Origin Resource Sharing(CORS) Policy Bypass
3+
* @description Checking user supplied origin headers using weak comparators like 'string.startswith' may lead to CORS policy bypass.
4+
* @kind path-problem
5+
* @problem.severity warning
6+
* @id py/cors-bypass
7+
* @tags security
8+
* externa/cwe/CWE-346
9+
*/
10+
11+
import python
12+
import semmle.python.ApiGraphs
13+
import semmle.python.dataflow.new.TaintTracking
14+
import semmle.python.Flow
15+
import semmle.python.dataflow.new.RemoteFlowSources
16+
17+
/**
18+
* Returns true if the control flow node may be useful in the current context.
19+
*
20+
* Ideally for more completeness, we should alert on every `startswith` call and every remote flow source which gets partailly checked. But, as this can lead to lots of FPs, we apply heuristics to filter some calls. This predicate provides logic for this filteration.
21+
*/
22+
private predicate maybeInteresting(ControlFlowNode c) {
23+
// Check if the name of the variable which calls the function matches the heuristic.
24+
// This would typically occur at the sink.
25+
// This should deal with cases like
26+
// `origin.startswith("bla")`
27+
heuristics(c.(CallNode).getFunction().(AttrNode).getObject().(NameNode).getId())
28+
or
29+
// Check if the name of the variable passed as an argument to the functions matches the heuristic. This would typically occur at the sink.
30+
// This should deal with cases like
31+
// `bla.startswith(origin)`
32+
heuristics(c.(CallNode).getArg(0).(NameNode).getId())
33+
or
34+
// Check if the value gets written to any interesting variable. This would typically occur at the source.
35+
// This should deal with cases like
36+
// `origin = request.headers.get('My-custom-header')`
37+
exists(Variable v | heuristics(v.getId()) | c.getASuccessor*().getNode() = v.getAStore())
38+
}
39+
40+
private class StringStartswithCall extends ControlFlowNode {
41+
StringStartswithCall() { this.(CallNode).getFunction().(AttrNode).getName() = "startswith" }
42+
}
43+
44+
bindingset[s]
45+
predicate heuristics(string s) { s.matches(["%origin%", "%cors%"]) }
46+
47+
/**
48+
* A member of the `cherrypy.request` class taken as a `RemoteFlowSource`.
49+
*/
50+
class CherryPyRequest extends RemoteFlowSource::Range {
51+
CherryPyRequest() {
52+
this =
53+
API::moduleImport("cherrypy")
54+
.getMember("request")
55+
.getMember([
56+
"charset", "content_type", "filename", "fp", "name", "params", "headers", "length",
57+
])
58+
.asSource()
59+
}
60+
61+
override string getSourceType() { result = "cherrypy.request" }
62+
}
63+
64+
module CorsBypassConfig implements DataFlow::ConfigSig {
65+
predicate isSource(DataFlow::Node node) { node instanceof RemoteFlowSource }
66+
67+
predicate isSink(DataFlow::Node node) {
68+
exists(StringStartswithCall s |
69+
node.asCfgNode() = s.(CallNode).getArg(0) or
70+
node.asCfgNode() = s.(CallNode).getFunction().(AttrNode).getObject()
71+
)
72+
}
73+
74+
predicate isAdditionalFlowStep(DataFlow::Node node1, DataFlow::Node node2) {
75+
exists(API::CallNode c, API::Node n |
76+
n = API::moduleImport("cherrypy").getMember("request").getMember("headers") and
77+
c = n.getMember("get").getACall()
78+
|
79+
c.getReturn().asSource() = node2 and n.asSource() = node1
80+
)
81+
}
82+
}
83+
84+
module CorsFlow = TaintTracking::Global<CorsBypassConfig>;
85+
86+
import CorsFlow::PathGraph
87+
88+
from CorsFlow::PathNode source, CorsFlow::PathNode sink
89+
where
90+
CorsFlow::flowPath(source, sink) and
91+
(
92+
maybeInteresting(source.getNode().asCfgNode())
93+
or
94+
maybeInteresting(sink.getNode().asCfgNode())
95+
)
96+
select sink, source, sink,
97+
"Potentially incorrect string comparison which could lead to a CORS bypass."
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import cherrypy
2+
3+
def good():
4+
request = cherrypy.request
5+
validOrigin = "domain.com"
6+
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
7+
origin = request.headers.get('Origin', None)
8+
if origin == validOrigin:
9+
print("Origin Valid")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import cherrypy
2+
3+
def bad():
4+
request = cherrypy.request
5+
validCors = "domain.com"
6+
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
7+
origin = request.headers.get('Origin', None)
8+
if origin.startswith(validCors):
9+
print("Origin Valid")
10+
11+
def good():
12+
request = cherrypy.request
13+
validOrigin = "domain.com"
14+
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
15+
origin = request.headers.get('Origin', None)
16+
if origin == validOrigin:
17+
print("Origin Valid")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
edges
2+
| Cors.py:7:9:7:14 | ControlFlowNode for origin | Cors.py:8:12:8:17 | ControlFlowNode for origin | provenance | |
3+
| Cors.py:7:18:7:32 | ControlFlowNode for Attribute | Cors.py:7:18:7:52 | ControlFlowNode for Attribute() | provenance | Config |
4+
| Cors.py:7:18:7:32 | ControlFlowNode for Attribute | Cors.py:7:18:7:52 | ControlFlowNode for Attribute() | provenance | dict.get |
5+
| Cors.py:7:18:7:52 | ControlFlowNode for Attribute() | Cors.py:7:9:7:14 | ControlFlowNode for origin | provenance | |
6+
nodes
7+
| Cors.py:7:9:7:14 | ControlFlowNode for origin | semmle.label | ControlFlowNode for origin |
8+
| Cors.py:7:18:7:32 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute |
9+
| Cors.py:7:18:7:52 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() |
10+
| Cors.py:8:12:8:17 | ControlFlowNode for origin | semmle.label | ControlFlowNode for origin |
11+
subpaths
12+
#select
13+
| Cors.py:8:12:8:17 | ControlFlowNode for origin | Cors.py:7:18:7:32 | ControlFlowNode for Attribute | Cors.py:8:12:8:17 | ControlFlowNode for origin | Potentially incorrect string comparison which could lead to a CORS bypass. |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
experimental/Security/CWE-346/CorsBypass.ql

0 commit comments

Comments
 (0)