PST-042: An inline document's Content-Type is steered by a user-typed title #771
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#771
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 in the security audit of 2026-10-10 (
SECURITY-AUDIT-2026-10-10.md), run againstmainatc30851bfrom 8 to 10 October 2026, as PST-042. Severity: Low.The text below is the audit's entry, unchanged apart from links to the other issues it names. The finding was produced by automated review and checked by a second automated pass; re-check it before acting on it.
src/postulo/core/files.py(114-131)Who can do it, and what they need. The account owner themselves (self-XSS), or anyone who can set a RenderedDocument title: the file-properties form on export (documents/properties.py
from_data, title up to MAX_TITLE) or an archive handed tomanage.py import_data. No cross-account path: the download view is owner-scoped.How it works.
data:text/html,xin the properties form;file_title()returnschosen.titleandsnapshot_cvstores it asRenderedDocument.title.RenderedDocument.download_nameisf"{self.title}.pdf"=data:text/html,x.pdf.RenderedDownloadViewcallsserve_private_file(request, document.file, download_name=...)with the defaultas_attachment=False.serve_private_filedoesmimetypes.guess_type(filename)[0]; CPython'sguess_typetreats adata:scheme specially and returns the media type named inside it. Verified on this interpreter (3.14):mimetypes.guess_type('data:text/html,x.pdf') == ('text/html', None).Content-Type: text/html,Content-Disposition: inline; filename="data:text/html,hello.pdf", body unchanged.handed_over = content_type != "image/svg+xml"is True, so underPOSTULO_MEDIA_ACCEL_PREFIX/POSTULO_MEDIA_SENDFILEthe file is handed to nginx/Apache astext/htmland the module's own note (lines 21-25) says the proxy does not carryContent-Security-PolicyorX-Content-Type-Optionsunless the operator adds them./URI (...)strings built from the person's own web links, Info/XMP strings) are the only place markup could sit. …Impact. Self-XSS only: the owner can make their own rendered document come back inline as
text/html(or any type they name) from the application origin. On Django's own response thedefault-src 'none'; sandboxpolicy andnosniffneutralise it; on an X-Accel/X-Sendfile deployment where the operator did not add those headers, scripts in the body would run in the app origin for the owner. The deeper weakness is that the served type is decided by a name the person typed rather than by what the record is, which undoes the "SVG is never handed over" reasoning for any type a title can name.Evidence.
Verification (verdict: confirmed; severity after verification: Low). Scope corrections: the inline path is only the web RenderedDownloadView (documents/views.py:802). The API download (api/routers/documents.py:348) and the upload download (documents/views.py:785, title plus the stored extension) pass as_attachment=True, so a data: title there only mislabels a saved file. The captured-rendering view overrides Content-Type with the stored kind (jobs/page_views.py:139) and export archives use a system-generated name (core/slow.py:44), so neither is affected. Only the data: scheme defeats the trailing .pdf, since guess_type uses the URL path for every other scheme. The body on the web path is WeasyPrint's own PDF; arbitrary bytes under this title need the operator's
import_dataof the person's own archive (importer.py:1854, uploaded=False skips the magic check). Remediation: have serve_private_file derive the type from the stored name's extension (or take an explicit content_type) and let RenderedDownloadView say application/pdf, as CapturedRenderingView already does; a test in tests/security pinning the type for a scheme-like title.Suggested remediation. Decide the media type from what the record is, never from the download name:
application/pdffor RenderedDocument, the stored extension's fixed type for UploadedDocument (a table keyed byintegrity.EXTENSIONS), the stored kind for CapturedPage. If guessing must stay, guess fromPath(name).suffixonly, and refuse or replace a://in a download stem. Hand over to the proxy only types on a short binary allow-list (pdf, png, jpeg, webp, zip, octet-stream) and stream everything else from Django with the file policy. Optionally make RenderedDownloadView an attachment like the API does, and have the importer applyintegrity.problem_with_bytes('pdf', ...)tosentfiles too.Fixed in
dff42dccbon main.