Skip to main content

max / makenotwork

11.3 KB · 296 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 /// Send the caller somewhere else, whichever way they asked.
90 ///
91 /// A 303 is what a browser following a form submission wants and it is the
92 /// wrong answer to an htmx request: the browser follows the redirect inside the
93 /// XHR and htmx swaps whatever came back into the element that sent it, which
94 /// for a hop to Stripe is a cross-origin fetch that lands nowhere. `HX-Redirect`
95 /// navigates the document instead. `c7b0d3c1` hit this on the header's Log Out
96 /// and `logout_handler` carries the same pair.
97 ///
98 /// Here rather than in each handler because the described forms made it three
99 /// call sites at once: the tip form, the tier subscribe form, and the paths
100 /// either one bails out of before reaching Stripe.
101 pub fn redirect_to(headers: &HeaderMap, location: &str) -> Response {
102 if is_htmx_request(headers)
103 && let Ok(value) = HeaderValue::from_str(location)
104 {
105 return (StatusCode::OK, [("HX-Redirect", value)], "").into_response();
106 }
107 axum::response::Redirect::to(location).into_response()
108 }
109
110 /// Check the client's `If-None-Match` header against a cache generation.
111 /// Returns `Some(304 Not Modified)` if the client's cached version is still fresh.
112 pub fn check_etag(headers: &HeaderMap, generation: i64) -> Option<Response> {
113 let etag = format!("\"g{generation}\"");
114 if let Some(if_none_match) = headers.get(axum::http::header::IF_NONE_MATCH)
115 && if_none_match.as_bytes() == etag.as_bytes()
116 {
117 return Some(
118 (
119 StatusCode::NOT_MODIFIED,
120 [(
121 axum::http::header::ETAG,
122 HeaderValue::try_from(&etag)
123 .unwrap_or_else(|_| HeaderValue::from_static("invalid")),
124 )],
125 )
126 .into_response(),
127 );
128 }
129 None
130 }
131
132 /// Wrap a rendered response with ETag and Cache-Control headers.
133 /// `no-cache` tells the browser to store the response but revalidate on each use.
134 pub fn with_etag(generation: i64, body: impl IntoResponse) -> Response {
135 let etag = format!("\"g{generation}\"");
136 (
137 [
138 (axum::http::header::ETAG, etag),
139 (
140 axum::http::header::CACHE_CONTROL,
141 "private, no-cache".to_string(),
142 ),
143 ],
144 body,
145 )
146 .into_response()
147 }
148
149 /// Build an HTMX response that shows a toast notification with an empty body.
150 ///
151 /// Use for delete/action endpoints that only need to signal success via toast.
152 pub fn htmx_toast_response(
153 message: &str,
154 toast_type: &str,
155 ) -> (
156 [(&'static str, HeaderValue); 1],
157 axum::response::Html<String>,
158 ) {
159 (
160 [("HX-Trigger", hx_toast(message, toast_type))],
161 axum::response::Html(String::new()),
162 )
163 }
164
165 pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
166 // Strip control characters (C0, DEL/0x7F, C1) before encoding. serde_json
167 // escapes the < 0x20 controls, but DEL and the C1 range pass through raw and
168 // make `HeaderValue::from_str` reject the whole header, which silently drops
169 // the toast. Stripping them keeps the JSON header-safe; the fallback below
170 // stays as defense-in-depth.
171 let message: String = message.chars().filter(|c| !c.is_control()).collect();
172 let toast_type: String = toast_type.chars().filter(|c| !c.is_control()).collect();
173 let json = serde_json::json!({
174 "showToast": {
175 "message": message,
176 "type": toast_type
177 }
178 })
179 .to_string();
180 HeaderValue::from_str(&json).unwrap_or_else(|e| {
181 tracing::warn!(error = %e, "hx_toast produced invalid header value");
182 HeaderValue::from_static("")
183 })
184 }
185
186 #[cfg(test)]
187 mod tests {
188 use super::*;
189
190 // ── is_htmx_request ──
191
192 #[test]
193 fn htmx_request_detected() {
194 let mut headers = HeaderMap::new();
195 headers.insert("HX-Request", HeaderValue::from_static("true"));
196 assert!(is_htmx_request(&headers));
197 }
198
199 #[test]
200 fn non_htmx_request() {
201 let headers = HeaderMap::new();
202 assert!(!is_htmx_request(&headers));
203 }
204
205 // ── hx_toast ──
206
207 #[test]
208 fn hx_toast_produces_valid_json() {
209 let val = hx_toast("Item deleted", "success");
210 let s = val.to_str().unwrap();
211 assert!(s.contains("showToast"));
212 assert!(s.contains("Item deleted"));
213 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
214 assert_eq!(parsed["showToast"]["message"], "Item deleted");
215 assert_eq!(parsed["showToast"]["type"], "success");
216 }
217
218 #[test]
219 fn hx_toast_error_type() {
220 let val = hx_toast("Something failed", "error");
221 let s = val.to_str().unwrap();
222 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
223 assert_eq!(parsed["showToast"]["type"], "error");
224 }
225
226 #[test]
227 fn hx_toast_with_quotes() {
228 let val = hx_toast("Say \"hello\"", "info");
229 let s = val.to_str().unwrap();
230 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
231 assert_eq!(parsed["showToast"]["message"], "Say \"hello\"");
232 }
233
234 #[test]
235 fn adversarial_hx_toast_json_injection() {
236 let val = hx_toast("\"},{\"malicious\":\"true", "error");
237 let s = val.to_str().unwrap();
238 let parsed: serde_json::Value = serde_json::from_str(s).unwrap();
239 assert_eq!(parsed["showToast"]["message"], "\"},{\"malicious\":\"true");
240 }
241
242 // ── extract_client_ip ──
243
244 #[test]
245 fn extract_client_ip_cf_preferred() {
246 let mut headers = HeaderMap::new();
247 headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
248 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
249 assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
250 }
251
252 #[test]
253 fn extract_client_ip_ignores_xff_when_cf_missing() {
254 // XFF alone must not be trusted, see security note on extract_client_ip.
255 let mut headers = HeaderMap::new();
256 headers.insert(
257 "x-forwarded-for",
258 HeaderValue::from_static("5.6.7.8, 9.10.11.12"),
259 );
260 assert_eq!(extract_client_ip(&headers), None);
261 }
262
263 #[test]
264 fn extract_client_ip_ignores_xff_even_when_cf_present() {
265 // Defense in depth: presence of XFF must not influence the result.
266 let mut headers = HeaderMap::new();
267 headers.insert("cf-connecting-ip", HeaderValue::from_static("1.2.3.4"));
268 headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8"));
269 assert_eq!(extract_client_ip(&headers).as_deref(), Some("1.2.3.4"));
270 }
271
272 #[test]
273 fn extract_client_ip_missing() {
274 let headers = HeaderMap::new();
275 assert_eq!(extract_client_ip(&headers), None);
276 }
277
278 // ── ip_advisory_lock_key ──
279
280 #[test]
281 fn ip_advisory_lock_key_deterministic() {
282 assert_eq!(
283 ip_advisory_lock_key("1.2.3.4"),
284 ip_advisory_lock_key("1.2.3.4")
285 );
286 }
287
288 #[test]
289 fn ip_advisory_lock_key_different_ips() {
290 assert_ne!(
291 ip_advisory_lock_key("1.2.3.4"),
292 ip_advisory_lock_key("5.6.7.8")
293 );
294 }
295 }
296