Skip to content

Commit 7270c92

Browse files
marc-jasper-sonarsourcesonartech
authored andcommitted
SONARPY-3613 Extend S6552 to support Flask route decorators alongside Django receiver (#816)
GitOrigin-RevId: e89907f1d61c48975fc396e0ea5ac510c79ea1a6
1 parent c73963d commit 7270c92

11 files changed

Lines changed: 297 additions & 121 deletions

File tree

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@
4141
import org.sonar.python.checks.django.DjangoModelFormFieldsCheck;
4242
import org.sonar.python.checks.django.DjangoModelStrMethodCheck;
4343
import org.sonar.python.checks.django.DjangoModelStringFieldCheck;
44-
import org.sonar.python.checks.django.DjangoReceiverDecoratorCheck;
4544
import org.sonar.python.checks.hotspots.ClearTextProtocolsCheck;
4645
import org.sonar.python.checks.hotspots.CommandLineArgsCheck;
4746
import org.sonar.python.checks.hotspots.CorsCheck;
@@ -484,7 +483,7 @@ public Stream<Class<?>> getChecks() {
484483
XMLParserXXEVulnerableCheck.class,
485484
XMLSignatureValidationCheck.class,
486485
DjangoModelFormFieldsCheck.class,
487-
DjangoReceiverDecoratorCheck.class,
486+
WebEntryPointDecoratorCheck.class,
488487
DjangoModelStringFieldCheck.class,
489488
DjangoModelStrMethodCheck.class,
490489
HardcodedCredentialsCallCheck.class);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
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 Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks;
18+
19+
import java.util.List;
20+
import java.util.Optional;
21+
import org.sonar.check.Rule;
22+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
23+
import org.sonar.plugins.python.api.SubscriptionContext;
24+
import org.sonar.plugins.python.api.quickfix.PythonQuickFix;
25+
import org.sonar.plugins.python.api.tree.CallExpression;
26+
import org.sonar.plugins.python.api.tree.Decorator;
27+
import org.sonar.plugins.python.api.tree.Expression;
28+
import org.sonar.plugins.python.api.tree.FunctionDef;
29+
import org.sonar.plugins.python.api.tree.Tree;
30+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
31+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
32+
import org.sonar.python.quickfix.TextEditUtils;
33+
import org.sonar.python.tree.TreeUtils;
34+
35+
@Rule(key = "S6552")
36+
public class WebEntryPointDecoratorCheck extends PythonSubscriptionCheck {
37+
38+
private static final String MESSAGE_TEMPLATE = "Move this '%s' decorator to the top of the other decorators.";
39+
private static final String QUICK_FIX_TEMPLATE = "Move the '%s' decorator to the top";
40+
41+
private record DecoratorMatcher(TypeMatcher matcher, String decoratorName) {}
42+
43+
private static final List<DecoratorMatcher> DECORATOR_MATCHERS = List.of(
44+
new DecoratorMatcher(TypeMatchers.withFQN("django.dispatch.receiver"), "@receiver"),
45+
new DecoratorMatcher(TypeMatchers.any(
46+
TypeMatchers.isType("flask.app.Flask.route"),
47+
TypeMatchers.isType("flask.blueprints.Blueprint.route")
48+
), "@route")
49+
);
50+
51+
@Override
52+
public void initialize(Context context) {
53+
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, WebEntryPointDecoratorCheck::checkFunctionDef);
54+
}
55+
56+
private static void checkFunctionDef(SubscriptionContext ctx) {
57+
var functionDef = (FunctionDef) ctx.syntaxNode();
58+
var decorators = functionDef.decorators();
59+
60+
if (decorators.size() < 2) {
61+
return;
62+
}
63+
64+
for (var matcher : DECORATOR_MATCHERS) {
65+
decorators.stream()
66+
.filter(decorator -> matchesDecorator(decorator, matcher.matcher(), ctx))
67+
.findFirst()
68+
.ifPresent(decorator -> checkDecoratorPosition(ctx, functionDef, decorator, matcher.decoratorName()));
69+
}
70+
}
71+
72+
private static boolean matchesDecorator(Decorator decorator, TypeMatcher matcher, SubscriptionContext ctx) {
73+
Expression expression = decorator.expression();
74+
if (!(expression instanceof CallExpression callExpr)) {
75+
return false;
76+
}
77+
return matcher.isTrueFor(callExpr.callee(), ctx);
78+
}
79+
80+
private static void checkDecoratorPosition(SubscriptionContext ctx, FunctionDef functionDef, Decorator decorator, String decoratorName) {
81+
var decorators = functionDef.decorators();
82+
if (decorators.indexOf(decorator) != 0) {
83+
String message = String.format(MESSAGE_TEMPLATE, decoratorName);
84+
String quickFixMessage = String.format(QUICK_FIX_TEMPLATE, decoratorName);
85+
var issue = ctx.addIssue(decorator, message);
86+
addQuickFix(issue, functionDef, decorator, quickFixMessage);
87+
}
88+
}
89+
90+
private static void addQuickFix(PreciseIssue issue, FunctionDef functionDef, Decorator decorator, String quickFixMessage) {
91+
var builder = PythonQuickFix.newQuickFix(quickFixMessage);
92+
93+
var decorators = functionDef.decorators();
94+
var decoratorPosition = decorators.indexOf(decorator);
95+
var removeTo = decorators.size() == decoratorPosition + 1 ? functionDef.defKeyword()
96+
: decorators.get(decoratorPosition + 1);
97+
98+
Optional.of(decorator)
99+
.map(d -> TreeUtils.treeToString(d, true))
100+
.map(decoratorString -> TextEditUtils.insertBefore(decorators.get(0), decoratorString))
101+
.ifPresent(builder::addTextEdit);
102+
103+
var removeEdit = TextEditUtils.removeUntil(decorator, removeTo);
104+
builder.addTextEdit(removeEdit);
105+
106+
issue.addQuickFix(builder.build());
107+
}
108+
}
109+

