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 toraw().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 insideannotate(),filter()ororder_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
- Search for
.raw(,.extra(,RawSQL(andcursor.execute(. Each call needs a constant string and aparamsargument. - Remove quotes around
%sin every raw query. - Never expand request data with
**intoannotate(),alias(),values()orfilter(). - Map sort and group-by choices through a dictionary of column names.
- Keep Django on a supported release.
References
- Reference: Django documentation: Performing raw SQL queries (checked 2026-10-12)
- Reference: Django documentation: Security in Django, SQL injection protection (checked 2026-10-12)
- Reference: Django documentation: QuerySet API, extra() (checked 2026-10-12)
- Reference: Django documentation: Query expressions, RawSQL (checked 2026-10-12)