Django 6.1.3 : un upload base64 peut saturer la mémoire avant tout contrôle
Un upload Django passe par deux garde-fous : la taille maximale acceptée par le proxy en frontal, et FILE_UPLOAD_MAX_MEMORY_SIZE, qui décide à partir de quand un fichier quitte la mémoire pour le disque. Les deux mesurent les octets reçus. Aucun ne mesure les octets décodés. CVE-2026-108147 tient dans cet écart.
Ce que dit la note du 3 novembre 2026
La note de sécurité publie trois versions : Django 6.1.3, 6.0.10 et 5.2.19. Le CVE-2026-108147 y est classé low selon la politique de sécurité du projet. Une sévérité low, donc, et une ligne de plus dans la liste des raisons de ne pas repousser une mise à jour mineure.
Le comportement décrit est simple. MultiPartParser pouvait bufferiser entièrement en mémoire un upload multipart portant Content-Transfer-Encoding: base64, sans tenir compte de FILE_UPLOAD_MAX_MEMORY_SIZE, et cela avant de le passer aux upload handlers.
Autrement dit, le réglage qui devait faire basculer un gros fichier sur disque ne s’appliquait pas. Le parser n’avait pas encore de fichier à confier à un handler. Il avait une chaîne base64 en mémoire, et il la décodait là.
Le proxy compte le fil, le process compte la RAM
Base64 gonfle : 3 octets deviennent 4 caractères. Un corps de 10 Mo transporte donc environ 7,5 Mo de données utiles, padding compris. client_max_body_size sous Nginx compte les 10 Mo. La mémoire du process retient les 7,5 Mo décodés, entiers, pour chaque requête en cours.
La valeur par défaut de FILE_UPLOAD_MAX_MEMORY_SIZE est 2,5 Mo. Un fichier de 7,5 Mo devrait donc partir sur disque. Dans le cas décrit, il restait en RAM. Sur un frontal réglé à 100 Mo, cela fait 75 Mo par requête, multipliés par la concurrence. La limite du proxy n’est pas fausse. Elle est mesurée à un autre endroit que celui où la mémoire se consomme.
Reproduire en local
Sur une version non corrigée, sans proxy devant. Un runserver suffit, il ne pose aucune limite de corps.
Générer une charge dont la version décodée est grosse :
python -c "import base64, os; open('payload.b64','wb').write(base64.b64encode(os.urandom(80*1024*1024)))"
80 Mo décodés, environ 107 Mo sur le fil. Envoyer le tout en une part multipart, avec l’en-tête sur la part et non sur la requête :
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/
Pendant l’appel, suivre la RSS du process :
while :; do ps -o rss= -p "$(pgrep -f manage.py | head -1)"; sleep 0.2; done
La RSS monte d’environ 80 Mo et ne redescend qu’après la réponse. Le fichier n’est jamais passé sur disque. C’est cette mesure, avant et après mise à jour, qui vaut mieux qu’un paragraphe de documentation.
Plafonner la taille décodée
Le correctif règle le parser. Il ne règle pas votre application, qui accepte peut-être du base64 par un autre chemin : un champ JSON, un formulaire, un endpoint maison.
Dans ce cas, la taille décodée se calcule avant de décoder. Une chaîne base64 de n caractères donne au plus n × 3 / 4 octets, padding déduit. Le contrôle ne coûte rien en mémoire puisqu’il porte sur la chaîne déjà reçue :
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
La seconde vérification paraît redondante. Elle ne l’est pas : la première est une borne haute, la seconde mesure ce qui est réellement sorti.
Pour un multipart, la question préalable est de savoir si vous avez besoin de Content-Transfer-Encoding: base64. La plupart des clients n’envoient pas cet en-tête. S’il n’est pas nécessaire, le refuser ferme le chemin. Sinon, il faut compter les octets décodés au fil de l’eau dans un upload handler, pas après coup : request.FILES['fichier'].size vous donne la taille, mais à un moment où le buffer existe déjà.
Ce que ça ne protège pas
Un plafond par requête ne dit rien du nombre de requêtes. Cinquante uploads de 4 Mo simultanés tiennent chacun sous la limite et consomment 200 Mo. Le plafond de mémoire du process se règle avec la concurrence, pas avec la taille d’un fichier.
Il ne protège pas non plus d’une bombe de décompression. Base64 borne les octets décodés ; un zip ou un gzip à l’intérieur peut encore produire des gigaoctets à l’extraction. C’est un autre contrôle, à un autre endroit.
Enfin, il ne remplace pas la mise à jour. Sur 6.1.2 et les versions antérieures, le parser garde son comportement, et le plafond applicatif n’entre en jeu qu’après lui.
Je n’ai pas instrumenté le correctif lui-même : je m’en tiens à ce que la note publie. Ce qui est vérifiable de mon côté, c’est la courbe de RSS avant et après, et le fait qu’un plafond sur la taille décodée se pose en quelques lignes.