python-checks/src/main/java/org/sonar/python/checks/django/DjangoReceiverDecoratorCheck.java

Lines changed: 0 additions & 80 deletions
This file was deleted.
Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
1-
<p>This rule enforces that the '@receiver' decorator is placed on top of all other decorators in Django functions.</p>
1+
<p>This rule enforces that the <code>@receiver</code> decorator in Django and the <code>@route</code> decorator in Flask are placed on top of all
2+
other decorators.</p>
23
<h2>Why is this an issue?</h2>
3-
<p>In Django, the '@receiver' decorator is used to register signal handlers. These handlers are used to respond to events that occur in the
4-
application, such as a user logging in or a database record being saved.</p>
5-
<p>The order in which decorators are applied can have a significant impact on their behavior. In the case of the @receiver decorator, it is important
6-
that it is applied first, before any other decorators, in order to ensure that the signal handler is registered correctly.</p>
7-
<p>If the '@receiver' decorator is not applied first, the decorators placed above it will be ignored, which can result in unexpected behavior or even
8-
errors in the application.</p>
9-
<h2>How to fix it</h2>
10-
<p>To fix this issue, simply move the '@receiver' decorator to the top of the list of decorators used to decorate the function.</p>
4+
<p>The order in which decorators are applied can have a significant impact on their behavior. When decorators are applied in Python, they are
5+
processed from bottom to top. The outermost decorator (the one written first) is applied last and receives the result of all inner decorators.</p>
6+
<p>In Django, the <code>@receiver</code> decorator is used to register signal handlers. It must be the outermost decorator (written at the top) so
7+
that all other decorators are applied to the function before it gets registered as a signal handler. If <code>@receiver</code> is not outermost, it
8+
registers the original function, and the decorators written above it will not affect the signal handler, resulting in unexpected behavior or
9+
errors.</p>
10+
<p>In Flask, the <code>@app.route()</code> or <code>@blueprint.route()</code> decorator must be the outermost decorator for view functions. If other
11+
decorators are placed outside the route decorator, the route may not be registered correctly, leading to 404 errors or routes that don’t respond to
12+
requests as expected.</p>
13+
<h2>How to fix it in Django</h2>
14+
<p>Move the <code>@receiver</code> decorator to the top of the list of decorators used to decorate the function.</p>
1115
<h3>Code examples</h3>
1216
<h4>Noncompliant code example</h4>
13-
<pre>
17+
<pre data-diff-id="1" data-diff-type="noncompliant">
1418
from django.dispatch import receiver
1519
from django.views.decorators.csrf import csrf_exempt
1620

1721
@csrf_exempt
18-
@receiver(some_signal)
22+
@receiver(some_signal) # Noncompliant: @receiver should be the outermost decorator
1923
def my_handler(sender, **kwargs):
2024
...
2125
</pre>
2226
<h4>Compliant solution</h4>
23-
<pre>
27+
<pre data-diff-id="1" data-diff-type="compliant">
2428
from django.dispatch import receiver
2529
from django.views.decorators.csrf import csrf_exempt
2630

