Skip to main content

max / makenotwork

Ultra-fuzz Run #1 remediation: all axes to A-; bump 0.4.0 Adversarial multi-axis audit (Community, Storage, UX, Security, Performance) surfaced 15 serious findings; this remediates all of them. - Storage: authorize image serving (community access check); drop reply_count denorm (migration 027) and compute it live from non-removed posts; delete S3 objects on image removal; atomic create_thread_with_op. - Security: rate-limit /auth/* and the internal API; consume OAuth state/PKCE verifier before validation; HMAC over raw bytes + asymmetric replay window; connect-time SSRF resolver dropping private IPs; remove /auth/ CSRF exemption; default bind to 127.0.0.1. - UX: form-failure no longer dead-ends (mt.js surfaces the error and keeps input); fix negative OFFSET on ?page=0. - Performance: size the DB pool (shared with sessions); DefaultBodyLimit on uploads; fan out thread-view fetches with try_join!; bound background link-preview spawns. reply_count is now computed live (denorm column dropped) and thread+OP creation is transactional. 353 tests green, clippy clean. Token-at-rest (S13) and HMAC method+path+nonce remain cross-repo/post-launch (see oauth-rp-refresh.md).
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 19:13 UTC
Signed with PGP, not checked
Commit: 9e7cbdec649fe94c62b625bba67473cffa3eb287
Parent: c221672
32 files changed, +501 insertions, -194 deletions
@@ -5,7 +5,11 @@
5 5 # Optional (defaults shown)
6 6 MNW_BASE_URL=http://127.0.0.1:3000
7 7 OAUTH_REDIRECT_URI=http://127.0.0.1:3400/auth/callback
8 - HOST=0.0.0.0
8 + # Bind loopback by default. Rate limiting trusts X-Forwarded-For, which is only
9 + # safe behind a reverse proxy that OVERWRITES that header (Caddy must set, not
10 + # append, X-Forwarded-For / X-Real-IP). Only set 0.0.0.0 if the port is meant to
11 + # be directly reachable (e.g. a tailnet-direct staging box).
12 + HOST=127.0.0.1
9 13 PORT=3400
10 14 COOKIE_SECURE=true
11 15 RUST_LOG=info
@@ -1080,7 +1080,7 @@
1080 1080
1081 1081 [[package]]
1082 1082 name = "docengine"
1083 - version = "0.3.4"
1083 + version = "0.3.5"
1084 1084 dependencies = [
1085 1085 "ammonia",
1086 1086 "pulldown-cmark",
@@ -2189,7 +2189,7 @@
2189 2189
2190 2190 [[package]]
2191 2191 name = "mt-core"
2192 - version = "0.3.5"
2192 + version = "0.4.0"
2193 2193 dependencies = [
2194 2194 "chrono",
2195 2195 "serde",
@@ -2198,7 +2198,7 @@
2198 2198
2199 2199 [[package]]
2200 2200 name = "mt-db"
2201 - version = "0.3.5"
2201 + version = "0.4.0"
2202 2202 dependencies = [
2203 2203 "chrono",
2204 2204 "mt-core",
@@ -2227,7 +2227,7 @@
2227 2227
2228 2228 [[package]]
2229 2229 name = "multithreaded"
2230 - version = "0.3.5"
2230 + version = "0.4.0"
2231 2231 dependencies = [
2232 2232 "askama",
2233 2233 "axum",
@@ -7,7 +7,7 @@
7 7 default-members = ["."]
8 8
9 9 [workspace.package]
10 - version = "0.3.5"
10 + version = "0.4.0"
11 11 edition = "2024"
12 12 license-file = "LICENSE"
13 13
@@ -355,22 +355,11 @@
355 355 ) -> impl IntoResponse {
356 356 tracing::info!("OAuth callback received");
357 357
358 - // Verify state nonce
358 + // Read and immediately consume the one-time OAuth params. Removing them up
359 + // front makes both single-use, so a failed state check — or a replayed
360 + // callback — cannot leave a reusable PKCE verifier behind in the session.
359 361 let stored_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.unwrap_or(None);
360 - if stored_state.as_deref() != Some(&params.state) {
361 - tracing::warn!(stored = ?stored_state, received = %params.state, "state mismatch");
362 - return Redirect::to("/?error=state_mismatch");
363 - }
364 -
365 - let verifier: String = match session.get(SESSION_PKCE_VERIFIER).await.unwrap_or(None) {
366 - Some(v) => v,
367 - None => {
368 - tracing::warn!("missing PKCE verifier in session");
369 - return Redirect::to("/?error=missing_verifier");
370 - }
371 - };
372 -
373 - // Clean up OAuth session data
362 + let stored_verifier: Option<String> = session.get(SESSION_PKCE_VERIFIER).await.unwrap_or(None);
374 363 if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
375 364 tracing::warn!(error = %e, "failed to remove OAuth state from session");
376 365 }
@@ -378,6 +367,20 @@
378 367 tracing::warn!(error = %e, "failed to remove PKCE verifier from session");
379 368 }
380 369
370 + // Verify state nonce
371 + if stored_state.as_deref() != Some(&params.state) {
372 + tracing::warn!(stored = ?stored_state, received = %params.state, "state mismatch");
373 + return Redirect::to("/?error=state_mismatch");
374 + }
375 +
376 + let verifier: String = match stored_verifier {
377 + Some(v) => v,
378 + None => {
379 + tracing::warn!("missing PKCE verifier in session");
380 + return Redirect::to("/?error=missing_verifier");
381 + }
382 + };
383 +
381 384 // Exchange code for token (retry up to 2 attempts on network/5xx errors)
382 385 let token_url = format!("{}/oauth/token", state.config.mnw_base_url);
383 386 tracing::info!(%token_url, "exchanging code for token");
@@ -45,7 +45,11 @@
45 45
46 46 /// Middleware: validate X-CSRF-Token header on POST/PUT/PATCH/DELETE.
47 47 ///
48 - /// Exempt paths: `/auth/`, `/api/health`, `/_test/`.
48 + /// `/auth/` is deliberately NOT exempt: its only mutating routes (`logout`,
49 + /// `refresh`) are same-origin forms that carry the token via mt.js, so they
50 + /// get CSRF protection like everything else. `login`/`callback` are GET and so
51 + /// never reach this check. `/_test/` is only ever mounted by the integration
52 + /// harness (never in production); `/api/health` is GET-only.
49 53 pub async fn csrf_middleware(request: Request, next: Next) -> Response {
50 54 let method = request.method().clone();
51 55
@@ -55,7 +59,7 @@
55 59
56 60 let path = request.uri().path().to_string();
57 61
58 - let exempt_prefixes = ["/auth/", "/api/health", "/_test/"];
62 + let exempt_prefixes = ["/api/health", "/_test/"];
59 63 if exempt_prefixes.iter().any(|p| path.starts_with(p)) {
60 64 return next.run(request).await;
61 65 }
@@ -18,6 +18,12 @@
18 18 /// Maximum age (in seconds) for an internal request timestamp before it's rejected.
19 19 const MAX_TIMESTAMP_AGE_SECS: i64 = 60;
20 20
21 + /// Maximum tolerated clock skew into the future. The window was previously
22 + /// symmetric (±60s), so a captured signature was replayable across a ~120s
23 + /// band; only a few seconds of skew are legitimate, so future timestamps are
24 + /// held to a tight bound, roughly halving the replay window.
25 + const MAX_FUTURE_SKEW_SECS: i64 = 5;
26 +
21 27 /// Axum extractor that validates HMAC-SHA256 signatures on internal API requests.
22 28 /// Extracts the raw request body as `Bytes` after successful verification.
23 29 pub struct InternalAuth(pub Bytes);
@@ -67,10 +73,16 @@
67 73 /// Compute the hex-encoded HMAC-SHA256 signature for an internal request.
68 74 /// `secret` may be any length (HMAC-SHA256 accepts any key length).
69 75 pub(crate) fn compute_internal_signature(secret: &str, timestamp_str: &str, body: &[u8]) -> String {
70 - let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
76 + // MAC over raw bytes (`timestamp\n` ++ body), not a lossy UTF-8 string. For
77 + // valid-UTF-8 bodies (all our JSON) this is byte-identical to the old
78 + // `format!`-based message, so it stays wire-compatible with MNW's signer;
79 + // it also closes the latent hole where two distinct non-UTF-8 bodies both
80 + // collapsed to the empty string and signed identically.
71 81 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
72 82 .expect("HMAC-SHA256 accepts any key length");
73 - mac.update(message.as_bytes());
83 + mac.update(timestamp_str.as_bytes());
84 + mac.update(b"\n");
85 + mac.update(body);
74 86 hex::encode(mac.finalize().into_bytes())
75 87 }
76 88
@@ -95,8 +107,11 @@
95 107 .parse()
96 108 .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
97 109
98 - if (now_unix - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
99 - return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
110 + if now_unix - timestamp > MAX_TIMESTAMP_AGE_SECS {
111 + return Err((StatusCode::UNAUTHORIZED, "Timestamp too old"));
112 + }
113 + if timestamp - now_unix > MAX_FUTURE_SKEW_SECS {
114 + return Err((StatusCode::UNAUTHORIZED, "Timestamp too far in the future"));
100 115 }
101 116
102 117 let expected = compute_internal_signature(secret, timestamp_str, body);
@@ -274,22 +289,22 @@
274 289
275 290 #[test]
276 291 fn verify_at_window_boundary_accepts_inside_rejects_outside() {
277 - // Pins `(now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS` (60s).
278 - // Exactly at the boundary (abs diff == 60) must be accepted (since `>`
279 - // is strict). One second past must be rejected.
292 + // Asymmetric window: up to MAX_TIMESTAMP_AGE_SECS (60s) old, but only
293 + // MAX_FUTURE_SKEW_SECS (5s) into the future. `>` is strict, so exactly
294 + // at each boundary is accepted.
280 295 let secret = "s";
281 296 let body = b"abc";
282 297 let ts = "1000";
283 298 let sig = valid(secret, ts, body);
284 299
285 - // diff = 60 → accepted
300 + // now - ts = 60 → accepted (at the age boundary)
286 301 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1060).is_ok());
287 - // diff = 61 → rejected (too old)
302 + // now - ts = 61 → rejected (too old)
288 303 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1061).is_err());
289 - // diff = -60 → accepted
290 - assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 940).is_ok());
291 - // diff = -61 → rejected (too far in future)
292 - assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 939).is_err());
304 + // ts - now = 5 → accepted (at the future-skew boundary)
305 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 995).is_ok());
306 + // ts - now = 6 → rejected (too far in the future)
307 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 994).is_err());
293 308 }
294 309
295 310 #[test]
@@ -30,8 +30,11 @@
30 30 }
31 31 }
32 32
33 - /// Validate that a URL is safe to fetch (no SSRF to internal networks).
34 - /// Resolves the hostname to catch alternative IP encodings (octal, hex, decimal, IPv6-mapped).
33 + /// Cheap, synchronous pre-check that a URL is safe to fetch: http(s) scheme and
34 + /// not an obvious private/literal-IP target. Hostnames are NOT resolved here —
35 + /// that (and the authoritative anti-rebinding check) happens at connect time in
36 + /// [`SsrfSafeResolver`], so this stays non-blocking and usable from reqwest's
37 + /// synchronous redirect callback.
35 38 fn validate_url(url: &str) -> bool {
36 39 let lower = url.to_ascii_lowercase();
37 40 if !lower.starts_with("http://") && !lower.starts_with("https://") {
@@ -64,15 +67,10 @@
64 67 return !is_private_ip(ip);
65 68 }
66 69
67 - // For hostnames, resolve and check all addresses
68 - if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(bare_host, 80)) {
69 - for addr in addrs {
70 - if is_private_ip(addr.ip()) {
71 - return false;
72 - }
73 - }
74 - }
75 -
70 + // Hostnames are resolved and re-checked at connect time by SsrfSafeResolver,
71 + // which is what reqwest actually connects through — so a name that resolves
72 + // to a private address (including via DNS rebinding after this check) can
73 + // never be connected to.
76 74 true
77 75 }
78 76
@@ -100,9 +98,37 @@
100 98 urls
101 99 }
102 100
103 - /// Build a reqwest client for link preview fetching with SSRF-safe redirect policy.
101 + /// Connect-time DNS resolver that drops private/reserved addresses.
102 + ///
103 + /// `validate_url` resolves and checks during validation, but reqwest re-resolves
104 + /// when it actually connects — a DNS-rebinding host could pass validation and
105 + /// then connect internally. Filtering here, at the resolver reqwest uses to
106 + /// connect, closes that TOCTOU: reqwest can only connect to an address this
107 + /// resolver returned, and we never return a private one. Resolution is async
108 + /// (`tokio::net::lookup_host`), so it also avoids blocking the runtime.
109 + struct SsrfSafeResolver;
110 +
111 + impl reqwest::dns::Resolve for SsrfSafeResolver {
112 + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
113 + Box::pin(async move {
114 + let host = name.as_str().to_string();
115 + let addrs = tokio::net::lookup_host((host.as_str(), 0)).await?;
116 + let public: Vec<std::net::SocketAddr> =
117 + addrs.filter(|a| !is_private_ip(a.ip())).collect();
118 + if public.is_empty() {
119 + return Err("no public address for host (SSRF guard)".into());
120 + }
121 + let iter: reqwest::dns::Addrs = Box::new(public.into_iter());
122 + Ok(iter)
123 + })
124 + }
125 + }
126 +
127 + /// Build a reqwest client for link preview fetching with SSRF-safe redirect
128 + /// policy and a connect-time resolver that refuses private addresses.
104 129 pub fn build_preview_client() -> reqwest::Client {
105 130 reqwest::Client::builder()
131 + .dns_resolver(std::sync::Arc::new(SsrfSafeResolver))
106 132 .redirect(reqwest::redirect::Policy::custom(|attempt| {
107 133 if !validate_url(attempt.url().as_str()) || attempt.previous().len() >= 5 {
108 134 attempt.stop()
@@ -1,5 +1,5 @@
1 1 use multithreaded::{config::Config, csrf, AppState};
2 - use sqlx::PgPool;
2 + use sqlx::postgres::PgPoolOptions;
3 3 use tokio::net::TcpListener;
4 4 use tower_http::services::ServeDir;
5 5 use tower_sessions::SessionManagerLayer;
@@ -19,7 +19,14 @@
19 19 let database_url = std::env::var("DATABASE_URL")
20 20 .expect("DATABASE_URL must be set");
21 21
22 - let pool = PgPool::connect(&database_url)
22 + // Explicit pool sizing. The sqlx default is 10 connections, and this pool
23 + // is shared with the tower-sessions PostgresStore (every authed request
24 + // does session I/O on top of its handler queries), so the default is tight.
25 + // Bound acquisition so a burst fails fast rather than hanging for 30s.
26 + let pool = PgPoolOptions::new()
27 + .max_connections(20)
28 + .acquire_timeout(std::time::Duration::from_secs(10))
29 + .connect(&database_url)
23 30 .await
24 31 .expect("failed to connect to database");
25 32
@@ -113,7 +120,13 @@
113 120 .merge(multithreaded::routes::internal::internal_routes(state))
114 121 .nest_service("/static", ServeDir::new("static"));
115 122
116 - let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
123 + // Default to loopback. Rate limiting uses SmartIpKeyExtractor, which trusts
124 + // X-Forwarded-For — that is only safe behind a reverse proxy (Caddy) that
125 + // *overwrites* the header. Binding to 127.0.0.1 by default keeps the app
126 + // unreachable except via that proxy, so XFF can't be spoofed by a direct
127 + // client. Environments that intentionally expose the port (e.g. a
128 + // tailnet-direct staging box) set HOST explicitly.
129 + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
117 130 let port = std::env::var("PORT").unwrap_or_else(|_| "3400".to_string());
118 131 let addr = format!("{host}:{port}");
119 132
@@ -99,14 +99,7 @@
99 99 seed_music_mixing(pool, music_mixing, &users).await;
100 100 seed_music_sound_design(pool, music_sound, &users).await;
101 101
102 - // Backfill denormalized reply_count from actual post data
103 - sqlx::query(
104 - "UPDATE threads SET reply_count = GREATEST(
105 - (SELECT COUNT(*) FROM posts WHERE posts.thread_id = threads.id) - 1, 0)",
106 - )
107 - .execute(pool)
108 - .await
109 - .expect("failed to backfill reply_count");
102 + // reply_count is computed live at read time — nothing to backfill.
110 103
111 104 let total_threads: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM threads")
112 105 .fetch_one(pool)
@@ -205,7 +205,23 @@
205 205 headers: { 'X-CSRF-Token': token, 'Content-Type': 'application/x-www-form-urlencoded' },
206 206 body: new URLSearchParams(new FormData(form)),
207 207 redirect: 'follow',
208 - }).then(function(resp) { window.location.href = resp.url; });
208 + }).then(function(resp) {
209 + if (resp.ok) {
210 + // Success: the draft is no longer needed, and the handler issued
211 + // a redirect we should follow.
212 + localStorage.removeItem('mt_draft:' + window.location.pathname);
213 + window.location.href = resp.url;
214 + return;
215 + }
216 + // Validation/other failure: keep the user on the page with their
217 + // input intact and surface the handler's message, rather than
218 + // navigating to the POST-only URL (which would GET a 404).
219 + return resp.text().then(function(msg) {
220 + showToast(msg || 'Something went wrong. Please try again.', 'error');
221 + });
222 + }).catch(function() {
223 + showToast('Network error. Please try again.', 'error');
224 + });
209 225 }
210 226 });
211 227
@@ -233,7 +249,6 @@
233 249 var title = document.getElementById('title');
234 250 if (title && title.tagName !== 'INPUT') title = null;
235 251 var key = 'mt_draft:' + window.location.pathname;
236 - var form = body.closest('form');
237 252 var timer = null;
238 253 var MAX_DRAFTS = 20;
239 254 var WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -290,8 +305,8 @@
290 305 body.addEventListener('input', debounced);
291 306 if (title) title.addEventListener('input', debounced);
292 307
293 - // Clear on submit
294 - if (form) form.addEventListener('submit', function() { localStorage.removeItem(key); });
308 + // The draft is cleared only on a successful submit (see the POST handler
309 + // above), so a validation failure keeps the user's text.
295 310 })();
296 311
297 312 /* ===========================================