Private files behind nginx: with POSTULO_MEDIA_ACCEL_PREFIX the file policy and nosniff never reach the browser #415

Closed
opened 2026-10-01 10:46:32 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the private-file delivery code against the wiki's proxy guidance.

Checked again on 2026-10-01 against main at 0a5cad587: still present. core/files.py and both test files are unchanged. In the wiki, since moved on to 9aefad9, the setting's entry is now at Configuration.md:258 and still shows no headers; Hardening.md:54-56 is as it was.

serve_private_file puts Content-Security-Policy: default-src 'none'; sandbox (FILE_POLICY, #264) and X-Content-Type-Options: nosniff on every private file. When POSTULO_MEDIA_ACCEL_PREFIX is set, those headers sit on an empty Django response that nginx consumes for its X-Accel-Redirect. On that internal redirect nginx carries over only a fixed set of upstream headers (Content-Type, Content-Disposition, Cache-Control, Expires, Accept-Ranges, Set-Cookie). Anything else is dropped unless the internal location adds it again, and nothing in the wiki says to. With this option on, the second of the two SVG defences is gone, and no test notices.

  • Where: core/files.py:98-117 (serve_private_file: the accel branch builds the response that later receives X-Content-Type-Options and Content-Security-Policy); core/files.py:51-62 (FILE_POLICY, described as "the half that holds if the sanitiser is ever wrong"); wiki Configuration.md:256 and Hardening.md:54-56 (the setting and "mark the location internal", with no headers); tests/test_private_media.py:132-137 (test_nginx_serves_the_bytes_when_configured checks only X-Accel-Redirect and the empty body); tests/security/test_svg.py:7-9 (promises two defences).
  • What happens: an operator follows the wiki, sets POSTULO_MEDIA_ACCEL_PREFIX and adds an internal location. A company logo stored as SVG (jobs:company_logo, served inline) then arrives with no sandbox policy and no nosniff. Visited directly, it is a same-origin document, and only core.pictures.sanitise_svg stands between it and the origin. Logos are often fetched from a company's own website, so their content is chosen by a third party. Other inline files (avatars, sent CVs, picture renderings of captures) lose the same headers. A PDF rendering of a capture is not exposed this way, because it is served as an attachment.
  • Who can trigger it: whoever controls an SVG that reaches a logo, on an instance whose operator opted into nginx acceleration, and only if the sanitiser misses something. The person must also open the logo's address directly.
  • Why it matters: #264 added the policy because a sanitiser on its own is a single point of failure. In this mode it is the only defence again, and the code, the tests and the documentation all say otherwise.
  • Fix: document the internal location in full in the wiki's POSTULO_MEDIA_ACCEL_PREFIX entry and in Hardening, with add_header Content-Security-Policy "default-src 'none'; sandbox" always; and add_header X-Content-Type-Options nosniff always;. Say in core/files.py's module docstring that the proxy must add these headers in this mode. As a code-side backstop, serve image/svg+xml through Django even when acceleration is configured, so the one type the policy exists for never depends on the proxy. In tests/security/test_svg.py's docstring, say which defence depends on the proxy.

Done when

  • tests/test_private_media.py has a test that sets POSTULO_MEDIA_ACCEL_PREFIX, serves an SVG and asserts that the response is not an X-Accel-Redirect and carries FILE_POLICY. It fails today.
  • The wiki's entry for POSTULO_MEDIA_ACCEL_PREFIX shows the internal location with both add_header lines.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the private-file delivery code against the wiki's proxy guidance. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. `core/files.py` and both test files are unchanged. In the wiki, since moved on to `9aefad9`, the setting's entry is now at `Configuration.md:258` and still shows no headers; `Hardening.md:54-56` is as it was. `serve_private_file` puts `Content-Security-Policy: default-src 'none'; sandbox` (`FILE_POLICY`, #264) and `X-Content-Type-Options: nosniff` on every private file. When `POSTULO_MEDIA_ACCEL_PREFIX` is set, those headers sit on an empty Django response that nginx consumes for its `X-Accel-Redirect`. On that internal redirect nginx carries over only a fixed set of upstream headers (`Content-Type`, `Content-Disposition`, `Cache-Control`, `Expires`, `Accept-Ranges`, `Set-Cookie`). Anything else is dropped unless the internal location adds it again, and nothing in the wiki says to. With this option on, the second of the two SVG defences is gone, and no test notices. - **Where:** `core/files.py:98-117` (`serve_private_file`: the accel branch builds the response that later receives `X-Content-Type-Options` and `Content-Security-Policy`); `core/files.py:51-62` (`FILE_POLICY`, described as "the half that holds if the sanitiser is ever wrong"); wiki `Configuration.md:256` and `Hardening.md:54-56` (the setting and "mark the location `internal`", with no headers); `tests/test_private_media.py:132-137` (`test_nginx_serves_the_bytes_when_configured` checks only `X-Accel-Redirect` and the empty body); `tests/security/test_svg.py:7-9` (promises two defences). - **What happens:** an operator follows the wiki, sets `POSTULO_MEDIA_ACCEL_PREFIX` and adds an `internal` location. A company logo stored as SVG (`jobs:company_logo`, served inline) then arrives with no sandbox policy and no `nosniff`. Visited directly, it is a same-origin document, and only `core.pictures.sanitise_svg` stands between it and the origin. Logos are often fetched from a company's own website, so their content is chosen by a third party. Other inline files (avatars, sent CVs, picture renderings of captures) lose the same headers. A PDF rendering of a capture is not exposed this way, because it is served as an attachment. - **Who can trigger it:** whoever controls an SVG that reaches a logo, on an instance whose operator opted into nginx acceleration, and only if the sanitiser misses something. The person must also open the logo's address directly. - **Why it matters:** #264 added the policy because a sanitiser on its own is a single point of failure. In this mode it is the only defence again, and the code, the tests and the documentation all say otherwise. - **Fix:** document the internal location in full in the wiki's `POSTULO_MEDIA_ACCEL_PREFIX` entry and in *Hardening*, with `add_header Content-Security-Policy "default-src 'none'; sandbox" always;` and `add_header X-Content-Type-Options nosniff always;`. Say in `core/files.py`'s module docstring that the proxy must add these headers in this mode. As a code-side backstop, serve `image/svg+xml` through Django even when acceleration is configured, so the one type the policy exists for never depends on the proxy. In `tests/security/test_svg.py`'s docstring, say which defence depends on the proxy. ## Done when - `tests/test_private_media.py` has a test that sets `POSTULO_MEDIA_ACCEL_PREFIX`, serves an SVG and asserts that the response is not an `X-Accel-Redirect` and carries `FILE_POLICY`. It fails today. - The wiki's entry for `POSTULO_MEDIA_ACCEL_PREFIX` shows the `internal` location with both `add_header` lines.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:32 +00:00
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#415
No description provided.