Skip to content

Commit 72d2482

Browse files
SONARPY-831 Rule S3752: Allowing both safe and unsafe HTTP methods is security-sensitive (Flask)
1 parent 4b9f9fc commit 72d2482

File tree

4 files changed

+118
-0
lines changed

4 files changed

+118
-0
lines changed

python-checks/src/main/java/org/sonar/python/checks/hotspots/UnsafeHttpMethodsCheck.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Arrays;
2323
import java.util.HashSet;
2424
import java.util.List;
25+
import java.util.Objects;
2526
import java.util.Optional;
2627
import java.util.Set;
2728
import org.sonar.check.Rule;
@@ -33,18 +34,22 @@
3334
import org.sonar.plugins.python.api.tree.CallExpression;
3435
import org.sonar.plugins.python.api.tree.Decorator;
3536
import org.sonar.plugins.python.api.tree.Expression;
37+
import org.sonar.plugins.python.api.tree.FileInput;
3638
import org.sonar.plugins.python.api.tree.FunctionDef;
3739
import org.sonar.plugins.python.api.tree.ListLiteral;
3840
import org.sonar.plugins.python.api.tree.RegularArgument;
3941
import org.sonar.plugins.python.api.tree.StringLiteral;
4042
import org.sonar.python.semantic.FunctionSymbolImpl;
4143
import org.sonar.python.tree.FunctionDefImpl;
44+
import org.sonar.python.tree.TreeUtils;
4245

4346
import static org.sonar.plugins.python.api.tree.Tree.Kind.CALL_EXPR;
47+
import static org.sonar.plugins.python.api.tree.Tree.Kind.FILE_INPUT;
4448
import static org.sonar.plugins.python.api.tree.Tree.Kind.FUNCDEF;
4549
import static org.sonar.plugins.python.api.tree.Tree.Kind.LIST_LITERAL;
4650
import static org.sonar.plugins.python.api.tree.Tree.Kind.REGULAR_ARGUMENT;
4751
import static org.sonar.plugins.python.api.tree.Tree.Kind.STRING_LITERAL;
52+
import static org.sonar.python.tree.TreeUtils.argumentByKeyword;
4853
import static org.sonar.python.tree.TreeUtils.getSymbolFromTree;
4954

5055
@Rule(key = "S3752")
@@ -65,6 +70,8 @@ public void initialize(Context context) {
6570
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
6671
if (isDjangoView(functionDef)) {
6772
checkDjangoView(functionDef, ctx);
73+
} else {
74+
getFlaskViewDecorator(functionDef).ifPresent(callExpression -> checkFlaskView(callExpression, ctx));
6875
}
6976
});
7077
}
@@ -121,4 +128,39 @@ private static boolean isDjangoView(FunctionDef functionDef) {
121128
.filter(FunctionSymbolImpl::isDjangoView)
122129
.isPresent();
123130
}
131+
132+
private static Optional<CallExpression> getFlaskViewDecorator(FunctionDef functionDef) {
133+
return functionDef.decorators().stream()
134+
.map(Decorator::expression)
135+
.filter(expression -> expression.is(CALL_EXPR))
136+
.map(CallExpression.class::cast)
137+
.filter(UnsafeHttpMethodsCheck::isFlaskRouteDecorator)
138+
.findFirst();
139+
}
140+
141+
private static boolean isFlaskRouteDecorator(CallExpression callExpression) {
142+
Symbol calleeSymbol = callExpression.calleeSymbol();
143+
if (calleeSymbol == null) {
144+
return false;
145+
}
146+
return calleeSymbol.name().equals("route");
147+
}
148+
149+
private static void checkFlaskView(CallExpression callExpression, SubscriptionContext ctx) {
150+
RegularArgument methodsArg = argumentByKeyword("methods", callExpression.arguments());
151+
if (methodsArg != null && hasBothUnsafeAndSafeHttpMethods(methodsArg) && isFlaskImported(callExpression)) {
152+
ctx.addIssue(callExpression, MESSAGE);
153+
}
154+
}
155+
156+
private static boolean isFlaskImported(CallExpression callExpression) {
157+
// When SONARPY-834 will be implemented we can have a cleaner implementation
158+
// checking decorator fqn to be equal to flask.blueprints.Blueprint.route
159+
return Optional.ofNullable(TreeUtils.firstAncestorOfKind(callExpression, FILE_INPUT))
160+
.filter(fileInput -> ((FileInput) fileInput).globalVariables().stream()
161+
.map(Symbol::fullyQualifiedName)
162+
.filter(Objects::nonNull)
163+
.anyMatch(fqn -> fqn.contains("flask")))
164+
.isPresent();
165+
}
124166
}

