PST-042: An inline document's Content-Type is steered by a user-typed title #771

Closed
opened 2026-10-10 10:56:15 +00:00 by tiagoagueda · 1 comment
Owner

Found in the security audit of 2026-10-10 (SECURITY-AUDIT-2026-10-10.md), run against main at c30851b from 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.


  • Severity: Low
  • Category: content-type-confusion / self-XSS
  • Location: src/postulo/core/files.py (114-131)
  • Found by: file handling

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 to manage.py import_data. No cross-account path: the download view is owner-scoped.

How it works.

  1. On Export PDF / Send, the person types the file title data:text/html,x in the properties form; file_title() returns chosen.title and snapshot_cv stores it as RenderedDocument.title.
  2. RenderedDocument.download_name is f"{self.title}.pdf" = data:text/html,x.pdf.
  3. RenderedDownloadView calls serve_private_file(request, document.file, download_name=...) with the default as_attachment=False.
  4. serve_private_file does mimetypes.guess_type(filename)[0]; CPython's guess_type treats a data: 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).
  5. Scratch test ([scratch test]) confirms the response: 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 under POSTULO_MEDIA_ACCEL_PREFIX / POSTULO_MEDIA_SENDFILE the file is handed to nginx/Apache as text/html and the module's own note (lines 21-25) says the proxy does not carry Content-Security-Policy or X-Content-Type-Options unless the operator adds them.
  6. In the web path the body is Postulo's own PDF; uncompressed PDF parts (link-annotation /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 the default-src 'none'; sandbox policy and nosniff neutralise 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.

core/files.py:114-115
    filename = download_name or posixpath.basename(file_field.name)
    content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
core/files.py:121-122
    handed_over = content_type != "image/svg+xml"
    if accel_prefix and handed_over:
documents/models.py:1036-1039
    def download_name(self) -> str:
        return f"{self.title}.pdf"
documents/views.py:800-802
    document = get_object_or_404(self.get_queryset(), pk=pk)
    return serve_private_file(request, document.file, download_name=document.download_name)
documents/properties.py:98
    title=_tidy(data.get("title"), MAX_TITLE),
documents/rendering.py:1050
    title = _fit(file_title(cv, cho
…

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_data of 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/pdf for RenderedDocument, the stored extension's fixed type for UploadedDocument (a table keyed by integrity.EXTENSIONS), the stored kind for CapturedPage. If guessing must stay, guess from Path(name).suffix only, 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 apply integrity.problem_with_bytes('pdf', ...) to sent files too.

Found in the security audit of 2026-10-10 (`SECURITY-AUDIT-2026-10-10.md`), run against `main` at `c30851b` from 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. --- - **Severity:** Low - **Category:** content-type-confusion / self-XSS - **Location:** `src/postulo/core/files.py` (114-131) - **Found by:** file handling **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 to `manage.py import_data`. No cross-account path: the download view is owner-scoped. **How it works.** 1. On *Export PDF* / *Send*, the person types the file title `data:text/html,x` in the properties form; `file_title()` returns `chosen.title` and `snapshot_cv` stores it as `RenderedDocument.title`. 2. `RenderedDocument.download_name` is `f"{self.title}.pdf"` = `data:text/html,x.pdf`. 3. `RenderedDownloadView` calls `serve_private_file(request, document.file, download_name=...)` with the default `as_attachment=False`. 4. `serve_private_file` does `mimetypes.guess_type(filename)[0]`; CPython's `guess_type` treats a `data:` 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)`. 5. Scratch test ([scratch test]) confirms the response: `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 under `POSTULO_MEDIA_ACCEL_PREFIX` / `POSTULO_MEDIA_SENDFILE` the file is handed to nginx/Apache as `text/html` and the module's own note (lines 21-25) says the proxy does not carry `Content-Security-Policy` or `X-Content-Type-Options` unless the operator adds them. 6. In the web path the body is Postulo's own PDF; uncompressed PDF parts (link-annotation `/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 the `default-src 'none'; sandbox` policy and `nosniff` neutralise 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.** ```text core/files.py:114-115 filename = download_name or posixpath.basename(file_field.name) content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" core/files.py:121-122 handed_over = content_type != "image/svg+xml" if accel_prefix and handed_over: documents/models.py:1036-1039 def download_name(self) -> str: return f"{self.title}.pdf" documents/views.py:800-802 document = get_object_or_404(self.get_queryset(), pk=pk) return serve_private_file(request, document.file, download_name=document.download_name) documents/properties.py:98 title=_tidy(data.get("title"), MAX_TITLE), documents/rendering.py:1050 title = _fit(file_title(cv, cho … ``` **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_data` of 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/pdf` for RenderedDocument, the stored extension's fixed type for UploadedDocument (a table keyed by `integrity.EXTENSIONS`), the stored kind for CapturedPage. If guessing must stay, guess from `Path(name).suffix` only, 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 apply `integrity.problem_with_bytes('pdf', ...)` to `sent` files too.
tiagoagueda added this to the 0.6.0 milestone 2026-10-10 10:57:46 +00:00
Author
Owner

Fixed in dff42dccb on main.

Fixed in dff42dccb on main.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#771
No description provided.