Skip to main content

max / makenotwork

10.3 KB · 275 lines History Blame Raw
1 //! HTTP request/response helpers: the shared outbound client, client-IP
2 //! extraction, HTMX detection, ETag conditional responses, and toast headers.
3
4 use axum::http::HeaderValue;
5 use axum::http::StatusCode;
6 use axum::http::header::HeaderMap;
7 use axum::response::{IntoResponse, Response};
8
9 /// Shared outbound HTTP client. `reqwest::Client` is internally `Arc`-wrapped
10 /// and pools connections, so it is meant to be built once and reused; a
11 /// per-request `reqwest::Client::new()` throws away the connection pool and
12 /// re-runs TLS setup every call. Carries a 10s default timeout as a backstop;
13 /// call sites may still set a tighter per-request `.timeout()`, which wins.
14 /// (Clients that need custom config, like Postmark and the PoM probe, keep their own.)
15 pub static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
16 crate::crypto::install_default_crypto_provider();
17 reqwest::Client::builder()
18 .timeout(std::time::Duration::from_secs(10))
19 .build()
20 .expect("build shared reqwest client")
21 });
22
23 /// Extract the client IP from request headers.
24 ///
25 /// Honors `CF-Connecting-IP` only, and that header is trustworthy on every
26 /// public path: the makenot.work blocks enforce Cloudflare mTLS (only
27 /// Cloudflare reaches the origin, and it sets the header), and the custom-domain
28 /// `:443` block overwrites `CF-Connecting-IP` with the real TCP peer + strips
29 /// `X-Forwarded-For` before proxying (see `deploy/Caddyfile`). `X-Forwarded-For`
30 /// is intentionally never consulted: there is no trusted-proxy allowlist, so a
31 /// request reaching the app with a client-set XFF could spoof the IP and evade
32 /// sandbox caps / poison audit logs / forge "new device" notifications.
33 ///
34 /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare
35 /// was bypassed or misconfigured, keying rate-limits on `None` then collapses
36 /// every requester into the same bucket. After 100 cumulative missing-header
37 /// requests, emit a one-shot WARN so the operator notices before any limit
38 /// surface degrades silently. Dev hits this immediately, which is fine: it's
39 /// a real signal the deployment isn't behind Cloudflare.
40 pub fn extract_client_ip(headers: &HeaderMap) -> Option<String> {
41 let ip = headers
42 .get("cf-connecting-ip")
43 .and_then(|v| v.to_str().ok())
44 .and_then(|s| s.split(',').next())
45 .map(|s| s.trim().to_string())
46 .filter(|s| !s.is_empty());
47 if ip.is_none() {
48 static MISSING_COUNT: std::sync::atomic::AtomicUsize =
49 std::sync::atomic::AtomicUsize::new(0);
50 static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
51 let n = MISSING_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
52 if n >= 100 {
53 WARNED.get_or_init(|| {
54 tracing::warn!(
55 missing_count = n,
56 "cf-connecting-ip header missing on 100+ requests, rate-limits and \
57 sandbox caps will key on None. Verify Cloudflare proxy is in front \
58 of the origin (dev/test environments hit this naturally and can ignore)."
59 );
60 });
61 }
62 }
63 ip
64 }
65
66 /// Derive a stable i64 key from an IP string for use with PostgreSQL advisory locks.
67 ///
68 /// Uses SHA-256 rather than `std::collections::hash_map::DefaultHasher`,
69 /// `DefaultHasher`'s algorithm is implementation-defined and can shift between
70 /// Rust releases, which would silently change the lock keyspace on rebuild
71 /// and let two concurrent operations from the same IP grab different locks
72 /// across a deploy boundary. SHA-256 is stable forever.
73 pub fn ip_advisory_lock_key(ip: &str) -> i64 {
74 use sha2::{Digest, Sha256};
75 let mut h = Sha256::new();
76 h.update(b"sandbox_ip_cap\0");
77 h.update(ip.as_bytes());
78 let digest = h.finalize();
79 // Take the first 8 bytes as an i64 (big-endian). The full SHA-256 output
80 // is 32 bytes; the leading 8 are uniformly random over the input space.
81 i64::from_be_bytes(digest[..8].try_into().expect("sha256 yields >= 8 bytes"))
82 }
83
84 /// Check whether the incoming request was made by HTMX.
85 pub fn is_htmx_request(headers: &HeaderMap) -> bool {
86 headers.get("HX-Request").is_some()
87 }
88
89 /// Check the client's `If-None-Match` header against a cache generation.
90 /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
91 pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
92 let etag = format!("\"g{generation}\"");
93 if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH)
94 && if_none_match.as_bytes() == etag.as_bytes()
95 {
96 return Some(
97 (
98 StatusCode::NOT_MODIFIED,
99 [(
100 axum::http::header::ETAG,
101 HeaderValue::try_from(&etag)
102 .unwrap_or_else(|_| HeaderValue::from_static("invalid")),
103 )],
104 )
105 .into_response(),
106 );
107 }
108 None
109 }
110
111 /// Wrap a rendered response with ETag and Cache-Control headers.
112 /// `no-cache` tells the browser to store the response but revalidate on each use.
113 pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
114 let etag = format!("\"g{generation}\"");
115 (
116 [
117 (axum::http::header::ETAG, etag),
118 (
119 axum::http::header::CACHE_CONTROL,
120 "private, no-cache".to_string(),
121 ),
122 ],
123 body,
124 )
125 .into_response()
126 }
127
128 /// Build an HTMX response that shows a toast notification with an empty body.
129 ///
130 /// Use for delete/action endpoints that only need to signal success via toast.
131 pub fn htmx_toast_response(
132 message: &str,
133 toast_type: &str,
134 ) -> (
135 [(&'static str, HeaderValue); 1],
136 axum::response::Html<String>,
137 ) {
138 (
139 [("HX-Trigger", hx_toast(message, toast_type))],
140 axum::response::Html(String::new()),
141 )
142 }
143
144 pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
145 // Strip control characters (C0, DEL/0x7F, C1) before encoding. serde_json
146 // escapes the < 0x20 controls, but DEL and the C1 range pass through raw and
147 // make `HeaderValue::from_str` reject the whole header, which silently drops
148 // the toast. Stripping them keeps the JSON header-safe; the fallback below
149 // stays as defense-in-depth.
150 let message: String = message.chars().filter(|c| !c.is_control()).collect();
151 let toast_type: String = toast_type.chars().filter(|c| !c.is_control()).collect();
152 let json = serde_json::json!({
153 "showToast": {
154 "message": message,
155 "type": toast_type
156 }
157 })
158 .to_string();
159 HeaderValue::from_str(&json).unwrap_or_else(|e| {
160 tracing::warn!(error = %e, "hx_toast produced invalid header value");
161 HeaderValue::from_static("")
162 })
163 }
164
165 #[cfg(test)]
166 mod tests {
167 use super::*;
168
169 // ── is_htmx_request ──
170
171 #[test]
172 fn htmx_request_detected() {
173 let mut headers = HeaderMap::new();
174 headers.insert("HX-Request", HeaderValue::from_static("true"));
175 assert!(is_htmx_request(&headers));
176 }
177
178 #[test]
179 fn non_htmx_request() {
180 let headers = HeaderMap::new();
181 assert!(!is_htmx_request(&headers));
182 }
183
184 // ── hx_toast ──
185
186 #[test]
187 fn hx_toast_produces_valid_json() {
188 let val = hx_toast("Item deleted", "success");
189 let s = val.to_str().unwrap();
190 assert!(s.contains("showToast"));
191 assert!(s.contains("Item deleted"));
192 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
193 assert_eq!(parsed["showToast"]["message"], "Item deleted");
194 assert_eq!(parsed["showToast"]["type"], "success");
195 }
196
197 #[test]
198 fn hx_toast_error_type() {
199 let val = hx_toast("Something failed", "error");
200 let s = val.to_str().unwrap();
201 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
202 assert_eq!(parsed["showToast"]["type"], "error");
203 }
204
205 #[test]
206 fn hx_toast_with_quotes() {
207 let val = hx_toast("Say \"hello\"", "info");
208 let s = val.to_str().unwrap();
209 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
210 assert_eq!(parsed["showToast"]["message"], "Say \"hello\"");
211 }
212
213 #[test]
214 fn adversarial_hx_toast_json_injection() {
215 let val = hx_toast("\"},{\"malicious\":\"true", "error");
216 let s = val.to_str().unwrap();
217 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
218 assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true");
219 }
220
221 // ── extract_client_ip ──
222
223 #[test]
224 fn extract_client_ip_cf_preferred() {
225 let mut headers = HeaderMap::new();
226 headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
227 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
228 assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
229 }
230
231 #[test]
232 fn extract_client_ip_ignores_xff_when_cf_missing() {
233 // XFF alone must not be trusted, see security note on extract_client_ip.
234 let mut headers = HeaderMap::new();
235 headers.insert(
236 "x-forwarded-for",
237 HeaderValue::from_static("5.6.7.8, 9.10.11.12"),
238 );
239 assert_eq!(extract_client_ip(&headers), None);
240 }
241
242 #[test]
243 fn extract_client_ip_ignores_xff_even_when_cf_present() {
244 // Defense in depth: presence of XFF must not influence the result.
245 let mut headers = HeaderMap::new();
246 headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
247 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
248 assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
249 }
250
251 #[test]
252 fn extract_client_ip_missing() {
253 let headers = HeaderMap::new();
254 assert_eq!(extract_client_ip(&headers), None);
255 }
256
257 // ── ip_advisory_lock_key ──
258
259 #[test]
260 fn ip_advisory_lock_key_deterministic() {
261 assert_eq!(
262 ip_advisory_lock_key("1.2.3.4"),
263 ip_advisory_lock_key("1.2.3.4")
264 );
265 }
266
267 #[test]
268 fn ip_advisory_lock_key_different_ips() {
269 assert_ne!(
270 ip_advisory_lock_key("1.2.3.4"),
271 ip_advisory_lock_key("5.6.7.8")
272 );
273 }
274 }
275