Downloading a file: the name goes into the header unescaped, so a quote breaks it and a line break answers 500 #376
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#376
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading the file-serving helpers and running them with awkward names.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atdocuments/models.py:605-606(an upload'sdownload_name) anddocuments/models.py:714-716(a rendered document's);core/files.pyand the callers are where they were.serve_private_filewrites the download name intofilename="…"after doing nothing to it but dropping the characters outside ASCII. A double quote in a document's title ends the quoted string early, so the header is malformed; a line break makes Django refuse the header, and the download answers 500. The document then cannot be downloaded at all until its title is changed.documents/views.py:61(handed_over) already builds the same header with Django'scontent_disposition_header, which handles both, so the two ways of naming a download have drifted apart.core/files.py:89-96(serve_private_file), feddocument.download_namefromdocuments/views.py:663and:676andapi/routers/documents.py:155-161, which is the title as stored (documents/models.py:577-578for uploads,:686-688for rendered documents).core/files.py:144-149(serve_private_text) builds the header the same way, but its only caller (jobs/page_views.py:116-117) passes a slugified stem, so it cannot be reached with a bad name today.CV "final"downloads withattachment; filename="CV "final".pdf"; filename*=UTF-8''CV%20%22final%22.pdf. Browsers that preferfilename*still name it correctly; a client that reads onlyfilenamegets a truncated or mangled name.BadHeaderError: Header values can't contain newlinesand the download (page or API) answers 500.forms.CharField.cleanonly strips the ends, so"CV\r\nfor Acme"is saved as it is from a hand-made POST, andcore/importer.py:845restores upload and sent-document titles from an archive without any check.Both reproduced against the snapshot;
content_disposition_header(True, name)givesfilename="CV \"final\".pdf"andfilename*=utf-8''CV%0AAcme.pdffor the same inputs.django.utils.http.content_disposition_header(as_attachment, name)in bothserve_private_fileandserve_private_text, ashanded_overdoes, so the three places cannot drift again. Optionally also strip control characters fromdownload_namebefore use, and reject them in the document title fields.Done when
tests/test_document_formats.pydownloads a document whose title isCV "final"and one whose title contains\r\n, and asserts a 200 with a well-formedContent-Disposition(quotes escaped, no raw line break); the second fails today withBadHeaderError.