@@ -29,9 +33,28 @@ <h4>Compliant solution</h4>
2933
def my_handler(sender, **kwargs):
3034
...
3135
</pre>
36+
<h2>How to fix it in Flask</h2>
37+
<p>Move the <code>@app.route()</code> or <code>@blueprint.route()</code> decorator to be the outermost (first) decorator.</p>
38+
<h3>Code examples</h3>
39+
<h4>Noncompliant code example</h4>
40+
<pre data-diff-id="2" data-diff-type="noncompliant">
41+
@login_required
42+
@app.route('/secret_page') # Noncompliant: @route should be the outermost decorator
43+
def secret_page():
44+
return "Secret content"
45+
</pre>
46+
<h4>Compliant solution</h4>
47+
<pre data-diff-id="2" data-diff-type="compliant">
48+
@app.route('/secret_page')
49+
@login_required
50+
def secret_page():
51+
return "Secret content"
52+
</pre>
3253
<h2>Resources</h2>
3354
<h3>Documentation</h3>
3455
<ul>
3556
<li> <a href="https://docs.djangoproject.com/en/4.1/topics/signals/">Django signals</a> </li>
57+
<li> <a href="https://flask.palletsprojects.com/en/stable/patterns/viewdecorators/">Flask View Decorators</a> </li>
58+
<li> <a href="https://flask.palletsprojects.com/en/stable/quickstart/#routing">Flask Routing</a> </li>
3659
</ul>
3760

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"title": "Django signal handler functions should have the \u0027@receiver\u0027 decorator on top of all other decorators",
2+
"title": "The \u0027@receiver\u0027 (Django) and \u0027@route\u0027 (Flask) decorators should be the outermost decorators",
33
"type": "BUG",
44
"code": {
55
"impacts": {
@@ -14,11 +14,12 @@
1414
},
1515
"tags": [
1616
"pitfall",
17-
"django"
17+
"django",
18+
"flask"
1819
],
1920
"defaultSeverity": "Major",
2021
"ruleSpecification": "RSPEC-6552",
2122
"sqKey": "S6552",
2223
"scope": "All",
23-
"quickfix": "unknown"
24+
"quickfix": "covered"
2425
}

python-checks/src/test/java/org/sonar/python/checks/django/DjangoReceiverDecoratorCheckTest.java renamed to python-checks/src/test/java/org/sonar/python/checks/WebEntryPointDecoratorCheckTest.java

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,34 @@
1414
* You should have received a copy of the Sonar Source-Available License
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
17-
package org.sonar.python.checks.django;
17+
package org.sonar.python.checks;
1818

1919
import org.junit.jupiter.api.Test;
2020
import org.sonar.python.checks.quickfix.PythonQuickFixVerifier;
2121
import org.sonar.python.checks.utils.PythonCheckVerifier;
2222

23-
class DjangoReceiverDecoratorCheckTest {
23+
class WebEntryPointDecoratorCheckTest {
24+
2425
@Test
25-
void test() {
26-
PythonCheckVerifier.verify("src/test/resources/checks/django/djangoReceiverDecoratorCheck.py", new DjangoReceiverDecoratorCheck());
26+
void testDjangoReceiver() {
27+
PythonCheckVerifier.verify("src/test/resources/checks/webEntryPointDecorator/djangoReceiverDecorator.py", new WebEntryPointDecoratorCheck());
2728
}
2829

2930
@Test
30-
void testRenamedImport() {
31-
PythonCheckVerifier.verify("src/test/resources/checks/django/djangoReceiverDecoratorCheck_renamed_import.py",
32-
new DjangoReceiverDecoratorCheck());
31+
void testDjangoReceiverRenamedImport() {
32+
PythonCheckVerifier.verify("src/test/resources/checks/webEntryPointDecorator/djangoReceiverDecoratorRenamedImport.py",
33+
new WebEntryPointDecoratorCheck());
3334
}
3435

3536
@Test
36-
void testWrongImport() {
37-
PythonCheckVerifier.verifyNoIssue("src/test/resources/checks/django/djangoReceiverDecoratorCheck_wrong_import.py",
38-
new DjangoReceiverDecoratorCheck());
37+
void testDjangoReceiverWrongImport() {
38+
PythonCheckVerifier.verifyNoIssue("src/test/resources/checks/webEntryPointDecorator/djangoReceiverDecoratorWrongImport.py",
39+
new WebEntryPointDecoratorCheck());
3940
}
4041

4142
@Test
42-
void quickFixTest() {
43-
var check = new DjangoReceiverDecoratorCheck();
43+
void djangoReceiverQuickFixTest() {
44+
var check = new WebEntryPointDecoratorCheck();
4445
var before = """
4546
from django.dispatch import receiver
4647
@csrf_exempt
@@ -76,4 +77,31 @@ def my_handler(sender, **kwargs):
7677
PythonQuickFixVerifier.verifyQuickFixMessages(check, before, "Move the '@receiver' decorator to the top");
7778
}
7879

80+
@Test
81+
void testFlaskRoute() {
82+
PythonCheckVerifier.verify("src/test/resources/checks/webEntryPointDecorator/flaskRouteDecorator.py", new WebEntryPointDecoratorCheck());
83+
}
84+
85+
@Test
86+
void flaskRouteQuickFixTest() {
87+
var check = new WebEntryPointDecoratorCheck();
88+
var before = """
89+
from flask import Flask
90+
app = Flask(__name__)
91+
@login_required
92+
@app.route('/secret')
93+
def secret_page():
94+
return "Secret" """;
95+
96+
var after = """
97+
from flask import Flask
98+
app = Flask(__name__)
99+
@app.route('/secret')
100+
@login_required
101+
def secret_page():
102+
return "Secret" """;
103+
PythonQuickFixVerifier.verify(check, before, after);
104+
PythonQuickFixVerifier.verifyQuickFixMessages(check, before, "Move the '@route' decorator to the top");
105+
}
79106
}
107+

python-checks/src/test/resources/checks/django/djangoReceiverDecoratorCheck.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)