fix(thumbnails): private, no-cache — the URL is gated and mutable

Thumbnails were served `public, max-age=31536000, immutable`. Two
problems, and the first is a security one.

`public` on a Permission::Read gated resource lets any shared cache — a
corporate proxy, a CDN — store one user's thumbnail and serve it to
another. `Vary: Accept` was no defence: it does not vary on
Authorization. Now `private`.

`immutable` was a promise this URL cannot keep. It is keyed by file id,
and its bytes change when a preview is uploaded, when content is
replaced, or when an attachment is removed. `immutable` tells a client
not to revalidate at all during the freshness lifetime, so with a
one-year max-age a browser that fetched once would never see a new
preview — which also made the content-keyed ETag unobservable in
practice. A correct validator is worthless if nothing asks. Now
`no-cache`, which still stores the body and only requires revalidation,
answered by the ETag with a body-less 304.

The hurl tests could not have caught this: hurl always sends the
request, so If-None-Match was exercised and passed while a browser
obeying `immutable` never got that far. Same "correct on the wire, wrong
in practice" shape as the bugs before it, so the test now asserts the
directives themselves rather than only the 304 behaviour.

One definition, shared by the REST and NextCloud endpoints, which are
gated identically and must not drift. /_app/immutable is untouched:
those are hash-named static assets, genuinely content-addressed and
public, where the directive is honest.

Cost is a conditional request per thumbnail per page load. Recovering it
needs a content-addressed URL — where `immutable` would be true — but
that puts the hash in the URL of an authorized resource, so it stays
`private` regardless, and it touches the SPA and the file DTO. Separate
change.
This commit is contained in:
Edouard Vanbelle
2026-08-25 23:17:49 +02:00
parent 7d9418f63c
commit 95648f2fa3
3 changed files with 71 additions and 31 deletions
+13 -9
View File
@@ -17,6 +17,9 @@ use crate::application::ports::storage_ports::FileReadPort;
use crate::application::ports::thumbnail_ports::{ThumbnailFormat, ThumbnailPort, ThumbnailSize};
use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject};
// One definition of the thumbnail cache policy, shared with the REST
// endpoint: both are Permission::Read gated, so both must stay `private`.
use crate::interfaces::api::handlers::file_handler::FileHandler;
use crate::interfaces::middleware::auth::AuthUser;
use uuid::Uuid;
@@ -143,12 +146,13 @@ pub async fn handle_preview(
// (ROUND10). Authz already passed above; a 304 must never skip the Read
// check.
//
// Keyed on the CONTENT hash, matching the REST thumbnail endpoint. A
// thumbnail is a pure function of (source bytes, size), so that pair
// identifies the response and `immutable` below is honest. Keying on the
// object id meant replacing a file's content — which preserves the id —
// left every client showing the old preview for up to a year, since
// `immutable` suppresses revalidation entirely.
// Keyed on the CONTENT of the bytes served, matching the REST thumbnail
// endpoint. Keying on the object id meant replacing a file's content —
// which preserves the id — left the validator unchanged, and the response
// was `immutable` with a one-year max-age, so clients never revalidated
// and showed the old preview indefinitely. Both halves are fixed: the
// ETag names what is served (see `thumbnail_content_id`) and the policy
// is `private, no-cache` (see `FileHandler::THUMBNAIL_CACHE_CONTROL`).
//
// This moves the blob-hash query ahead of the 304 rather than adding one:
// the same lookup used to sit just below, on the path that renders.
@@ -189,7 +193,7 @@ pub async fn handle_preview(
{
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CACHE_CONTROL, FileHandler::THUMBNAIL_CACHE_CONTROL)
.header(header::ETAG, etag)
.body(Body::empty())
.unwrap();
@@ -226,7 +230,7 @@ pub async fn handle_preview(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CACHE_CONTROL, FileHandler::THUMBNAIL_CACHE_CONTROL)
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap();
@@ -251,7 +255,7 @@ pub async fn handle_preview(
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/jpeg")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CACHE_CONTROL, FileHandler::THUMBNAIL_CACHE_CONTROL)
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap(),