Skip to main content

max / makenotwork

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