Skip to content

Commit 4b9f9fc

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

File tree

11 files changed

+357
-0
lines changed

11 files changed

+357
-0
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
'project:django-2.2.3/django/contrib/admin/sites.py':[
3+
368,
4+
],
5+
'project:django-2.2.3/django/contrib/flatpages/views.py':[
6+
22,
7+
],
8+
'project:django-2.2.3/django/views/i18n.py':[
9+
23,
10+
],
11+
}

python-checks/src/main/java/org/sonar/python/checks/CheckList.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.sonar.python.checks.hotspots.SecureCookieCheck;
4848
import org.sonar.python.checks.hotspots.StandardInputCheck;
4949
import org.sonar.python.checks.hotspots.StrongCryptographicKeysCheck;
50+
import org.sonar.python.checks.hotspots.UnsafeHttpMethodsCheck;
5051
import org.sonar.python.checks.hotspots.UnverifiedHostnameCheck;
5152

5253
public final class CheckList {
@@ -211,6 +212,7 @@ public static Iterable<Class> getChecks() {
211212
UnreadPrivateAttributesCheck.class,
212213
UnreadPrivateInnerClassesCheck.class,
213214
UnreadPrivateMethodsCheck.class,
215+
UnsafeHttpMethodsCheck.class,
214216
UndefinedSymbolsCheck.class,
215217
UnusedLocalVariableCheck.class,
216218
UnusedNestedDefinitionCheck.class,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2021 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks.hotspots;
21+
22+
import java.util.Arrays;
23+
import java.util.HashSet;
24+
import java.util.List;
25+
import java.util.Optional;
26+
import java.util.Set;
27+
import org.sonar.check.Rule;
28+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
29+
import org.sonar.plugins.python.api.SubscriptionContext;
30+
import org.sonar.plugins.python.api.symbols.FunctionSymbol;
31+
import org.sonar.plugins.python.api.symbols.Symbol;
32+
import org.sonar.plugins.python.api.tree.Argument;
33+
import org.sonar.plugins.python.api.tree.CallExpression;
34+
import org.sonar.plugins.python.api.tree.Decorator;
35+
import org.sonar.plugins.python.api.tree.Expression;
36+
import org.sonar.plugins.python.api.tree.FunctionDef;
37+
import org.sonar.plugins.python.api.tree.ListLiteral;
38+
import org.sonar.plugins.python.api.tree.RegularArgument;
39+
import org.sonar.plugins.python.api.tree.StringLiteral;
40+
import org.sonar.python.semantic.FunctionSymbolImpl;
41+
import org.sonar.python.tree.FunctionDefImpl;
42+
43+
import static org.sonar.plugins.python.api.tree.Tree.Kind.CALL_EXPR;
44+
import static org.sonar.plugins.python.api.tree.Tree.Kind.FUNCDEF;
45+
import static org.sonar.plugins.python.api.tree.Tree.Kind.LIST_LITERAL;
46+
import static org.sonar.plugins.python.api.tree.Tree.Kind.REGULAR_ARGUMENT;
47+
import static org.sonar.plugins.python.api.tree.Tree.Kind.STRING_LITERAL;
48+
import static org.sonar.python.tree.TreeUtils.getSymbolFromTree;
49+
50+
@Rule(key = "S3752")
51+
public class UnsafeHttpMethodsCheck extends PythonSubscriptionCheck {
52+
53+
private static final Set<String> SAFE_HTTP_METHODS = new HashSet<>(Arrays.asList("GET", "HEAD", "OPTIONS"));
54+
private static final Set<String> UNSAFE_HTTP_METHODS = new HashSet<>(Arrays.asList("POST", "PUT", "DELETE"));
55+
private static final Set<String> COMPLIANT_DECORATORS = new HashSet<>(Arrays.asList(
56+
"django.views.decorators.http.require_POST",
57+
"django.views.decorators.http.require_GET",
58+
"django.views.decorators.http.require_safe"
59+
));
60+
private static final String MESSAGE = "Make sure allowing safe and unsafe HTTP methods is safe here.";
61+
62+
@Override
63+
public void initialize(Context context) {
64+
context.registerSyntaxNodeConsumer(FUNCDEF, ctx -> {
65+
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
66+
if (isDjangoView(functionDef)) {
67+
checkDjangoView(functionDef, ctx);
68+
}
69+
});
70+
}
71+
72+
private static void checkDjangoView(FunctionDef functionDef, SubscriptionContext ctx) {
73+
for (Decorator decorator : functionDef.decorators()) {
74+
if (getSymbolFromTree(decorator.expression())
75+
.filter(symbol -> symbol.fullyQualifiedName() == null || COMPLIANT_DECORATORS.contains(symbol.fullyQualifiedName()))
76+
.isPresent()) {
77+
return;
78+
}
79+
if (decorator.expression().is(CALL_EXPR)) {
80+
CallExpression callExpression = (CallExpression) decorator.expression();
81+
Symbol symbol = callExpression.calleeSymbol();
82+
if (symbol != null && "django.views.decorators.http.require_http_methods".equals(symbol.fullyQualifiedName())) {
83+
checkRequireHttpMethodsDecorator(ctx, callExpression);
84+
return;
85+
}
86+
}
87+
}
88+
ctx.addIssue(functionDef.name(), MESSAGE);
89+
}
90+
91+
private static void checkRequireHttpMethodsDecorator(SubscriptionContext ctx, CallExpression callExpression) {
92+
List<Argument> arguments = callExpression.arguments();
93+
if (!arguments.isEmpty() && hasBothUnsafeAndSafeHttpMethods(arguments.get(0))) {
94+
ctx.addIssue(callExpression, MESSAGE);
95+
}
96+
}
97+
98+
private static boolean hasBothUnsafeAndSafeHttpMethods(Argument argument) {
99+
boolean hasSafeHttpMethod = false;
100+
boolean hasUnsafeHttpMethod = false;
101+
if (argument.is(REGULAR_ARGUMENT) && ((RegularArgument) argument).expression().is(LIST_LITERAL)) {
102+
ListLiteral listLiteral = (ListLiteral) ((RegularArgument) argument).expression();
103+
for (Expression expression : listLiteral.elements().expressions()) {
104+
if (expression.is(STRING_LITERAL)) {
105+
String value = ((StringLiteral) expression).trimmedQuotesValue();
106+
if (SAFE_HTTP_METHODS.contains(value)) {
107+
hasSafeHttpMethod = true;
108+
} else if (UNSAFE_HTTP_METHODS.contains(value)) {
109+
hasUnsafeHttpMethod = true;
110+
}
111+
}
112+
}
113+
}
114+
return hasSafeHttpMethod && hasUnsafeHttpMethod;
115+
}
116+
117+
private static boolean isDjangoView(FunctionDef functionDef) {
118+
FunctionSymbol functionSymbol = ((FunctionDefImpl) functionDef).functionSymbol();
119+
return Optional.ofNullable(functionSymbol)
120+
.map(FunctionSymbolImpl.class::cast)
121+
.filter(FunctionSymbolImpl::isDjangoView)
122+
.isPresent();
123+
}
124+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<p>An HTTP method is safe when used to perform a read-only operation, such as retrieving information. In contrast, an unsafe HTTP method is used to
2+
change the state of an application, for instance to update a user's profile on a web application.</p>
3+
<p>Common safe HTTP methods are GET, HEAD, or OPTIONS.</p>
4+
<p>Common unsafe HTTP methods are POST, PUT and DELETE.</p>
5+
<p>Allowing both safe and unsafe HTTP methods to perform a specific operation on a web application could impact its security, for example CSRF
6+
protections are most of the time only protecting operations performed by unsafe HTTP methods.</p>
7+
<h2>Ask Yourself Whether</h2>
8+
<ul>
9+
<li> HTTP methods are not defined at all for a route/controller of the application. </li>
10+
<li> Safe HTTP methods are defined and used for a route/controller that can change the state of an application. </li>
11+
</ul>
12+
<p>There is a risk if you answered yes to any of those questions.</p>
13+
<h2>Recommended Secure Coding Practices</h2>
14+
<p>For all the routes/controllers of an application, the authorized HTTP methods should be explicitly defined and safe HTTP methods should only be
15+
used to perform read-only operations.</p>
16+
<h2>Sensitive Code Example</h2>
17+
<p>For <a href="https://www.djangoproject.com/">Django</a>:</p>
18+
<pre>
19+
# No method restriction
20+
def view(request): # Sensitive
21+
return HttpResponse("...")
22+
</pre>
23+
<pre>
24+
@require_http_methods(["GET", "POST"]) # Sensitive
25+
def view(request):
26+
return HttpResponse("...")
27+
</pre>
28+
<p>For <a href="https://flask.palletsprojects.com/en/1.1.x/">Flask</a>:</p>
29+
<pre>
30+
@methods.route('/sensitive', methods=['GET', 'POST']) # Sensitive
31+
def view():
32+
return Response("...", 200)
33+
</pre>
34+
<h2>Compliant Solution</h2>
35+
<p>For <a href="https://www.djangoproject.com/">Django</a>:</p>
36+
<pre>
37+
@require_http_methods(["POST"])
38+
def view(request):
39+
return HttpResponse("...")
40+
</pre>
41+
<pre>
42+
@require_POST
43+
def view(request):
44+
return HttpResponse("...")
45+
</pre>
46+
<pre>
47+
@require_GET
48+
def view(request):
49+
return HttpResponse("...")
50+
</pre>
51+
<pre>
52+
@require_safe
53+
def view(request):
54+
return HttpResponse("...")
55+
</pre>
56+
<p>For <a href="https://flask.palletsprojects.com/en/1.1.x/">Flask</a>:</p>
57+
<pre>
58+
@methods.route('/compliant1')
59+
def view():
60+
return Response("...", 200)
61+
</pre>
62+
<pre>
63+
@methods.route('/compliant2', methods=['GET'])
64+
def view():
65+
return Response("...", 200)
66+
</pre>
67+
<h2>See</h2>
68+
<ul>
69+
<li> <a href="https://owasp.org/www-project-top-ten/OWASP_Top_Ten_2017/Top_10-2017_A5-Broken_Access_Control">OWASP Top 10 2017 Category A5</a> -
70+
Broken Access Control </li>
71+
<li> <a href="https://cwe.mitre.org/data/definitions/352.html">MITRE, CWE-352</a> - Cross-Site Request Forgery (CSRF) </li>
72+
<li> <a href="https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29">OWASP: Cross-Site Request Forgery</a> </li>
73+
<li> <a href="https://www.sans.org/top25-software-errors/#cat1">SANS Top 25</a> - Insecure Interaction Between Components </li>
74+
<li> <a href="https://docs.djangoproject.com/en/3.1/topics/http/decorators/#allowed-http-methods">Django</a> - Allowed HTTP Methods </li>
75+
<li> <a href="https://flask.palletsprojects.com/en/1.1.x/quickstart/#http-methods">Flask</a> - HTTP Methods </li>
76+
</ul>
77+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"title": "Allowing both safe and unsafe HTTP methods is security-sensitive",
3+
"type": "SECURITY_HOTSPOT",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"cwe",
11+
"sans-top25-insecure",
12+
"owasp-a5"
13+
],
14+
"defaultSeverity": "Minor",
15+
"ruleSpecification": "RSPEC-3752",
16+
"sqKey": "S3752",
17+
"scope": "Main",
18+
"securityStandards": {
19+
"CWE": [
20+
352
21+
],
22+
"OWASP": [
23+
"A5"
24+
]
25+
}
26+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"S3457",
7070
"S3516",
7171
"S3626",
72+
"S3752",
7273
"S3776",
7374
"S3827",
7475
"S3862",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2021 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.python.checks.hotspots;
21+
22+
import java.util.Arrays;
23+
import org.junit.Test;
24+
import org.sonar.python.checks.utils.PythonCheckVerifier;
25+
26+
public class UnsafeHttpMethodsCheckTest {
27+
28+
@Test
29+
public void test_django() {
30+
PythonCheckVerifier.verify(Arrays.asList(
31+
"src/test/resources/checks/hotspots/unsafeHttpMethods/django/urls.py",
32+
"src/test/resources/checks/hotspots/unsafeHttpMethods/django/views.py",
33+
"src/test/resources/checks/hotspots/unsafeHttpMethods/django/views_decorator_fqn_null.py"
34+
),
35+
new UnsafeHttpMethodsCheck());
36+
}
37+
}

python-checks/src/test/resources/checks/hotspots/unsafeHttpMethods/django/__init__.py

Whitespace-only changes.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from django.urls import path
2+
3+
from . import views
4+
from . import views_decorator_fqn_null
5+
6+
urlpatterns = [
7+
path('sensitive1', views.sensitive1, name='sensitive1'),
8+
path('sensitive2', views.sensitive2, name='sensitive2'),
9+
path('compliant_require_http_methods', views.compliant_require_http_methods, name='compliant_require_http_methods'),
10+
path('compliant_require_http_methods2', views.compliant_require_http_methods2, name='compliant_require_http_methods2'),
11+
path('compliant_require_http_methods3', views.compliant_require_http_methods3, name='compliant_require_http_methods3'),
12+
path('compliant_require_http_methods4', views.compliant_require_http_methods4, name='compliant_require_http_methods4'),
13+
path('compliant_require_http_methods5', views.compliant_require_http_methods5, name='compliant_require_http_methods5'),
14+
path('compliant_require_http_methods6', views.compliant_require_http_methods6, name='compliant_require_http_methods6'),
15+
path('compliant', views_decorator_fqn_null.compliant, name='compliant'),
16+
path('compliant_require_post', views.compliant_require_post, name='compliant_require_post'),
17+
path('compliant_require_get', views.compliant_require_get, name='compliant_require_get'),
18+
path('compliant_require_safe', views.compliant_require_safe, name='compliant_require_safe'),
19+
path('sensitive_other_dec', views.sensitive_other_dec, name='sensitive_other_dec'),
20+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from django.views.decorators.http import require_http_methods, require_POST, require_GET, require_safe, other_decorator
2+
3+
def sensitive1(request): # Noncompliant
4+
...
5+
6+
@require_http_methods(["GET", "POST"]) # Noncompliant
7+
def sensitive2(request):
8+
...
9+
10+
@require_http_methods(["POST"]) # Compliant
11+
def compliant_require_http_methods(request):
12+
...
13+
14+
@require_http_methods(["GET"]) # Compliant
15+
def compliant_require_http_methods2(request):
16+
...
17+
@require_http_methods() # Compliant
18+
def compliant_require_http_methods3(request):
19+
...
20+
21+
methods = []
22+
@require_http_methods(*methods) # Compliant
23+
def compliant_require_http_methods4(request):
24+
...
25+
26+
@unknown(["GET", "POST"])
27+
def compliant_require_http_methods5(request): # Noncompliant
28+
...
29+
30+
def foo(): ...
31+
@foo(["GET", "POST"])
32+
def compliant_require_http_methods6(request): # Noncompliant
33+
...
34+
35+
@require_POST # Compliant
36+
def compliant_require_post(request):
37+
...
38+
39+
@require_GET # Compliant
40+
def compliant_require_get(request):
41+
...
42+
43+
@require_safe # Compliant
44+
def compliant_require_safe(request):
45+
...
46+
@other_decorator
47+
def sensitive_other_dec(request): # Noncompliant
48+
...
49+
50+
def other(request):
51+
...

0 commit comments

Comments
 (0)