# PYTHON-DJANGO-SEC-001: Django SQL Injection via cursor.execute()

> **Severity:** CRITICAL | **CWE:** CWE-89 | **OWASP:** A03:2021

- **Language:** Python
- **Category:** Django
- **URL:** https://codepathfinder.dev/registry/python/django/PYTHON-DJANGO-SEC-001
- **Detection:** `pathfinder scan --ruleset python/PYTHON-DJANGO-SEC-001 --project .`

## Description

This rule detects SQL injection vulnerabilities in Django applications where untrusted
user input from HTTP request parameters flows into raw SQL queries passed to
cursor.execute() without proper parameterization.

Django's ORM provides safe, parameterized query construction by default, but developers
sometimes bypass this protection by dropping down to raw SQL using Django's database
connection cursor. When request.GET, request.POST, request.body, or other user-controlled
data is concatenated or f-stringed into a SQL string before being passed to cursor.execute(),
an attacker can inject arbitrary SQL, read or modify all database records, bypass
authentication, or escalate to OS-level commands on vulnerable database servers.

The rule uses inter-procedural taint analysis to follow data across function calls and
file boundaries, catching patterns like query strings built in one function and executed
in another.


## Vulnerable Code

```python
from django.db import connection
from django.db.models.expressions import RawSQL
from django.http import HttpRequest

# SEC-001: cursor.execute with request data
def vulnerable_cursor(request):
    user_id = request.GET.get('id')
    cursor = connection.cursor()
    query = f"SELECT * FROM users WHERE id = {user_id}"
    cursor.execute(query)
    return cursor.fetchone()
```

## Secure Code

```python
from django.http import JsonResponse
from django.db import connection

def search_users(request):
    username = request.GET.get('username', '')
    # SECURE: Pass user input as a separate parameters list, never in the SQL string
    with connection.cursor() as cursor:
        cursor.execute(
            "SELECT id, username, email FROM users WHERE username = %s",
            [username]
        )
        rows = cursor.fetchall()
    return JsonResponse({'users': rows})

def get_user_orders(request):
    user_id = request.GET.get('user_id', '')
    status = request.GET.get('status', '')
    # SECURE: Multiple parameters are safe when passed as a list
    with connection.cursor() as cursor:
        cursor.execute(
            "SELECT * FROM orders WHERE user_id = %s AND status = %s",
            [user_id, status]
        )
        orders = cursor.fetchall()
    return JsonResponse({'orders': orders})

def get_report(request):
    # SECURE: Prefer Django ORM over raw SQL wherever possible
    from myapp.models import Order
    status = request.GET.get('status', '')
    orders = Order.objects.filter(status=status).values('id', 'total', 'created_at')
    return JsonResponse({'orders': list(orders)})

```

## Detection Rule (Python SDK)

```python
from rules.python_decorators import python_rule
from codepathfinder import calls, flows, QueryType
from codepathfinder.presets import PropagationPresets

class DBCursor(QueryType):
    fqns = ["sqlite3.Cursor", "mysql.connector.cursor.MySQLCursor",
            "psycopg2.extensions.cursor", "pymysql.cursors.Cursor"]
    patterns = ["*Cursor"]
    match_subclasses = True

# Common Django request sources
_DJANGO_SOURCES = [
    calls("request.GET.get"),
    calls("request.POST.get"),
    calls("request.GET"),
    calls("request.POST"),
    calls("request.COOKIES.get"),
    calls("request.FILES.get"),
    calls("*.GET.get"),
    calls("*.POST.get"),
]


@python_rule(
    id="PYTHON-DJANGO-SEC-001",
    name="Django SQL Injection via cursor.execute()",
    severity="CRITICAL",
    category="django",
    cwe="CWE-89",
    tags="python,django,sql-injection,cursor,OWASP-A03,CWE-89",
    message="User input flows to cursor.execute() without parameterization. Use %s placeholders.",
    owasp="A03:2021",
)
def detect_django_cursor_sqli():
    """Detects request data flowing to cursor.execute()."""
    return flows(
        from_sources=_DJANGO_SOURCES,
        to_sinks=[
            DBCursor.method("execute", "executemany").tracks(0),
            calls("cursor.execute"),
            calls("*.execute"),
        ],
        sanitized_by=[
            calls("escape"),
            calls("escape_string"),
            calls("escape_sql"),
        ],
        propagates_through=PropagationPresets.standard(),
        scope="global",
    )
```

## How to Fix

