Make the origin's Cache-Control headers CDN-safe

An audit of the live site ahead of the Cloudflare move found three ways a
cache-everything rule would have gone wrong.

First, several routes named no policy at all — `/login`, `/issues.json`,
`/files/epub/*`, `/files/xtc/*`, `/robots.txt`, the error pages — so the edge
would have applied its own default TTL (Cloudflare: two hours on a 200). For
`/files/*` that means an authenticated download becoming a publicly cached one.
Those routes now say what they mean, and `security_headers` fails closed: a
response that set no `Cache-Control` gets `no-store`, so a route added later
cannot silently inherit the CDN's default. Everything cookie- or Basic-auth
gated (`/files/*`, `/opds*`) is `private, no-store` on every response, 401s and
404s included.

Second, the public pages said `max-age=300` alone. They now say
`public, max-age=300, s-maxage=86400`: five minutes for the browser, a day for
the edge, which is safe because publishing purges the edge. A request carrying
a `daily_session=` cookie still gets `private, no-store`.

Third, `/static/favicon.svg` and `/static/speculation.json` were referenced
without `?v=` while being served `immutable` for a year — editing either one
could never have reached a browser again. Both are now in the `ASSET_VERSION`
hash and referenced with the version, and `static_asset` only promises a year
when the URL actually carries `?v=`; a bare `/static/…` URL gets an hour and
revalidates against the same ETag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
2026-09-04 21:28:59 +00:00
co-authored by Claude Fable 5.1
parent 41fe61a691
commit 7d12f53314
5 changed files with 289 additions and 37 deletions
+37 -6
View File
@@ -13,7 +13,10 @@
//! | `GET /healthz` | liveness |
//! | `GET /issues.json` | the last 30 run reports, newest first |
//!
//! `/opds/*` and `/files/*` sit behind optional Basic auth (`server.basic_auth_*`).
//! `/opds/*` and `/files/*` sit behind optional Basic auth (`server.basic_auth_*`)
//! and are therefore `private, no-store` on every response, 401s and 404s
//! included: the site sits behind a CDN whose cache-everything rule would
//! otherwise turn an authenticated download into a public one (§3.12).
//!
//! The EPUB article footer (§3.10) mints its three verdict links with the very same
//! [`rating_url`] this module verifies with — both re-export [`crate::auth`],
@@ -44,6 +47,7 @@ use tower_http::trace::TraceLayer;
use crate::config::Config;
use crate::db::Db;
use crate::types::{ArticleId, RatingEvent, Vote};
use crate::web::public::{PRIVATE_CACHE, PUBLIC_CACHE};
/// Characters of the hex HMAC kept in rating links (§3.9).
pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN;
@@ -300,9 +304,13 @@ async fn handle_issues_json(State(state): State<AppState>) -> Response {
})
.collect();
match serde_json::to_string_pretty(&issues) {
// Only publishing changes this, and publishing purges the edge (§3.12).
Ok(body) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
[
(header::CONTENT_TYPE, "application/json"),
(header::CACHE_CONTROL, PUBLIC_CACHE),
],
body,
)
.into_response(),
@@ -406,7 +414,7 @@ async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Respo
StatusCode::OK,
[
(header::CONTENT_TYPE, OPDS_CONTENT_TYPE),
(header::CACHE_CONTROL, "no-cache"),
(header::CACHE_CONTROL, PRIVATE_CACHE),
],
feed,
)
@@ -415,6 +423,7 @@ async fn handle_opds(State(state): State<AppState>, headers: HeaderMap) -> Respo
tracing::error!(error = %e, "could not build the OPDS feed");
(
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CACHE_CONTROL, PRIVATE_CACHE)],
"could not build the feed",
)
.into_response()
@@ -465,7 +474,12 @@ async fn serve_file(
}
let Some(path) = safe_join(dir, name) else {
tracing::warn!(name, "rejected an unsafe file name");
return (StatusCode::BAD_REQUEST, "bad file name").into_response();
return (
StatusCode::BAD_REQUEST,
[(header::CACHE_CONTROL, PRIVATE_CACHE)],
"bad file name",
)
.into_response();
};
// An XTCH issue is a pre-rendered page bitmap per page — ~100 MB for a full
// day. Stream it rather than buffering the whole file per request (§3.11).
@@ -476,7 +490,12 @@ async fn serve_file(
}
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "file not found");
return (StatusCode::NOT_FOUND, "not found").into_response();
return (
StatusCode::NOT_FOUND,
[(header::CACHE_CONTROL, PRIVATE_CACHE)],
"not found",
)
.into_response();
}
};
// CrossPoint dispatches on the saved file's extension, not on this header,
@@ -488,6 +507,12 @@ async fn serve_file(
};
let mut headers = HeaderMap::new();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
// Downloads are cookie- or Basic-auth gated: a shared cache must never hold
// one, whatever cache rule the CDN in front of us is configured with (§3.12).
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(PRIVATE_CACHE),
);
if let Ok(value) = HeaderValue::from_str(&format!(
"attachment; filename=\"{}\"",
name.replace('"', "")
@@ -563,7 +588,13 @@ fn check_basic_auth(config: &Config, headers: &HeaderMap) -> Option<Response> {
Some(
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, challenge)],
[
(header::WWW_AUTHENTICATE, challenge),
(
header::CACHE_CONTROL,
HeaderValue::from_static(PRIVATE_CACHE),
),
],
"authentication required",
)
.into_response(),