//! HTTP request/response helpers: the shared outbound client, client-IP //! extraction, HTMX detection, ETag conditional responses, and toast headers. use axum::http::HeaderValue; use axum::http::StatusCode; use axum::http::header::HeaderMap; use axum::response::{IntoResponse, Response}; /// Shared outbound HTTP client. `reqwest::Client` is internally `Arc`-wrapped /// and pools connections, so it is meant to be built once and reused; a /// per-request `reqwest::Client::new()` throws away the connection pool and /// re-runs TLS setup every call. Carries a 10s default timeout as a backstop; /// call sites may still set a tighter per-request `.timeout()`, which wins. /// (Clients that need custom config, like Postmark and the PoM probe, keep their own.) pub static HTTP_CLIENT: std::sync::LazyLock = std::sync::LazyLock::new(|| { reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .expect("build shared reqwest client") }); /// Extract the client IP from request headers. /// /// Honors `CF-Connecting-IP` only, and that header is trustworthy on every /// public path: the makenot.work blocks enforce Cloudflare mTLS (only /// Cloudflare reaches the origin, and it sets the header), and the custom-domain /// `:443` block overwrites `CF-Connecting-IP` with the real TCP peer + strips /// `X-Forwarded-For` before proxying (see `deploy/Caddyfile`). `X-Forwarded-For` /// is intentionally never consulted: there is no trusted-proxy allowlist, so a /// request reaching the app with a client-set XFF could spoof the IP and evade /// sandbox caps / poison audit logs / forge "new device" notifications. /// /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare /// was bypassed or misconfigured, keying rate-limits on `None` then collapses /// every requester into the same bucket. After 100 cumulative missing-header /// requests, emit a one-shot WARN so the operator notices before any limit /// surface degrades silently. Dev hits this immediately, which is fine: it's /// a real signal the deployment isn't behind Cloudflare. pub fn extract_client_ip(headers: &HeaderMap) -> Option { let ip = headers .get("cf-connecting-ip") .and_then(|v| v.to_str().ok()) .and_then(|s| s.split(',').next()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); if ip.is_none() { static MISSING_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); let n = MISSING_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; if n >= 100 { WARNED.get_or_init(|| { tracing::warn!( missing_count = n, "cf-connecting-ip header missing on 100+ requests — rate-limits and \ sandbox caps will key on None. Verify Cloudflare proxy is in front \ of the origin (dev/test environments hit this naturally and can ignore)." ); }); } } ip } /// Derive a stable i64 key from an IP string for use with PostgreSQL advisory locks. /// /// Uses SHA-256 rather than `std::collections::hash_map::DefaultHasher`, /// `DefaultHasher`'s algorithm is implementation-defined and can shift between /// Rust releases, which would silently change the lock keyspace on rebuild /// and let two concurrent operations from the same IP grab different locks /// across a deploy boundary. SHA-256 is stable forever. pub fn ip_advisory_lock_key(ip: &str) -> i64 { use sha2::{Digest, Sha256}; let mut h = Sha256::new(); h.update(b"sandbox_ip_cap\0"); h.update(ip.as_bytes()); let digest = h.finalize(); // Take the first 8 bytes as an i64 (big-endian). The full SHA-256 output // is 32 bytes; the leading 8 are uniformly random over the input space. i64::from_be_bytes(digest[..8].try_into().expect("sha256 yields >= 8 bytes")) } /// Check whether the incoming request was made by HTMX. pub fn is_htmx_request(headers: &HeaderMap) -> bool { headers.get("HX-Request").is_some() } /// Check the client's `If-None-Match` header against a cache generation. /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh. pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option { let etag = format!("\"g{generation}\""); if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH) && if_none_match.as_bytes() == etag.as_bytes() { return Some( ( StatusCode::NOT_MODIFIED, [( axum::http::header::ETAG, HeaderValue::try_from(&etag) .unwrap_or_else(|_| HeaderValue::from_static("invalid")), )], ) .into_response(), ); } None } /// Wrap a rendered response with ETag and Cache-Control headers. /// `no-cache` tells the browser to store the response but revalidate on each use. pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response { let etag = format!("\"g{generation}\""); ( [ (axum::http::header::ETAG, etag), ( axum::http::header::CACHE_CONTROL, "private, no-cache".to_string(), ), ], body, ) .into_response() } /// Build an HTMX response that shows a toast notification with an empty body. /// /// Use for delete/action endpoints that only need to signal success via toast. pub fn htmx_toast_response( message: &str, toast_type: &str, ) -> ( [(&'static str, HeaderValue); 1], axum::response::Html, ) { ( [("HX-Trigger", hx_toast(message, toast_type))], axum::response::Html(String::new()), ) } pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue { // Strip control characters (C0, DEL/0x7F, C1) before encoding. serde_json // escapes the < 0x20 controls, but DEL and the C1 range pass through raw and // make `HeaderValue::from_str` reject the whole header, which silently drops // the toast. Stripping them keeps the JSON header-safe; the fallback below // stays as defense-in-depth. let message: String = message.chars().filter(|c| !c.is_control()).collect(); let toast_type: String = toast_type.chars().filter(|c| !c.is_control()).collect(); let json = serde_json::json!({ "showToast": { "message": message, "type": toast_type } }) .to_string(); HeaderValue::from_str(&json).unwrap_or_else(|e| { tracing::warn!(error = %e, "hx_toast produced invalid header value"); HeaderValue::from_static("") }) } #[cfg(test)] mod tests { use super::*; // ── is_htmx_request ── #[test] fn htmx_request_detected() { let mut headers = HeaderMap::new(); headers.insert("HX-Request", HeaderValue::from_static("true")); assert!(is_htmx_request(&headers)); } #[test] fn non_htmx_request() { let headers = HeaderMap::new(); assert!(!is_htmx_request(&headers)); } // ── hx_toast ── #[test] fn hx_toast_produces_valid_json() { let val = hx_toast("Item deleted", "success"); let s = val.to_str().unwrap(); assert!(s.contains("showToast")); assert!(s.contains("Item deleted")); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["message"], "Item deleted"); assert_eq!(parsed["showToast"]["type"], "success"); } #[test] fn hx_toast_error_type() { let val = hx_toast("Something failed", "error"); let s = val.to_str().unwrap(); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["type"], "error"); } #[test] fn hx_toast_with_quotes() { let val = hx_toast("Say \"hello\"", "info"); let s = val.to_str().unwrap(); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["message"], "Say \"hello\""); } #[test] fn adversarial_hx_toast_json_injection() { let val = hx_toast("\"},{\"malicious\":\"true", "error"); let s = val.to_str().unwrap(); let parsed: serde_json::Value = serde_json::from_str(s).unwrap(); assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true"); } // ── extract_client_ip ── #[test] fn extract_client_ip_cf_preferred() { let mut headers = HeaderMap::new(); headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4")); headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8")); assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4")); } #[test] fn extract_client_ip_ignores_xff_when_cf_missing() { // XFF alone must not be trusted, see security note on extract_client_ip. let mut headers = HeaderMap::new(); headers.insert( "x-forwarded-for", HeaderValue::from_static("5.6.7.8, 9.10.11.12"), ); assert_eq!(extract_client_ip(&headers), None); } #[test] fn extract_client_ip_ignores_xff_even_when_cf_present() { // Defense in depth: presence of XFF must not influence the result. let mut headers = HeaderMap::new(); headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4")); headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8")); assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4")); } #[test] fn extract_client_ip_missing() { let headers = HeaderMap::new(); assert_eq!(extract_client_ip(&headers), None); } // ── ip_advisory_lock_key ── #[test] fn ip_advisory_lock_key_deterministic() { assert_eq!( ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("1.2.3.4") ); } #[test] fn ip_advisory_lock_key_different_ips() { assert_ne!( ip_advisory_lock_key("1.2.3.4"), ip_advisory_lock_key("5.6.7.8") ); } }