Skip to main content

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)))`.
73/// Default ceiling on a cacheable response body (gaps4 #23). A response larger
74/// than this is served normally but NOT stored — a page cache exists to make
75/// small hot pages cheap, and letting a multi-megabyte response in bloats the
76/// store (or a shared Redis), evicts many useful entries for one, and risks
77/// memory pressure. 1 MiB comfortably covers HTML pages and JSON list
78/// responses; raise it with `CachePageLayer::max_object_bytes` if you cache
79/// something legitimately larger.
80pub const DEFAULT_MAX_OBJECT_BYTES: usize = 1024 * 1024;
81
82pub fn cache_page(ttl: Duration) -> CachePageLayer {
83    CachePageLayer {
84        ttl,
85        cache: None,
86        max_object_bytes: DEFAULT_MAX_OBJECT_BYTES,
87    }
88}
89
90// ── Layer ────────────────────────────────────────────────────────────────────
91
92/// [`tower::Layer`] returned by [`cache_page`]. Wraps the inner service
93/// with [`CachePageService`].
94#[derive(Clone)]
95pub struct CachePageLayer {
96    ttl: Duration,
97    // An explicit cache can be injected for testing; production code
98    // reads the ambient handle via `crate::ambient()`.
99    cache: Option<Arc<Cache>>,
100    /// Responses larger than this are not stored (gaps4 #23).
101    max_object_bytes: usize,
102}
103
104impl CachePageLayer {
105    /// Override the cache handle used by this layer. Useful in tests
106    /// where the ambient cache isn't initialised.
107    pub fn with_cache(mut self, cache: Cache) -> Self {
108        self.cache = Some(Arc::new(cache));
109        self
110    }
111
112    /// Cap the size of a cacheable response body. A larger response is served
113    /// normally but not stored. Defaults to [`DEFAULT_MAX_OBJECT_BYTES`].
114    pub fn max_object_bytes(mut self, bytes: usize) -> Self {
115        self.max_object_bytes = bytes;
116        self
117    }
118}
119
120impl<S> Layer<S> for CachePageLayer {
121    type Service = CachePageService<S>;
122
123    fn layer(&self, inner: S) -> Self::Service {
124        CachePageService {
125            inner,
126            ttl: self.ttl,
127            cache: self.cache.clone(),
128            max_object_bytes: self.max_object_bytes,
129        }
130    }
131}
132
133// ── Service ──────────────────────────────────────────────────────────────────
134
135/// [`tower::Service`] produced by [`CachePageLayer`].
136#[derive(Clone)]
137pub struct CachePageService<S> {
138    inner: S,
139    ttl: Duration,
140    cache: Option<Arc<Cache>>,
141    max_object_bytes: usize,
142}
143
144impl<S> Service<Request<Body>> for CachePageService<S>
145where
146    S: Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
147    S::Future: Send + 'static,
148    S::Error: Send + 'static,
149{
150    type Response = Response<Body>;
151    type Error = S::Error;
152    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
153
154    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
155        self.inner.poll_ready(cx)
156    }
157
158    fn call(&mut self, req: Request<Body>) -> Self::Future {
159        let mut inner = self.inner.clone();
160        let ttl = self.ttl;
161        let explicit_cache = self.cache.clone();
162        let max_object_bytes = self.max_object_bytes;
163
164        Box::pin(async move {
165            // Only attempt to cache GET and HEAD
166            let method = req.method().clone();
167            if method != Method::GET && method != Method::HEAD {
168                return inner.call(req).await;
169            }
170
171            // Bypass for personalised / authenticated requests: if the incoming
172            // request carries an `umbral_session` cookie the response is user-
173            // specific and must not be served from or stored in the page cache.
174            // We match the literal cookie name "umbral_session" (the canonical
175            // name from umbral-sessions::COOKIE_NAME) without importing that crate
176            // to avoid a plugin-to-plugin dependency.
177            if request_is_personalised(&req) {
178                return inner.call(req).await;
179            }
180
181            // Build the cache key from method + Host header + full URI (path + query).
182            // Including the Host prevents multi-tenant cache-poisoning where different
183            // virtual hosts serving different content share cache entries.
184            let host = req
185                .headers()
186                .get(header::HOST)
187                .and_then(|v| v.to_str().ok())
188                .unwrap_or("")
189                .to_owned();
190            let uri = req.uri().to_string();
191            let cache_key = format!("cache:page:{}:{}:{}", method, host, uri);
192
193            // Resolve the cache to use: explicit (test injection) > ambient
194            let cache: Option<&Cache> = if let Some(ref c) = explicit_cache {
195                Some(c.as_ref())
196            } else {
197                crate::ambient()
198            };
199
200            // Cache hit — return the stored response bytes
201            if let Some(cache) = cache {
202                if let Some(stored) = cache.get_bytes_raw(&cache_key).await {
203                    if let Ok(resp) = deserialise_cached_response(stored) {
204                        return Ok(resp);
205                    }
206                    // Deserialisation failure → treat as a miss and re-run the handler
207                }
208            }
209
210            // Cache miss — call through to the handler
211            let resp = inner.call(req).await?;
212
213            // Only cache eligible responses
214            let status = resp.status();
215            if status != StatusCode::OK {
216                return Ok(resp);
217            }
218
219            let should_skip = response_bypasses_cache(&resp);
220
221            // Collect the body so we can both cache and return it.
222            // This buffers the full response in memory which is fine
223            // for HTML pages (< a few MB). Skip caching if collection
224            // fails but still return the original error to the client.
225            let (parts, body) = resp.into_parts();
226            let body_bytes = match body.collect().await {
227                Ok(collected) => collected.to_bytes(),
228                Err(e) => {
229                    // BROKEN-7: the body stream failed partway. Reusing the
230                    // success `parts` with an empty body fabricates a 200
231                    // whose `Content-Length` no longer matches the (empty)
232                    // body — that desyncs keep-alive connections and is
233                    // indistinguishable from a real empty page. Log it and
234                    // return a clean 502 instead; never cache it.
235                    tracing::error!(
236                        error = %e,
237                        "cache_page: failed to collect upstream response body; returning 502"
238                    );
239                    let mut resp = Response::new(Body::from("Bad Gateway"));
240                    *resp.status_mut() = StatusCode::BAD_GATEWAY;
241                    return Ok(resp);
242                }
243            };
244
245            // gaps4 #23: never STORE an oversized body. It is still served in
246            // full below; it just doesn't get to evict a store's worth of small
247            // hot pages (or bloat a shared Redis) for one large response.
248            let too_large = body_bytes.len() > max_object_bytes;
249            if too_large {
250                tracing::debug!(
251                    size = body_bytes.len(),
252                    cap = max_object_bytes,
253                    "cache_page: response exceeds the cacheable size cap; serving but not storing"
254                );
255            }
256            if !should_skip && !too_large {
257                if let Some(cache) = explicit_cache.as_deref().or_else(|| crate::ambient()) {
258                    let serialised = serialise_cached_response(&parts, &body_bytes);
259                    cache.set_bytes_raw(&cache_key, serialised, Some(ttl)).await;
260                }
261            }
262
263            let resp = Response::from_parts(parts, Body::from(body_bytes));
264            Ok(resp)
265        })
266    }
267}
268
269// ── Helpers ──────────────────────────────────────────────────────────────────
270
271/// Return `true` when the request carries an `umbral_session` cookie.
272///
273/// Session-cookie-bearing requests are for authenticated / personalised pages.
274/// Serving those from (or caching them into) the shared page cache would either
275/// leak one user's content to another user or serve a stale anonymous page to a
276/// logged-in user.  We bypass the cache entirely for these requests.
277///
278/// The cookie name `umbral_session` matches `umbral_sessions::COOKIE_NAME`.  We
279/// match the literal string to avoid a crate dependency from umbral-cache on
280/// umbral-sessions.
281/// Return `true` when the request is personalised and must bypass the shared
282/// page cache: it carries a session cookie OR an `Authorization` header (token /
283/// bearer auth). Caching a token-authenticated response and serving it to the
284/// next anonymous/other caller leaks one user's data (audit_2 cache #1 / H26).
285fn request_is_personalised<B>(req: &Request<B>) -> bool {
286    request_has_session_cookie(req)
287        || req.headers().contains_key(header::AUTHORIZATION)
288        // audit_2 realtime #1: a proxy-auth'd request is equally per-user;
289        // don't serve its response to the next caller.
290        || req.headers().contains_key(header::PROXY_AUTHORIZATION)
291}
292
293fn request_has_session_cookie<B>(req: &Request<B>) -> bool {
294    // Cookie header value is a semicolon-separated list of "name=value" pairs.
295    req.headers()
296        .get(header::COOKIE)
297        .and_then(|v| v.to_str().ok())
298        .map(|cookie_str| {
299            cookie_str
300                .split(';')
301                .any(|pair| pair.trim().starts_with("umbral_session="))
302        })
303        .unwrap_or(false)
304}
305
306/// Return `true` when the response should not be cached:
307/// - `Cache-Control: no-store` is present
308/// - `Set-Cookie` header is present
309fn response_bypasses_cache<B>(resp: &Response<B>) -> bool {
310    let headers = resp.headers();
311
312    // Cache-Control: no-store / private / no-cache all forbid a SHARED cache
313    // from storing + replaying the response to other users (audit_2 H26).
314    if let Some(cc) = headers.get(header::CACHE_CONTROL) {
315        if cc.to_str().unwrap_or("").split(',').any(|d| {
316            let d = d.trim();
317            d.eq_ignore_ascii_case("no-store")
318                || d.eq_ignore_ascii_case("private")
319                || d.eq_ignore_ascii_case("no-cache")
320        }) {
321            return true;
322        }
323    }
324
325    // Any Set-Cookie header means the response is personalised
326    if headers.contains_key(header::SET_COOKIE) {
327        return true;
328    }
329
330    // audit_2 realtime #1: a `Vary` on `Cookie` / `Authorization` (or `*`) means
331    // the response body depends on the caller's identity — a shared cache keyed
332    // only on the URL would replay one user's response to another. Bypass.
333    if let Some(vary) = headers.get(header::VARY) {
334        if vary.to_str().unwrap_or("").split(',').any(|field| {
335            let field = field.trim();
336            field == "*"
337                || field.eq_ignore_ascii_case("cookie")
338                || field.eq_ignore_ascii_case("authorization")
339        }) {
340            return true;
341        }
342    }
343
344    false
345}
346
347// ── Wire format for cached responses ─────────────────────────────────────────
348//
349// Stored bytes layout (length-prefixed, little-endian u32):
350//   [4 bytes: header_count N]
351//   for each header:
352//     [4 bytes: name_len][name bytes][4 bytes: value_len][value bytes]
353//   [body bytes]
354//
355// This is a simple custom format; serde/JSON would add overhead for the
356// binary body. Status code is always 200 (the only value we cache) so
357// it's not stored.
358
359fn serialise_cached_response(parts: &http::response::Parts, body: &Bytes) -> Vec<u8> {
360    let mut out: Vec<u8> = Vec::new();
361
362    let header_count = parts.headers.len() as u32;
363    out.extend_from_slice(&header_count.to_le_bytes());
364
365    for (name, value) in &parts.headers {
366        let name_bytes = name.as_str().as_bytes();
367        let value_bytes = value.as_bytes();
368        out.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
369        out.extend_from_slice(name_bytes);
370        out.extend_from_slice(&(value_bytes.len() as u32).to_le_bytes());
371        out.extend_from_slice(value_bytes);
372    }
373
374    out.extend_from_slice(body);
375    out
376}
377
378fn deserialise_cached_response(data: Vec<u8>) -> Result<Response<Body>, ()> {
379    if data.len() < 4 {
380        return Err(());
381    }
382    let mut pos = 0;
383
384    let header_count = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
385    pos += 4;
386
387    let mut builder = Response::builder().status(StatusCode::OK);
388
389    for _ in 0..header_count {
390        if pos + 4 > data.len() {
391            return Err(());
392        }
393        let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
394        pos += 4;
395        if pos + name_len > data.len() {
396            return Err(());
397        }
398        let name = std::str::from_utf8(&data[pos..pos + name_len]).map_err(|_| ())?;
399        pos += name_len;
400
401        if pos + 4 > data.len() {
402            return Err(());
403        }
404        let val_len = u32::from_le_bytes(data[pos..pos + 4].try_into().map_err(|_| ())?) as usize;
405        pos += 4;
406        if pos + val_len > data.len() {
407            return Err(());
408        }
409        let value = &data[pos..pos + val_len];
410        pos += val_len;
411
412        builder = builder.header(name, value);
413    }
414
415    let body_bytes = Bytes::copy_from_slice(&data[pos..]);
416    builder.body(Body::from(body_bytes)).map_err(|_| ())
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn authorization_header_makes_request_personalised() {
425        // Token/bearer-authenticated requests carry no session cookie but are
426        // still per-user — they must bypass the shared cache (audit_2 H26).
427        let req = Request::builder()
428            .header(header::AUTHORIZATION, "Bearer abc.def.ghi")
429            .body(Body::empty())
430            .unwrap();
431        assert!(
432            request_is_personalised(&req),
433            "an Authorization-bearing request must bypass the shared page cache"
434        );
435    }
436
437    #[test]
438    fn private_and_no_cache_responses_bypass_cache() {
439        for directive in ["private", "no-cache", "no-store"] {
440            let resp = Response::builder()
441                .header(header::CACHE_CONTROL, directive)
442                .body(Body::empty())
443                .unwrap();
444            assert!(
445                response_bypasses_cache(&resp),
446                "`Cache-Control: {directive}` must not be stored in the shared cache"
447            );
448        }
449    }
450
451    #[test]
452    fn plain_get_is_cacheable() {
453        let req = Request::builder().body(Body::empty()).unwrap();
454        assert!(!request_is_personalised(&req));
455        let resp = Response::builder().body(Body::empty()).unwrap();
456        assert!(!response_bypasses_cache(&resp));
457    }
458
459    /// audit_2 realtime #1 — a proxy-authenticated request is per-user too.
460    #[test]
461    fn proxy_authorization_header_makes_request_personalised() {
462        let req = Request::builder()
463            .header(header::PROXY_AUTHORIZATION, "Basic dXNlcjpwYXNz")
464            .body(Body::empty())
465            .unwrap();
466        assert!(
467            request_is_personalised(&req),
468            "a Proxy-Authorization request must bypass the shared page cache"
469        );
470    }
471
472    /// audit_2 realtime #1 — a `Vary` on an identity header (or `*`) means the
473    /// body depends on the caller, so a URL-keyed shared cache must not store it.
474    #[test]
475    fn vary_on_identity_headers_bypasses_cache() {
476        for vary in ["Cookie", "Authorization", "*", "Accept-Encoding, Cookie"] {
477            let resp = Response::builder()
478                .header(header::VARY, vary)
479                .body(Body::empty())
480                .unwrap();
481            assert!(
482                response_bypasses_cache(&resp),
483                "`Vary: {vary}` must bypass the shared cache"
484            );
485        }
486        // A benign Vary (only on encoding) stays cacheable.
487        let resp = Response::builder()
488            .header(header::VARY, "Accept-Encoding")
489            .body(Body::empty())
490            .unwrap();
491        assert!(
492            !response_bypasses_cache(&resp),
493            "`Vary: Accept-Encoding` alone is fine to cache"
494        );
495    }
496}