max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
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(¶ms.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(¶ms.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 | /* =========================================== |