umbral_cache/cache_page.rs
1//! View-level caching middleware.
2//!
3//! Wrap a [`Router`] subtree with [`cache_page`] and every eligible
4//! `GET` or `HEAD` response for that subtree is cached for the
5//! configured TTL. Subsequent requests for the same URI + query string
6//! get the cached response without hitting the handler.
7//!
8//! ```ignore
9//! use umbral_cache::cache_page;
10//! use std::time::Duration;
11//!
12//! let public = Router::new()
13//! .route("/", get(home))
14//! .route("/about", get(about))
15//! .layer(cache_page(Duration::from_secs(60)));
16//! ```
17//!
18//! ## Cache key
19//!
20//! `cache:page:GET:<host>:/path?query` — method + Host header + full URI
21//! including query string. Fragments are stripped by the browser and never
22//! reach the server. Including the Host header prevents multi-tenant
23//! cache-poisoning where tenant A's cached page would otherwise be served to
24//! requests arriving on a different Host.
25//!
26//! ## What gets cached
27//!
28//! Only `GET` and `HEAD` responses with HTTP status **200** are stored.
29//! The following bypass caching:
30//! - Any method other than `GET` / `HEAD` (POST, PUT, PATCH, DELETE).
31//! - Status code other than 200.
32//! - Response carries `Cache-Control: no-store`.
33//! - Response carries a `Set-Cookie` header (the body may be personalised).
34//! - Request carries an `umbral_session` cookie — personalised / logged-in
35//! requests are neither served from nor written to the page cache, keeping
36//! the cache to the safe anonymous-only subset.
37//!
38//! ## Ambient cache dependency
39//!
40//! [`cache_page`] reads the ambient [`super::Cache`] via [`super::ambient()`].
41//! If the ambient cache has not been initialised (i.e. [`super::CachePlugin::init`]
42//! has not been called), cache misses and stores are silently skipped —
43//! the handler always fires normally. This is intentional: a misconfigured
44//! cache degrades gracefully rather than returning 500s.
45//!
46//! ## Deferred
47//!
48//! - ETag / 304 conditional caching — the current implementation always
49//! serves the full cached body. A future iteration will store and compare
50//! ETags to emit 304 Not Modified, saving bandwidth.
51//! - Vary-header awareness (`Vary: Accept-Language`, etc.).
52//! - Per-route cache key prefix customisation.
53
54use std::sync::Arc;
55use std::task::{Context, Poll};
56use std::time::Duration;
57
58use axum::body::Body;
59use axum::http::{Method, Request, Response, StatusCode, header};
60use bytes::Bytes;
61use futures_util::future::BoxFuture;
62use http_body_util::BodyExt;
63use tower::{Layer, Service};
64
65use crate::Cache;
66
67// ── Public constructor ───────────────────────────────────────────────────────
68
69/// Return a [`CachePageLayer`] that caches eligible `GET`/`HEAD` responses
70/// for `ttl`.
71///
72/// Mount it with `Router::layer(cache_page(Duration::from_secs(60)))`.
73pub fn cache_page(ttl: Duration) -> CachePageLayer {
74 CachePageLayer { ttl, cache: None }
75}
76
77// ── Layer ────────────────────────────────────────────────────────────────────
78
79/// [`tower::Layer`] returned by [`cache_page`]. Wraps the inner service
80/// with [`CachePageService`].
81#[derive(Clone)]
82pub struct CachePageLayer {
83 ttl: Duration,
84 // An explicit cache can be injected for testing; production code
85 // reads the ambient handle via `crate::ambient()`.
86 cache: Option<Arc<Cache>>,
87}
88
89impl CachePageLayer {
90 /// Override the cache handle used by this layer. Useful in tests
91 /// where the ambient cache isn't initialised.
92 pub fn with_cache(mut self, cache: Cache) -> Self {
93 self.cache = Some(Arc::new(cache));
94 self
95 }
96}
97
98impl<S> Layer<S> for CachePageLayer {
99 type Service = CachePageService<S>;
100
101 fn layer(&self, inner: S) -> Self::Service {
102 CachePageService {
103 inner,
104 ttl: self.ttl,
105 cache: self.cache.clone(),
106 }
107 }
108}
109
110// ── Service ──────────────────────────────────────────────────────────────────
111
112/// [`tower::Service`] produced by [`CachePageLayer`].
113#[derive(Clone)]
114pub struct CachePageService<S> {
115 inner: S,
116 ttl: Duration,
117 cache: Option<Arc<Cache>>,
118}
119
120impl<S> Service<Request<Body>> for CachePageService<S>
121where
122 S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
123 S::Future: Send + 'static,
124 S::Error: Send + 'static,
125{
126 type Response = Response<Body>;
127 type Error = S::Error;
128 type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
129
130 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
131 self.inner.poll_ready(cx)
132 }
133
134 fn call(&mut self, req: Request<Body>) -> Self::Future {
135 let mut inner = self.inner.clone();
136 let ttl = self.ttl;
137 let explicit_cache = self.cache.clone();
138
139 Box::pin(async move {
140 // Only attempt to cache GET and HEAD
141 let method = req.method().clone();
142 if method != Method::GET && method != Method::HEAD {
143 return inner.call(req).await;
144 }
145
146 // Bypass for personalised / authenticated requests: if the incoming
147 // request carries an `umbral_session` cookie the response is user-
148 // specific and must not be served from or stored in the page cache.
149 // We match the literal cookie name "umbral_session" (the canonical
150 // name from umbral-sessions::COOKIE_NAME) without importing that crate
151 // to avoid a plugin-to-plugin dependency.
152 if request_is_personalised(&req) {
153 return inner.call(req).await;
154 }
155
156 // Build the cache key from method + Host header + full URI (path + query).
157 // Including the Host prevents multi-tenant cache-poisoning where different
158 // virtual hosts serving different content share cache entries.
159 let host = req
160 .headers()
161 .get(header::HOST)
162 .and_then(|v| v.to_str().ok())
163 .unwrap_or("")
164 .to_owned();
165 let uri = req.uri().to_string();
166 let cache_key = format!("cache:page:{}:{}:{}", method, host, uri);
167
168 // Resolve the cache to use: explicit (test injection) > ambient
169 let cache: Option<&Cache> = if let Some(ref c) = explicit_cache {
170 Some(c.as_ref())
171 } else {
172 crate::ambient()
173 };
174
175 // Cache hit — return the stored response bytes
176 if let Some(cache) = cache {
177 if let Some(stored) = cache.get_bytes_raw(&cache_key).await {
178 if let Ok(resp) = deserialise_cached_response(stored) {
179 return Ok(resp);
180 }
181 // Deserialisation failure → treat as a miss and re-run the handler
182 }
183 }
184
185 // Cache miss — call through to the handler
186 let resp = inner.call(req).await?;
187
188 // Only cache eligible responses
189 let status = resp.status();
190 if status != StatusCode::OK {
191 return Ok(resp);
192 }
193
194 let should_skip = response_bypasses_cache(&resp);
195
196 // Collect the body so we can both cache and return it.
197 // This buffers the full response in memory which is fine
198 // for HTML pages (< a few MB). Skip caching if collection
199 // fails but still return the original error to the client.
200 let (parts, body) = resp.into_parts();
201 let body_bytes = match body.collect().await {
202 Ok(collected) => collected.to_bytes(),
203 Err(e) => {
204 // BROKEN-7: the body stream failed partway. Reusing the
205 // success `parts` with an empty body fabricates a 200
206 // whose `Content-Length` no longer matches the (empty)
207 // body — that desyncs keep-alive connections and is
208 // indistinguishable from a real empty page. Log it and
209 // return a clean 502 instead; never cache it.
210 tracing::error!(
211 error = %e,
212 "cache_page: failed to collect upstream response body; returning 502"
213 );
214 let mut resp = Response::new(Body::from("Bad Gateway"));
215 *resp.status_mut() = StatusCode::BAD_GATEWAY;
216 return Ok(resp);
217 }
218 };
219
220 if !should_skip {
221 if let Some(cache) = explicit_cache.as_deref().or_else(|| crate::ambient()) {
222 let serialised = serialise_cached_response(&parts, &body_bytes);
223 cache.set_bytes_raw(&cache_key, serialised, Some(ttl)).await;
224 }
225 }
226
227 let resp = Response::from_parts(parts, Body::from(body_bytes));
228 Ok(resp)
229 })
230 }
231}
232
233// ── Helpers ──────────────────────────────────────────────────────────────────
234
235/// Return `true` when the request carries an `umbral_session` cookie.
236///
237/// Session-cookie-bearing requests are for authenticated / personalised pages.
238/// Serving those from (or caching them into) the shared page cache would either
239/// leak one user's content to another user or serve a stale anonymous page to a
240/// logged-in user. We bypass the cache entirely for these requests.
241///
242/// The cookie name `umbral_session` matches `umbral_sessions::COOKIE_NAME`. We
243/// match the literal string to avoid a crate dependency from umbral-cache on
244/// umbral-sessions.
245/// Return `true` when the request is personalised and must bypass the shared
246/// page cache: it carries a session cookie OR an `Authorization` header (token /
247/// bearer auth). Caching a token-authenticated response and serving it to the
248/// next anonymous/other caller leaks one user's data (audit_2 cache #1 / H26).
249fn request_is_personalised<B>(req: &Request<B>) -> bool {
250 request_has_session_cookie(req)
251 || req.headers().contains_key(header::AUTHORIZATION)
252 // audit_2 realtime #1: a proxy-auth'd request is equally per-user;
253 // don't serve its response to the next caller.
254 || req.headers().contains_key(header::PROXY_AUTHORIZATION)
255}
256
257fn request_has_session_cookie<B>(req: &Request<B>) -> bool {
258 // Cookie header value is a semicolon-separated list of "name=value" pairs.
259 req.headers()
260 .get(header::COOKIE)
261 .and_then(|v| v.to_str().ok())
262 .map(|cookie_str| {
263 cookie_str
264 .split(';')
265 .any(|pair| pair.trim().starts_with("umbral_session="))
266 })
267 .unwrap_or(false)
268}
269
270/// Return `true` when the response should not be cached:
271/// - `Cache-Control: no-store` is present
272/// - `Set-Cookie` header is present
273fn response_bypasses_cache<B>(resp: &Response<B>) -> bool {
274 let headers = resp.headers();
275
276 // Cache-Control: no-store / private / no-cache all forbid a SHARED cache
277 // from storing + replaying the response to other users (audit_2 H26).
278 if let Some(cc) = headers.get(header::CACHE_CONTROL) {
279 if cc.to_str().unwrap_or("").split(',').any(|d| {
280 let d = d.trim();
281 d.eq_ignore_ascii_case("no-store")
282 || d.eq_ignore_ascii_case("private")
283 || d.eq_ignore_ascii_case("no-cache")
284 }) {
285 return true;
286 }
287 }
288
289 // Any Set-Cookie header means the response is personalised
290 if headers.contains_key(header::SET_COOKIE) {
291 return true;
292 }
293
294 // audit_2 realtime #1: a `Vary` on `Cookie` / `Authorization` (or `*`) means
295 // the response body depends on the caller's identity — a shared cache keyed
296 // only on the URL would replay one user's response to another. Bypass.
297 if let Some(vary) = headers.get(header::VARY) {
298 if vary.to_str().unwrap_or("").split(',').any(|field| {
299 let field = field.trim();
300 field == "*"
301 || field.eq_ignore_ascii_case("cookie")
302 || field.eq_ignore_ascii_case("authorization")
303 }) {
304 return true;
305 }
306 }
307
308 false
309}
310
311// ── Wire format for cached responses ─────────────────────────────────────────
312//
313// Stored bytes layout (length-prefixed, little-endian u32):
314// [4 bytes: header_count N]
315// for each header:
316// [4 bytes: name_len][name bytes][4 bytes: value_len][value bytes]
317// [body bytes]
318//
319// This is a simple custom format; serde/JSON would add overhead for the
320// binary body. Status code is always 200 (the only value we cache) so
321// it's not stored.
322
323fn serialise_cached_response(parts: &http::response::Parts, body: &Bytes) -> Vec<u8> {
324 let mut out: Vec<u8> = Vec::new();
325
326 let header_count = parts.headers.len() as u32;
327 out.extend_from_slice(&header_count.to_le_bytes());
328
329 for (name, value) in &parts.headers {
330 let name_bytes = name.as_str().as_bytes();
331 let value_bytes = value.as_bytes();
332 out.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
333 out.extend_from_slice(name_bytes);
334 out.extend_from_slice(&(value_bytes.len() as u32).to_le_bytes());
335 out.extend_from_slice(value_bytes);
336 }
337
338 out.extend_from_slice(body);
339 out
340}
341
342fn deserialise_cached_response(data: Vec<u8>) -> Result<Response<Body>, ()> {
343 if data.len() < 4 {
344 return Err(());
345 }
346 let mut pos = 0;
347
348 let header_count = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
349 pos += 4;
350
351 let mut builder = Response::builder().status(StatusCode::OK);
352
353 for _ in 0..header_count {
354 if pos + 4 > data.len() {
355 return Err(());
356 }
357 let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
358 pos += 4;
359 if pos + name_len > data.len() {
360 return Err(());
361 }
362 let name = std::str::from_utf8(&data[pos..pos + name_len]).map_err(|_| ())?;
363 pos += name_len;
364
365 if pos + 4 > data.len() {
366 return Err(());
367 }
368 let val_len = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
369 pos += 4;
370 if pos + val_len > data.len() {
371 return Err(());
372 }
373 let value = &data[pos..pos + val_len];
374 pos += val_len;
375
376 builder = builder.header(name, value);
377 }
378
379 let body_bytes = Bytes::copy_from_slice(&data[pos..]);
380 builder.body(Body::from(body_bytes)).map_err(|_| ())
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn authorization_header_makes_request_personalised() {
389 // Token/bearer-authenticated requests carry no session cookie but are
390 // still per-user — they must bypass the shared cache (audit_2 H26).
391 let req = Request::builder()
392 .header(header::AUTHORIZATION, "Bearer abc.def.ghi")
393 .body(Body::empty())
394 .unwrap();
395 assert!(
396 request_is_personalised(&req),
397 "an Authorization-bearing request must bypass the shared page cache"
398 );
399 }
400
401 #[test]
402 fn private_and_no_cache_responses_bypass_cache() {
403 for directive in ["private", "no-cache", "no-store"] {
404 let resp = Response::builder()
405 .header(header::CACHE_CONTROL, directive)
406 .body(Body::empty())
407 .unwrap();
408 assert!(
409 response_bypasses_cache(&resp),
410 "`Cache-Control: {directive}` must not be stored in the shared cache"
411 );
412 }
413 }
414
415 #[test]
416 fn plain_get_is_cacheable() {
417 let req = Request::builder().body(Body::empty()).unwrap();
418 assert!(!request_is_personalised(&req));
419 let resp = Response::builder().body(Body::empty()).unwrap();
420 assert!(!response_bypasses_cache(&resp));
421 }
422
423 /// audit_2 realtime #1 — a proxy-authenticated request is per-user too.
424 #[test]
425 fn proxy_authorization_header_makes_request_personalised() {
426 let req = Request::builder()
427 .header(header::PROXY_AUTHORIZATION, "Basic dXNlcjpwYXNz")
428 .body(Body::empty())
429 .unwrap();
430 assert!(
431 request_is_personalised(&req),
432 "a Proxy-Authorization request must bypass the shared page cache"
433 );
434 }
435
436 /// audit_2 realtime #1 — a `Vary` on an identity header (or `*`) means the
437 /// body depends on the caller, so a URL-keyed shared cache must not store it.
438 #[test]
439 fn vary_on_identity_headers_bypasses_cache() {
440 for vary in ["Cookie", "Authorization", "*", "Accept-Encoding, Cookie"] {
441 let resp = Response::builder()
442 .header(header::VARY, vary)
443 .body(Body::empty())
444 .unwrap();
445 assert!(
446 response_bypasses_cache(&resp),
447 "`Vary: {vary}` must bypass the shared cache"
448 );
449 }
450 // A benign Vary (only on encoding) stays cacheable.
451 let resp = Response::builder()
452 .header(header::VARY, "Accept-Encoding")
453 .body(Body::empty())
454 .unwrap();
455 assert!(
456 !response_bypasses_cache(&resp),
457 "`Vary: Accept-Encoding` alone is fine to cache"
458 );
459 }
460}