//! Content-addressed serving for the browser widget bundle. //! //! The widget is not one file. `captcha-widget.js` fetches //! `captcha-worker.js` at runtime, which in turn pulls //! `vendor/argon2.umd.min.js`, and the stylesheet is a fourth request. Those //! are four independent browser cache entries that all have to agree with //! each other *and* with the server's wire format — the worker's //! `solveArgon2id` has to match `compute_argon2id`, and the widget has to //! understand whatever `algorithm` the puzzle response names. //! //! Served from plain unversioned paths with no `Cache-Control`, browsers //! apply heuristic freshness, so a visitor could hold a new widget against a //! stale worker for an unbounded window after any deploy that touched the PoW //! contract. Switching the default algorithm to Argon2id is exactly that //! shape of change: a cached SHA-256-only worker cannot solve the puzzle it //! gets handed, and no server-side rollback reaches it. //! //! The fix is the shape Turnstile and hCaptcha use. One *mutable* entry point //! at a stable URL integrators embed forever (`/v1/widget.js`, short TTL), //! pointing at an *immutable* directory keyed by a hash of the asset contents //! (`/assets//…`, cached for a year). Any change to any asset changes //! the hash, so the bundle moves atomically and a half-updated combination is //! not representable. A build that changes no asset keeps the hash, so //! redeploys don't needlessly evict warm caches — which a git-SHA stamp would //! do on every commit, and which `.dockerignore` excluding `/.git` would make //! awkward to compute in the image anyway. use std::path::Path; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use sha2::{Digest, Sha256}; /// Files whose contents determine the bundle hash. /// /// Every asset the widget pulls at runtime belongs here: if a file can change /// the client's behaviour but is not hashed, the version stops being a /// truthful description of the bundle and stale-mixing comes back. The /// operator-facing pages under `static/` (`admin.html`, `testsite.html`, the /// info pages) are deliberately absent — nothing loads them as part of a /// widget, they keep being served from `/static/`, and including them would /// churn the hash for edits that cannot affect an embed. const HASHED_ASSETS: [&str; 4] = [ "captcha-widget.js", "captcha-worker.js", "captcha-widget.css", "vendor/argon2.umd.min.js", ]; /// Token in `captcha-widget.js` that the entry-point route rewrites to the /// hashed asset directory. /// /// The same file is also served verbatim from `/static/` (the legacy embed /// path) and from inside `/assets//`, where no substitution happens. /// The widget therefore treats an unsubstituted placeholder as "resolve my /// siblings relative to my own URL" — see `resolveAssetBase` in the widget. /// That keeps one source file correct on all three paths instead of needing a /// build step to emit variants. const ASSET_BASE_PLACEHOLDER: &str = "__BOLLWARK_ASSET_BASE__"; /// URL prefix under which the immutable, content-hashed bundle is mounted. pub const ASSET_MOUNT: &str = "/assets"; /// `Cache-Control` for the mutable entry point. /// /// Short rather than `no-store`: the entry point is on the critical path of /// every embed, so it wants to be cacheable, but five minutes bounds how long /// a bad deploy can keep reaching visitors. This is the only asset URL whose /// contents ever change, which is what makes the year-long TTL below safe. const ENTRY_CACHE_CONTROL: &str = "public, max-age=300"; /// `Cache-Control` for everything under `/assets//`. /// /// Safe at a year precisely because the path contains the content hash: a /// changed file is a different URL, never a stale hit. pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; /// `Cache-Control` for the legacy unversioned `/static/` tree. /// /// Same bound as the entry point, and for the same reason: nothing under /// `/static/` is content-addressed, so this is the longest a stale copy can /// outlive a deploy. Before it existed the answer was "however long the /// browser's heuristic freshness decides", which is what made a widget/worker /// version skew unbounded. pub const LEGACY_CACHE_CONTROL: &str = ENTRY_CACHE_CONTROL; /// The widget entry point, resolved once at router construction. pub struct WidgetBundle { /// Short content hash over [`HASHED_ASSETS`], used as the immutable path /// segment. pub version: String, /// `captcha-widget.js` with [`ASSET_BASE_PLACEHOLDER`] substituted for /// this bundle's asset directory. source: String, } impl WidgetBundle { /// Read and hash the bundle out of `static_dir`. /// /// Returns `None` if any hashed asset is missing or unreadable, which the /// caller treats as "don't mount the versioned routes at all". Degrading /// to no entry point is better than serving one that points at an asset /// directory we could not verify; the legacy `/static/` paths keep /// working either way, so a partial `STATIC_DIR` still yields a /// functioning (if unversioned) widget rather than a boot failure. pub fn load(static_dir: &str) -> Option { let dir = Path::new(static_dir); let mut hasher = Sha256::new(); for name in HASHED_ASSETS { let path = dir.join(name); let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) => { tracing::warn!( asset = name, path = %path.display(), error = %e, "widget asset unreadable; serving unversioned /static assets only" ); return None; } }; // Length-prefix each entry so that two different splits of the // same total byte stream can't collide onto one hash. hasher.update(name.as_bytes()); hasher.update((bytes.len() as u64).to_le_bytes()); hasher.update(&bytes); } // 8 bytes of SHA-256. This is a cache key, not a security boundary — // it only has to make an accidental collision between two builds // implausible, and it keeps the URL short enough to read in a log. let version = hex::encode(&hasher.finalize()[..8]); let raw = std::fs::read_to_string(dir.join("captcha-widget.js")).ok()?; let source = raw.replace(ASSET_BASE_PLACEHOLDER, &format!("{ASSET_MOUNT}/{version}")); Some(Self { version, source }) } /// Path this bundle's immutable assets are mounted at. pub fn mount_path(&self) -> String { format!("{ASSET_MOUNT}/{}", self.version) } /// Serve the entry point. pub fn respond(&self) -> Response { ( [ (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, ENTRY_CACHE_CONTROL), ], self.source.clone(), ) .into_response() } } /// Fallback when no bundle could be loaded, so the route still answers /// predictably instead of 404-ing as if the URL were wrong. pub async fn unavailable() -> Response { ( StatusCode::SERVICE_UNAVAILABLE, "widget assets unavailable; check STATIC_DIR", ) .into_response() } #[cfg(test)] mod tests { use super::*; /// The repo's own `static/` is the bundle every deployment ships, so a /// missing or renamed asset should fail here rather than at boot. #[test] fn loads_the_repo_bundle() { let bundle = WidgetBundle::load("static").expect("static/ bundle should load"); assert_eq!(bundle.version.len(), 16, "8 bytes hex-encoded"); assert!(bundle.version.chars().all(|c| c.is_ascii_hexdigit())); } /// The whole scheme rests on the entry point no longer containing the /// placeholder: if substitution silently stopped matching, the widget /// would fall back to unversioned `/static` paths and the skew window /// would reopen without anything failing loudly. #[test] fn entry_point_has_the_asset_base_substituted() { let bundle = WidgetBundle::load("static").expect("static/ bundle should load"); assert!( !bundle.source.contains(ASSET_BASE_PLACEHOLDER), "placeholder must be rewritten in the served entry point" ); assert!(bundle.source.contains(&bundle.mount_path())); } /// Content-addressing is only useful if it is stable across runs — an /// unstable hash would evict every cache on every restart. #[test] fn version_is_deterministic() { let a = WidgetBundle::load("static").expect("bundle"); let b = WidgetBundle::load("static").expect("bundle"); assert_eq!(a.version, b.version); } #[test] fn missing_static_dir_yields_no_bundle() { assert!(WidgetBundle::load("static/does-not-exist").is_none()); } }