Django 6.1.3: a base64 upload can exhaust memory before any check
A Django upload goes through two guardrails: the maximum size accepted by the proxy in front, and FILE_UPLOAD_MAX_MEMORY_SIZE, which decides when a file leaves memory for disk. Both measure bytes received. Neither measures bytes decoded. CVE-2026-108147 fits in that gap.
What the 3 November 2026 advisory says
The security advisory publishes three versions: Django 6.1.3, 6.0.10 and 5.2.19. CVE-2026-108147 is rated low there under the project’s security policy. A low severity, then, and one more line in the list of reasons not to postpone a minor update.
The behaviour described is simple. MultiPartParser could buffer a multipart upload carrying Content-Transfer-Encoding: base64 entirely in memory, ignoring FILE_UPLOAD_MAX_MEMORY_SIZE, and that before handing it to the upload handlers.
In other words, the setting that was supposed to move a large file to disk did not apply. The parser did not yet have a file to hand to a handler. It had a base64 string in memory, and it decoded it right there.
The proxy counts bytes on the wire, the process counts RAM
Base64 inflates: 3 bytes become 4 characters. A 10 MB body therefore carries about 7.5 MB of useful data, padding included. client_max_body_size under Nginx counts the 10 MB. Process memory holds the 7.5 MB decoded, whole, for each request in flight.
The default value of FILE_UPLOAD_MAX_MEMORY_SIZE is 2.5 MB. A 7.5 MB file should therefore go to disk. In the case described, it stayed in RAM. On an edge proxy set to 100 MB, that is 75 MB per request, multiplied by concurrency. The proxy limit is not wrong. It is measured at a different place from where the memory is consumed.
Reproducing locally
On an unpatched version, with no proxy in front. A runserver is enough, it sets no body limit.
Generate a payload whose decoded version is large:
python -c "import base64, os; open('payload.b64','wb').write(base64.b64encode(os.urandom(80*1024*1024)))"
80 MB decoded, about 107 MB on the wire. Send the whole thing as one multipart part, with the header on the part and not on the request:
curl -s -o /dev/null -w '%{http_code}\n' \
-F 'fichier=@payload.b64;headers="Content-Transfer-Encoding: base64"' \
http://127.0.0.1:8000/upload/
During the call, watch the process RSS:
while :; do ps -o rss= -p "$(pgrep -f manage.py | head -1)"; sleep 0.2; done
RSS climbs by about 80 MB and only comes back down after the response. The file never touches disk. That measurement, before and after the update, is worth more than a paragraph of documentation.
Capping the decoded size
The fix settles the parser. It does not settle your application, which may accept base64 by another route: a JSON field, a form, a homegrown endpoint.
In that case, the decoded size is computed before decoding. A base64 string of n characters yields at most n × 3 / 4 bytes, padding deducted. The check costs nothing in memory since it applies to the string already received:
MAX_DECODED = 5 * 1024 * 1024
def decode_bounded(champ: str) -> bytes:
if len(champ) * 3 // 4 > MAX_DECODED:
raise ValueError("payload trop gros")
data = base64.b64decode(champ, validate=True)
if len(data) > MAX_DECODED:
raise ValueError("payload trop gros")
return data
The second check looks redundant. It is not: the first is an upper bound, the second measures what actually came out.
For a multipart, the prior question is whether you need Content-Transfer-Encoding: base64 at all. Most clients do not send that header. If it is not needed, refusing it closes the path. If it is, you have to count decoded bytes as they stream in, inside an upload handler, not after the fact: request.FILES['fichier'].size gives you the size, but at a point where the buffer already exists.
What this does not protect against
A per-request cap says nothing about the number of requests. Fifty simultaneous 4 MB uploads each sit under the limit and consume 200 MB. The process memory ceiling is set with concurrency, not with file size.
Nor does it protect against a decompression bomb. Base64 bounds the decoded bytes; a zip or a gzip inside can still produce gigabytes on extraction. That is another check, at another place.
Finally, it does not replace the update. On 6.1.2 and earlier, the parser keeps its behaviour, and the application-level cap only comes into play after it.
I did not instrument the fix itself: I stick to what the advisory publishes. What I can verify on my side is the RSS curve before and after, and the fact that a cap on decoded size takes a few lines to put in place.