- Always pass user input as the second argument to cursor.execute() as a list or tuple, never by concatenating it into the SQL string.
- Prefer Django ORM methods (filter(), exclude(), annotate()) over raw SQL to benefit from automatic parameterization.
- Validate and restrict the format of input used in SQL queries (e.g., enforce integer type for ID parameters) before it reaches the database layer.
- Grant database accounts the minimum required privileges so that a compromised query cannot DROP tables or access unrelated schemas.
- Enable Django's database query logging in development to audit all SQL statements and catch unparameterized queries early.

## Security Implications

- **Full Database Compromise:** An attacker who controls the SQL string passed to cursor.execute() can use UNION
SELECT to read from any table in the database, including user credentials, session
tokens, and private records. There is no row-level restriction once the query is
fully attacker-controlled.

- **Authentication Bypass:** Login queries built with string concatenation can be bypassed with the classic
' OR '1'='1 payload. The query returns all rows, the application logs in as the
first user found, which is typically an admin account.

- **Data Manipulation and Destruction:** Stacked queries or subquery injection can issue INSERT, UPDATE, or DELETE statements
through the same cursor.execute() call, allowing attackers to alter financial records,
create backdoor accounts, or destroy application data.

- **Database Server Escalation:** On PostgreSQL, COPY TO/FROM can read or write server filesystem files. On MySQL,
INTO OUTFILE and LOAD DATA INFILE enable similar file access. These paths can
escalate from SQL injection to full server compromise.


## FAQ

**Q: Why does this rule flag my code even when I'm using cursor.execute() safely?**

The rule tracks whether user-controlled data reaches the SQL string argument
(position 0) of cursor.execute(). If your code passes user input as the second
argument (the parameters list), it will not be flagged. If you are still seeing
a finding, check whether the tainted value is being embedded inside the SQL
string itself even partially, for example via f-strings or % formatting before
the execute call.


**Q: Can this rule detect SQL injection across multiple Django view files?**

Yes. The rule uses inter-procedural analysis with global scope, meaning it follows
tainted data from request.GET.get() in a view through helper functions in
services.py or utils.py all the way to cursor.execute() in a separate database
module. This catches the common pattern where views are thin and database logic
lives in a separate layer.


**Q: Does using Django ORM make my application immune to this finding?**

Yes, Django ORM methods like filter(), exclude(), get(), and annotate() are
parameterized by design and will not trigger this rule. This rule specifically
targets the raw SQL path through cursor.execute(). If you replace all raw SQL
usage with ORM calls, this class of vulnerability is eliminated.


**Q: How do I fix this in code that must use raw SQL for performance reasons?**

Keep the SQL template string as a static literal and pass all user-controlled
values in the second argument: cursor.execute("SELECT * FROM t WHERE id = %s", [user_id]).
Django's database backend handles escaping for you. Never build the SQL string
with f-strings or % formatting using untrusted input, even for parts of the query
like column names or ORDER BY direction -- use allowlists for those.


**Q: What compliance frameworks require fixing SQL injection?**

PCI DSS v4.0 Requirement 6.2.4 explicitly mandates protection against injection
attacks for any system that handles cardholder data. OWASP Top 10 A03:2021 covers
Injection. NIST SP 800-53 SI-10 requires input validation. SOC 2 Type II CC6.1
covers protection of data from unauthorized access. All of these are satisfied
by using parameterized queries.


**Q: Will this rule flag Django admin or third-party apps that use cursor.execute()?**

Only if user-controlled input flows into the SQL string argument. Django admin
and well-maintained third-party packages typically use the ORM or parameterized
raw queries, so they will not be flagged. If a third-party package is flagged,
that is worth investigating as a genuine vulnerability in that dependency.


**Q: How do I suppress a false positive for a specific cursor.execute() call?**

If the value reaching cursor.execute() is validated (e.g., an integer ID parsed
from a URL path parameter with int()), add explicit type conversion before the
call. Code Pathfinder recognizes int() and float() conversions as sanitizers for
SQL injection. Alternatively, use an allowlist check before the query.


## References

- [CWE-89: SQL Injection](https://cwe.mitre.org/data/definitions/89.html)
- [OWASP SQL Injection](https://owasp.org/www-community/attacks/SQL_Injection)
- [OWASP SQL Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)
- [Django Raw SQL Queries](https://docs.djangoproject.com/en/stable/topics/db/sql/)
- [Django Security - SQL Injection Protection](https://docs.djangoproject.com/en/stable/topics/security/#sql-injection-protection)
- [Python DB-API 2.0 Parameterized Queries](https://peps.python.org/pep-0249/#paramstyle)

---

Source: https://codepathfinder.dev/registry/python/django/PYTHON-DJANGO-SEC-001
Code Pathfinder — Open source, type-aware SAST with cross-file dataflow analysis
