Django · SQL injection

SQL injection in Django

Django's ORM binds every value for you, so SQL injection in a Django project comes from the handful of APIs that accept SQL text, and from request keys that are allowed to choose column names.

Class
SQL injection in Python
CWE
CWE-89
web framework
Django documentation
On this page

How it arises in Django

A queryset built from filter(), exclude() and get() sends the SQL and the values separately. The escape hatches are different: each of them takes a string that becomes part of the statement.

  • Manager.raw(raw_query, params=()) runs the query you give it. The Django documentation says no checking is done on the statement passed to raw().
  • QuerySet.extra(where=[...], select={...}) injects clauses into the generated SQL. Django documents it as an old API to use as a last resort.
  • RawSQL(sql, params) places a SQL fragment inside annotate(), filter() or order_by().
  • connection.cursor().execute(sql, [params]) skips the model layer entirely.

All four are safe when the values travel in params, and all four are injectable when the values are formatted into the string. Django's documentation names two mistakes: using string formatting on the query, and putting quotes around the %s placeholder. The second one looks careful and is not: a quoted placeholder is filled in as text.

There is a second, quieter route. Code that expands request data into keyword arguments, such as annotate(**request.GET.dict()) or a values() call fed with JSON keys, lets the caller choose aliases and lookups. Two severe Django CVEs have that shape: CVE-2024-42005 (CVSS 7.3 High per NVD; CISA rates it 9.8 Critical) through JSON keys in values() and values_list() on a JSONField, and CVE-2025-57833 (CVSS 8.1 High per NVD) through FilteredRelation aliases in annotate() and alias(). Django's security team fixed both, and current releases reject those aliases.

Vulnerable and fixed

A report view that searches by surname and sorts by a column the client picks:

from django.db import connection
from myapp.models import Person


def people(request):
    last = request.GET["last_name"]
    sort = request.GET.get("sort", "last_name")
    rows = Person.objects.raw(
        "SELECT * FROM myapp_person WHERE last_name = '%s' ORDER BY %s" % (last, sort)
    )
    with connection.cursor() as cursor:
        cursor.execute(f"SELECT count(*) FROM myapp_person WHERE last_name = '{last}'")
        total = cursor.fetchone()[0]
    return rows, total

The value goes into params, unquoted. The column cannot be a parameter, so it comes from a mapping the code owns:

from django.db import connection
from myapp.models import Person

SORTABLE = {"name": "last_name", "born": "birth_date"}


def people(request):
    last = request.GET["last_name"]
    column = SORTABLE.get(request.GET.get("sort"), "last_name")
    rows = Person.objects.raw(
        "SELECT * FROM myapp_person WHERE last_name = %s ORDER BY " + column, [last]
    )
    with connection.cursor() as cursor:
        cursor.execute("SELECT count(*) FROM myapp_person WHERE last_name = %s", [last])
        total = cursor.fetchone()[0]
    return rows, total

In most views the better fix is to drop the raw SQL: Person.objects.filter(last_name=last).order_by(column) does the same work with no SQL text in your code.

What Django already does for you

Quite a lot. Querysets are built with query parameterization, so user-provided values are escaped by the database driver. The raw APIs all accept params. And the project's security team publishes advisories and fixes quickly, which is why keeping Django current is part of the fix for the alias CVEs above.

What es-python adds

es-python follows the request's bytes through your view and reports when they end up inside the text of a statement. For raw() and extra() the event is labelled sql raw. A string passed to cursor.execute() is reported at the database driver's own query call, for the drivers that ship with es-python. Both carry the sqli impact tag.

A value passed in params never becomes statement text, so the fixed view is silent on the surname. The sort column is different: it is still request data inside SQL text, so the event still appears, and you record the allowlist decision once with a rule. The ready-made Block backend network attacks template blocks sql raw, the sort-column query included, so under it pass the column through a fixed mapping in code or switch that rule off for the view.

Because the check is on the statement and not on your source, it also covers SQL text assembled inside Django or a third-party app. Switch on the socket source and run the test suite or a staging server under es-python to see which statements receive request data.

Review checklist

  1. Search for .raw(, .extra(, RawSQL( and cursor.execute(. Each call needs a constant string and a params argument.
  2. Remove quotes around %s in every raw query.
  3. Never expand request data with ** into annotate(), alias(), values() or filter().
  4. Map sort and group-by choices through a dictionary of column names.
  5. Keep Django on a supported release.

References

See it on your own Django code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo