Django security releases 5.2.6, 5.1.12, and 4.2.24 patch a critical SQL injection vulnerability in FilteredRelation. If you're using FilteredRelation with user-provided input, your application might be vulnerable to SQLi attacks. This guide shows you how to identify, patch, and prevent this security issue.
Step 1: Understanding the Vulnerability
The SQL injection vulnerability exists in Django's FilteredRelation when combined with lookups that accept user input. Attackers can inject malicious SQL through specially crafted filter conditions.
Here's vulnerable code that demonstrates the issue:
# views.py - VULNERABLE CODE
from django.db.models import FilteredRelation, Q
from django.http import JsonResponse
from myapp.models import Order
def get_filtered_orders(request):
# User input comes from query parameter
status_filter = request.GET.get('status', '')
# This FilteredRelation is vulnerable to SQL injection
orders = Order.objects.annotate(
special_items=FilteredRelation(
'items',
condition=Q(items__status__icontains=status_filter)
)
).filter(special_items__isnull=False)
return JsonResponse({
'count': orders.count(),
'orders': list(orders.values('id', 'customer_name'))
})
If an attacker sends this request:
$ curl "http://localhost:8000/api/orders/?status=pending'%20OR%201=1--"
The malicious input bypasses the intended query logic and can expose unauthorized data or manipulate the database.
The error might not appear immediately. Instead, you'll see unexpected query results or database behavior. In development with DEBUG=True, you might see SQL queries like:
SELECT ... WHERE items.status ILIKE '%pending' OR 1=1--%'
Step 2: Identifying the Cause
Django's FilteredRelation builds SQL conditions dynamically. Before the patch, certain lookup types didn't properly sanitize user input when constructing SQL WHERE clauses. The vulnerability affects these specific scenarios:
- Using FilteredRelation with user-provided values in Q objects
- Lookups like
icontains,contains,startswith,endswithwith direct user input
- Any custom lookups that don't escape SQL properly
- Combining multiple Q objects with user data
The root cause is insufficient parameterization of SQL queries. Django's ORM normally uses parameterized queries, but FilteredRelation's condition handling had a gap where raw strings could leak into SQL without proper escaping.
Check your codebase for these patterns:
$ grep -r "FilteredRelation" --include="*.py" .
$ grep -r "condition=Q" --include="*.py" .
Look for any FilteredRelation usage where the condition includes variables from:
- request.GET or request.POST
- URL parameters
- Form data
- External API responses
- Any untrusted source
Step 3: Implementing the Solution
Immediate Fix: Upgrade Django
The fastest solution is upgrading to a patched version:
$ pip install --upgrade Django==5.2.6
# or
$ pip install --upgrade Django==5.1.12
# or
$ pip install --upgrade Django==4.2.24
Verify your Django version:
$ python -c "import django; print(django.get_version())"
5.2.6
Update your requirements.txt:
Django>=5.2.6
# or
Django>=5.1.12,<5.2
# or
Django>=4.2.24,<4.3
Secure Code Pattern
Even after patching, follow these best practices to prevent similar issues:
# views.py - SECURE CODE
from django.db.models import FilteredRelation, Q, Value
from django.http import JsonResponse
from myapp.models import Order
def get_filtered_orders(request):
# Whitelist allowed status values
ALLOWED_STATUSES = ['pending', 'shipped', 'delivered', 'cancelled']
status_filter = request.GET.get('status', '')
# Validate input against whitelist
if status_filter not in ALLOWED_STATUSES:
return JsonResponse({'error': 'Invalid status'}, status=400)
# Now safe to use in FilteredRelation
orders = Order.objects.annotate(
special_items=FilteredRelation(
'items',
condition=Q(items__status__iexact=status_filter)
)
).filter(special_items__isnull=False)
return JsonResponse({
'count': orders.count(),
'orders': list(orders.values('id', 'customer_name'))
})
For more complex filtering requirements:
# views.py - ADVANCED SECURE PATTERN
from django.db.models import FilteredRelation, Q
from django.core.exceptions import ValidationError
from myapp.models import Order, Item
def get_orders_with_filters(request):
# Extract and validate multiple filters
filters = {}
status = request.GET.get('status')
if status:
# Validate against model choices if available
valid_statuses = [choice[0] for choice in Item.STATUS_CHOICES]
if status in valid_statuses:
filters['items__status'] = status
min_quantity = request.GET.get('min_quantity')
if min_quantity:
try:
# Validate numeric input
min_qty = int(min_quantity)
if min_qty > 0:
filters['items__quantity__gte'] = min_qty
except ValueError:
return JsonResponse({'error': 'Invalid quantity'}, status=400)
# Build condition with validated inputs only
if filters:
orders = Order.objects.annotate(
matching_items=FilteredRelation(
'items',
condition=Q(**filters)
)
).filter(matching_items__isnull=False)
else:
orders = Order.objects.all()
return JsonResponse({
'count': orders.count(),
'orders': list(orders.values('id', 'customer_name'))
})
Using Form Validation
Leverage Django's form validation for additional security:
# forms.py
from django import forms
class OrderFilterForm(forms.Form):
STATUS_CHOICES = [
('pending', 'Pending'),
('shipped', 'Shipped'),
('delivered', 'Delivered'),
]
status = forms.ChoiceField(choices=STATUS_CHOICES, required=False)
min_quantity = forms.IntegerField(min_value=1, required=False)
# views.py
from django.db.models import FilteredRelation, Q
from myapp.forms import OrderFilterForm
def get_filtered_orders(request):
form = OrderFilterForm(request.GET)
if not form.is_valid():
return JsonResponse({'errors': form.errors}, status=400)
# Form validation ensures data is safe
filters = {}
if form.cleaned_data.get('status'):
filters['items__status'] = form.cleaned_data['status']
if form.cleaned_data.get('min_quantity'):
filters['items__quantity__gte'] = form.cleaned_data['min_quantity']
orders = Order.objects.annotate(
matching_items=FilteredRelation('items', condition=Q(**filters))
).filter(matching_items__isnull=False)
return JsonResponse({
'count': orders.count(),
'orders': list(orders.values('id', 'customer_name'))
})
Step 4: Testing Your Fix
Create tests to verify the vulnerability is patched:
# tests.py
from django.test import TestCase, Client
from myapp.models import Order, Item
class FilteredRelationSecurityTest(TestCase):
def setUp(self):
self.client = Client()
order = Order.objects.create(customer_name='Test Customer')
Item.objects.create(order=order, status='pending', quantity=5)
def test_sql_injection_attempt_blocked(self):
# Attempt SQL injection
response = self.client.get('/api/orders/?status=pending\' OR \'1\'=\'1')
# Should return 400 or empty results, not expose data
self.assertIn(response.status_code, [400, 200])
if response.status_code == 200:
data = response.json()
# Should not return all orders
self.assertLessEqual(data['count'], 1)
def test_valid_status_works(self):
response = self.client.get('/api/orders/?status=pending')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['count'], 1)
def test_invalid_status_rejected(self):
response = self.client.get('/api/orders/?status=invalid_status')
self.assertEqual(response.status_code, 400)
Run the tests:
$ python manage.py test myapp.tests.FilteredRelationSecurityTest
Manual Security Testing
Test with common SQL injection payloads:
# Test single quote injection
$ curl "http://localhost:8000/api/orders/?status=pending'"
# Test OR condition injection
$ curl "http://localhost:8000/api/orders/?status=pending'%20OR%201=1--"
# Test UNION injection
$ curl "http://localhost:8000/api/orders/?status=pending'%20UNION%20SELECT%20*%20FROM%20auth_user--"
# Test comment injection
$ curl "http://localhost:8000/api/orders/?status=pending'/**/OR/**/1=1--"
All these should return either validation errors or empty results, never expose unauthorized data.
Additional Tips and Related Issues
Check for Similar Patterns
SQL injection isn't limited to FilteredRelation. Review other areas:
# Find potential raw SQL usage
$ grep -r "raw(" --include="*.py" .
$ grep -r "extra(" --include="*.py" .
$ grep -r "RawSQL" --include="*.py" .
Security Scanning
Add security scanning to your CI/CD pipeline:
$ pip install bandit
$ bandit -r . -f json -o security_report.json
Configure bandit to check for SQL injection:
# .bandit
tests:
- B608 # SQL injection
- B703 # Django extra/raw SQL
Database Query Logging
Enable query logging in development to inspect generated SQL:
# settings.py
LOGGING = {
'version': 1,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
},
'loggers': {
'django.db.backends': {
'handlers': ['console'],
'level': 'DEBUG',
},
},
}
Related CVEs to Check
This patch addresses CVE-2025-23687. Check if you're affected by related Django security issues:
- CVE-2024-45230: Potential denial-of-service in django.utils.html.urlize
- CVE-2024-45231: Potential user enumeration in django.contrib.auth
- CVE-2024-42005: Potential SQL injection in QuerySet.values() and values_list()
Performance Considerations
Input validation adds minimal overhead but can accumulate in high-traffic applications. Cache validation results when possible:
from django.core.cache import cache
def validate_status(status):
cache_key = f'valid_status_{status}'
is_valid = cache.get(cache_key)
if is_valid is None:
is_valid = status in ALLOWED_STATUSES
cache.set(cache_key, is_valid, timeout=3600)
return is_valid
Deployment Checklist
Before deploying the patch:
- Update Django in all environments (dev, staging, production)
- Run full test suite including security tests
- Review all FilteredRelation usage in codebase
- Update dependencies in requirements.txt
- Document the security patch in change log
- Monitor error logs after deployment
- Consider a security audit for similar vulnerabilities
The Django security team recommends treating this as a high-priority patch if you use FilteredRelation with any user input. The vulnerability allows direct SQL manipulation, which can lead to data breaches, unauthorized access, or database corruption.