Expiring URLs are served with Cache-Control: max-age=31536000, immutable #63

Closed
opened 2026-08-09 03:43:32 +02:00 by clawbot · 2 comments
Collaborator

Verified against main at 61f42e6.

Both image paths set a one-year immutable cache header unconditionally:

  • internal/handlers/image.go:126
  • internal/handlers/imageenc.go:76
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")

But both URL schemes expire. A signed URL carries exp, and pixa returns 410/401 once it passes; an encrypted URL carries a TTL (the manual test pass recorded in TODO.md confirms a ttl=1 URL returns 410 Gone after 3 s).

So for any URL with a TTL shorter than a year — i.e. all of them — we instruct every intermediary cache and browser to keep serving the content for a year after we would refuse to serve it ourselves. Expiration becomes advisory the moment a CDN or corporate proxy is in front of pixa, which defeats the stated purpose of putting expiry in the signature ("to prevent replay attacks", README.md:69-70).

immutable compounds it: conforming clients will not revalidate even on a forced reload.

Definition of done

  1. max-age is clamped to the remaining lifetime of the URL (exp - now for signed URLs, remaining TTL for encrypted URLs), never exceeding it.
  2. Decide and document whether immutable is still appropriate; it is defensible for content-addressed variants within the URL's own lifetime, but state the reasoning in the PR.
  3. A URL with no expiry (allowlisted host, unsigned) may keep the long max-age — confirm that path still does.
  4. Failing tests first: a request through a signed URL expiring in 60 s must not emit a max-age greater than 60; same for a short-TTL encrypted URL.
  5. make check green.
Verified against `main` at `61f42e6`. Both image paths set a one-year immutable cache header unconditionally: - `internal/handlers/image.go:126` - `internal/handlers/imageenc.go:76` ```go w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") ``` But both URL schemes expire. A signed URL carries `exp`, and pixa returns 410/401 once it passes; an encrypted URL carries a TTL (the manual test pass recorded in `TODO.md` confirms a `ttl=1` URL returns 410 Gone after 3 s). So for any URL with a TTL shorter than a year — i.e. all of them — we instruct every intermediary cache and browser to keep serving the content for a year after we would refuse to serve it ourselves. Expiration becomes advisory the moment a CDN or corporate proxy is in front of pixa, which defeats the stated purpose of putting expiry in the signature ("to prevent replay attacks", `README.md:69-70`). `immutable` compounds it: conforming clients will not revalidate even on a forced reload. ## Definition of done 1. `max-age` is clamped to the remaining lifetime of the URL (`exp - now` for signed URLs, remaining TTL for encrypted URLs), never exceeding it. 2. Decide and document whether `immutable` is still appropriate; it is defensible for content-addressed variants within the URL's own lifetime, but state the reasoning in the PR. 3. A URL with no expiry (allowlisted host, unsigned) may keep the long max-age — confirm that path still does. 4. Failing tests first: a request through a signed URL expiring in 60 s must not emit a `max-age` greater than 60; same for a short-TTL encrypted URL. 5. `make check` green.
clawbot added this to the 1.0.0 milestone 2026-08-09 03:43:32 +02:00
clawbot self-assigned this 2026-09-29 02:36:59 +02:00
Author
Collaborator

Plan, checked against next at be060a8: both routes still set public, max-age=31536000, immutable unconditionally, in writeImageResponse (internal/handlers/image.go) and HandleImageEnc (internal/handlers/imageenc.go).

  • One small function in internal/handlers/image.go builds the header from the URL's expiry: max-age is one year, or the whole seconds left until the expiry when that is sooner, rounded down and never below 0. /v1/image/ passes the URL's exp; /v1/e/ passes the encrypted URL's expiry. A URL with no expiry (no exp, or an encrypted URL made without a TTL) keeps one year.
  • An expired URL is still refused before any header is written; that code is unchanged.
  • immutable stays: it only stops a client revalidating while its copy is fresh, and freshness now ends no later than the URL's expiry, so it can no longer keep an image in use after pixa would refuse the URL.
  • Judgement call: a URL for an allowlisted host that carries an exp is still served without checking it, as today, but its max-age follows that exp too. The header follows exp whenever the URL has one, which is the simpler rule and never gives a longer lifetime.
  • Failing route tests first, in their own commit: a signed URL expiring in 60 s, an encrypted URL with a 60 s TTL, an encrypted URL with no TTL, and allowlisted URLs with no exp and with an exp already past (the one request that reaches the header after its expiry; it must get max-age=0).
  • README.md gets a short paragraph on the header under Routes.

Model: opus-5-5

Plan, checked against `next` at `be060a8`: both routes still set `public, max-age=31536000, immutable` unconditionally, in `writeImageResponse` (`internal/handlers/image.go`) and `HandleImageEnc` (`internal/handlers/imageenc.go`). - One small function in `internal/handlers/image.go` builds the header from the URL's expiry: `max-age` is one year, or the whole seconds left until the expiry when that is sooner, rounded down and never below 0. `/v1/image/` passes the URL's `exp`; `/v1/e/` passes the encrypted URL's expiry. A URL with no expiry (no `exp`, or an encrypted URL made without a TTL) keeps one year. - An expired URL is still refused before any header is written; that code is unchanged. - `immutable` stays: it only stops a client revalidating while its copy is fresh, and freshness now ends no later than the URL's expiry, so it can no longer keep an image in use after pixa would refuse the URL. - Judgement call: a URL for an allowlisted host that carries an `exp` is still served without checking it, as today, but its `max-age` follows that `exp` too. The header follows `exp` whenever the URL has one, which is the simpler rule and never gives a longer lifetime. - Failing route tests first, in their own commit: a signed URL expiring in 60 s, an encrypted URL with a 60 s TTL, an encrypted URL with no TTL, and allowlisted URLs with no `exp` and with an `exp` already past (the one request that reaches the header after its expiry; it must get `max-age=0`). - `README.md` gets a short paragraph on the header under Routes. Model: opus-5-5
Author
Collaborator

Built as planned in #146. One change from the plan: ToImageRequest in internal/encurl now carries an encrypted URL's expiry onto the request, so both routes pass the same field to the new header function.

Model: opus-5-5

Built as planned in https://git.eeqj.de/sneak/pixa/pulls/146. One change from the plan: `ToImageRequest` in `internal/encurl` now carries an encrypted URL's expiry onto the request, so both routes pass the same field to the new header function. Model: opus-5-5
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/pixa#63