python-checks/src/test/java/org/sonar/python/checks/hotspots/UnsafeHttpMethodsCheckTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,12 @@ public void test_django() {
3434
),
3535
new UnsafeHttpMethodsCheck());
3636
}
37+
38+
@Test
39+
public void test_flask() {
40+
PythonCheckVerifier.verify(Arrays.asList(
41+
"src/test/resources/checks/hotspots/unsafeHttpMethods/flask/views.py",
42+
"src/test/resources/checks/hotspots/unsafeHttpMethods/flask/otherDecorator.py"
43+
), new UnsafeHttpMethodsCheck());
44+
}
3745
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class Methods:
2+
def route(self): ...
3+
def foo(): ...
4+
5+
methods = Methods()
6+
7+
@methods.route('/sensitive1', methods=['GET', 'POST']) # OK
8+
def compliant():
9+
return Response("Method: " + request.method, 200)
10+
11+
@foo('/sensitive1', methods=['GET', 'POST']) # OK
12+
def compliant2():
13+
return Response("Method: " + request.method, 200)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from flask import Blueprint, request
2+
from flask import Response
3+
4+
methods = Blueprint('methods', __name__)
5+
6+
# 127.0.0.1:5000/methods/sensitive1
7+
@methods.route('/sensitive1', methods=['GET', 'POST']) # Noncompliant
8+
def sensitive1():
9+
return Response("Method: " + request.method, 200)
10+
11+
# 127.0.0.1:5000/methods/compliant1
12+
@methods.route('/compliant1') # Compliant
13+
def compliant1():
14+
return Response("Method: " + request.method, 200)
15+
16+
# 127.0.0.1:5000/methods/compliant2
17+
@methods.route('/compliant2', methods=['GET']) # Compliant
18+
def compliant2():
19+
return Response("Method: " + request.method, 200)
20+
21+
@methods.other('/compliant3', methods=['GET', 'POST']) # Compliant
22+
def compliant3():
23+
return Response("Method: " + request.method, 200)
24+
25+
@unknown('/compliant4', methods=['GET', 'POST']) # Compliant
26+
def compliant4():
27+
return Response("Method: " + request.method, 200)
28+
29+
@methods.route('/compliant5', methods=[getMethod()]) # Compliant
30+
def compliant5():
31+
return Response("Method: " + request.method, 200)
32+
33+
@methods.route('/compliant6', methods=getMethods()) # Compliant
34+
def compliant6():
35+
return Response("Method: " + request.method, 200)
36+
37+
@methods.route('/compliant7', methods=['FOO']) # Compliant
38+
def compliant7():
39+
return Response("Method: " + request.method, 200)
40+
41+
def inside_fn():
42+
from flask import Blueprint, request
43+
from flask import Response
44+
45+
methods = Blueprint('methods', __name__)
46+
47+
# 127.0.0.1:5000/methods/sensitive1
48+
@methods.route('/sensitive1', methods=['GET', 'POST']) # Noncompliant
49+
def sensitive1():
50+
return Response("Method: " + request.method, 200)
51+
52+
# 127.0.0.1:5000/methods/compliant1
53+
@methods.route('/compliant1') # Compliant
54+
def compliant1():
55+
return Response("Method: " + request.method, 200)

0 commit comments

Comments
 (0)