Skip to main content

max / makenotwork

mt: land ultra-fuzz Run #4 remediation (S1, H2, P1/P2, M2, NOTE-1, UX1, security LOWs, P3) Run #4 findings, all gated on tests + clippy: - S1 (SERIOUS): search post_matches joins author on p.author_id (matched reply), not t.author_id (OP); regression test added. - H2: get_thread_stats excludes removed_at/deleted_at; count_posts_in_thread left tombstone-inclusive (pagination correctness, verified false positive). - P1/P2: list_pending_flags + list_community_bans take a limit; moderation page fetches CAP+1 and shows a truncation notice instead of unbounded reads. - M2 (latent): migration 031 redefines the post_count trigger to treat a post active iff removed_at AND deleted_at are null, firing on either column. - NOTE-1: add_footnote_handler 404s on community/slug mismatch (matches flag/endorse/remove); regression test added. - UX1: CSRF degrades without JS via hidden csrf_token input + urlencoded form-field fallback in the middleware (header path untouched). - Security LOWs: link_preview rejects non-80/443 ports; validate_image rejects >50 MP pixel bombs from format headers; nonce eviction time-bucketed. - P3: community_members paginated via the now-revived count_community_members. - Security headers moved outermost so /static and /internal also receive them. Left by decision: M3 (sqlx offline migration), UX2/UX3 (low-value churn), NOTE-3 (mod_remove_post is a live test helper, not dead). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 23:04 UTC
Commit: 4d7b285212b02350af64c3d7670c097b7f634b4b
Parent: 64c640f
27 files changed, +570 insertions, -59 deletions
@@ -153,10 +153,17 @@ pub async fn get_thread_stats(
153 153 pool: &PgPool,
154 154 thread_id: Uuid,
155 155 ) -> Result<Option<(i64, Option<DateTime<Utc>>)>, sqlx::Error> {
156 + // This count surfaces on the MNW server (the internal /stats endpoint), which
157 + // can't render mt's tombstones — so it must be the canonical *active* count,
158 + // excluding mod-removed and soft-deleted posts (matching the m029 trigger and
159 + // the profile tallies). Contrast `count_posts_in_thread`, which deliberately
160 + // counts tombstones because the in-app thread list renders them.
156 161 sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>(
157 162 "SELECT COUNT(p.id), MAX(p.created_at)
158 163 FROM posts p
159 - WHERE p.thread_id = $1",
164 + WHERE p.thread_id = $1
165 + AND p.removed_at IS NULL
166 + AND p.deleted_at IS NULL",
160 167 )
161 168 .bind(thread_id)
162 169 .fetch_optional(pool)
@@ -488,6 +495,11 @@ pub async fn count_posts_in_thread(
488 495 pool: &PgPool,
489 496 thread_id: Uuid,
490 497 ) -> Result<i64, sqlx::Error> {
498 + // Intentionally counts ALL posts including mod-removed/soft-deleted: this
499 + // drives the thread-view pagination, and `list_posts_in_thread_paginated`
500 + // returns those rows so they render as tombstones. The count must match the
501 + // list it paginates, or the last page of tombstones would be cut off. For an
502 + // active-only count (e.g. the server-facing stats), use `get_thread_stats`.
491 503 sqlx::query_scalar(
492 504 "SELECT COUNT(*) FROM posts WHERE thread_id = $1",
493 505 )
@@ -762,11 +774,14 @@ pub struct CommunityBanRow {
762 774 pub banned_by_username: String,
763 775 }
764 776
765 - /// List all active bans and mutes in a community.
777 + /// List active bans and mutes in a community, newest first, bounded by `limit`.
778 + /// The moderation page caps the read so a community with a large ban backlog
779 + /// can't make a single page load materialize an unbounded result set.
766 780 #[tracing::instrument(skip_all)]
767 781 pub async fn list_community_bans(
768 782 pool: &PgPool,
769 783 community_id: Uuid,
784 + limit: i64,
770 785 ) -> Result<Vec<CommunityBanRow>, sqlx::Error> {
771 786 sqlx::query_as::<_, CommunityBanRow>(
772 787 "SELECT cb.id, cb.user_id,
@@ -778,9 +793,11 @@ pub async fn list_community_bans(
778 793 JOIN users actor ON actor.mnw_account_id = cb.banned_by
779 794 WHERE cb.community_id = $1
780 795 AND (cb.expires_at IS NULL OR cb.expires_at > now())
781 - ORDER BY cb.created_at DESC",
796 + ORDER BY cb.created_at DESC
797 + LIMIT $2",
782 798 )
783 799 .bind(community_id)
800 + .bind(limit)
784 801 .fetch_all(pool)
785 802 .await
786 803 }
@@ -1385,11 +1402,15 @@ pub struct PendingFlagRow {
1385 1402 pub created_at: DateTime<Utc>,
1386 1403 }
1387 1404
1388 - /// List unresolved flags in a community, newest first.
1405 + /// List unresolved flags in a community, newest first, bounded by `limit`.
1406 + /// The read is capped so a coordinated flag-spam backlog can't make the
1407 + /// moderation page build an unbounded result set on every load; mods clear the
1408 + /// newest flags and the next batch surfaces as they're resolved.
1389 1409 #[tracing::instrument(skip_all)]
1390 1410 pub async fn list_pending_flags(
1391 1411 pool: &PgPool,
1392 1412 community_id: Uuid,
1413 + limit: i64,
1393 1414 ) -> Result<Vec<PendingFlagRow>, sqlx::Error> {
1394 1415 sqlx::query_as::<_, PendingFlagRow>(
1395 1416 "SELECT f.id AS flag_id, f.post_id, p.thread_id,
@@ -1403,9 +1424,11 @@ pub async fn list_pending_flags(
1403 1424 JOIN categories cat ON cat.id = t.category_id
1404 1425 JOIN users u ON u.mnw_account_id = f.flagger_id
1405 1426 WHERE cat.community_id = $1 AND f.resolved_at IS NULL
1406 - ORDER BY f.created_at DESC",
1427 + ORDER BY f.created_at DESC
1428 + LIMIT $2",
1407 1429 )
1408 1430 .bind(community_id)
1431 + .bind(limit)
1409 1432 .fetch_all(pool)
1410 1433 .await
1411 1434 }
@@ -1528,7 +1551,10 @@ pub async fn search_threads(
1528 1551 JOIN threads t ON t.id = p.thread_id
1529 1552 JOIN categories c ON c.id = t.category_id
1530 1553 JOIN communities co ON co.id = c.community_id
1531 - JOIN users pu ON pu.mnw_account_id = t.author_id
1554 + -- Author of the matched *reply* (`p.author_id`), not the thread OP
1555 + -- (`t.author_id`): the snippet is the reply's body, so it must be
1556 + -- attributed to whoever wrote it.
1557 + JOIN users pu ON pu.mnw_account_id = p.author_id
1532 1558 CROSS JOIN q
1533 1559 WHERE t.deleted_at IS NULL
1534 1560 AND co.suspended_at IS NULL
@@ -0,0 +1,67 @@
1 + -- Harden the thread post_count trigger (migration 029) against the *other*
2 + -- soft-delete column. Posts carry two: `removed_at` (mod-remove, migration 011)
3 + -- and `deleted_at` (author soft-delete, migration 007). The 029 trigger only
4 + -- watched `removed_at`, so the day a feature starts setting `posts.deleted_at`
5 + -- (a "delete my own post" path), post_count would silently overcount — the exact
6 + -- drift class 027/029 were written to kill, just keyed on the dormant column.
7 + --
8 + -- A post counts toward post_count iff it is active: BOTH columns null. The
9 + -- trigger now reacts to transitions on either column. `deleted_at` is currently
10 + -- null for every post, so the backfill is a no-op today; this is purely a guard
11 + -- so the counter stays correct ahead of any future user-delete feature.
12 + --
13 + -- Migration hygiene: CREATE OR REPLACE for the function, DROP ... IF EXISTS
14 + -- before recreating the trigger so a partial re-run can't hard-fail.
15 +
16 + CREATE OR REPLACE FUNCTION threads_maintain_post_count() RETURNS TRIGGER AS $$
17 + DECLARE
18 + old_active BOOLEAN;
19 + new_active BOOLEAN;
20 + BEGIN
21 + IF TG_OP = 'INSERT' THEN
22 + IF NEW.removed_at IS NULL AND NEW.deleted_at IS NULL THEN
23 + UPDATE threads SET post_count = post_count + 1 WHERE id = NEW.thread_id;
24 + END IF;
25 + RETURN NEW;
26 + ELSIF TG_OP = 'DELETE' THEN
27 + IF OLD.removed_at IS NULL AND OLD.deleted_at IS NULL THEN
28 + UPDATE threads SET post_count = post_count - 1 WHERE id = OLD.thread_id;
29 + END IF;
30 + RETURN OLD;
31 + ELSE -- UPDATE: react to active-state transitions (and the unlikely thread move)
32 + old_active := (OLD.removed_at IS NULL AND OLD.deleted_at IS NULL);
33 + new_active := (NEW.removed_at IS NULL AND NEW.deleted_at IS NULL);
34 + IF OLD.thread_id = NEW.thread_id THEN
35 + IF old_active AND NOT new_active THEN
36 + UPDATE threads SET post_count = post_count - 1 WHERE id = NEW.thread_id;
37 + ELSIF NOT old_active AND new_active THEN
38 + UPDATE threads SET post_count = post_count + 1 WHERE id = NEW.thread_id;
39 + END IF;
40 + ELSE
41 + IF old_active THEN
42 + UPDATE threads SET post_count = post_count - 1 WHERE id = OLD.thread_id;
43 + END IF;
44 + IF new_active THEN
45 + UPDATE threads SET post_count = post_count + 1 WHERE id = NEW.thread_id;
46 + END IF;
47 + END IF;
48 + RETURN NEW;
49 + END IF;
50 + END;
51 + $$ LANGUAGE plpgsql;
52 +
53 + -- Recreate the trigger so it also fires on deleted_at changes.
54 + DROP TRIGGER IF EXISTS trg_posts_maintain_thread_post_count ON posts;
55 + CREATE TRIGGER trg_posts_maintain_thread_post_count
56 + AFTER INSERT OR DELETE OR UPDATE OF removed_at, deleted_at, thread_id ON posts
57 + FOR EACH ROW EXECUTE FUNCTION threads_maintain_post_count();
58 +
59 + -- Re-backfill against the active definition (both columns null). No-op while
60 + -- deleted_at is unused, but keeps the column authoritative if that changes.
61 + UPDATE threads t
62 + SET post_count = (
63 + SELECT COUNT(*) FROM posts p
64 + WHERE p.thread_id = t.id
65 + AND p.removed_at IS NULL
66 + AND p.deleted_at IS NULL
67 + );
@@ -43,13 +43,65 @@ pub fn constant_time_compare(a: &str, b: &str) -> bool {
43 43 == 0
44 44 }
45 45
46 - /// Middleware: validate X-CSRF-Token header on POST/PUT/PATCH/DELETE.
46 + /// Extract a single field value from an `application/x-www-form-urlencoded`
47 + /// body, decoding `+` and `%XX` escapes. Returns the first match. Kept
48 + /// dependency-free; only used on the no-JS CSRF fallback path.
49 + fn extract_form_field(bytes: &[u8], key: &str) -> Option<String> {
50 + for pair in bytes.split(|&b| b == b'&') {
51 + let mut it = pair.splitn(2, |&b| b == b'=');
52 + let k = it.next().unwrap_or(b"");
53 + if k == key.as_bytes() {
54 + return Some(percent_decode_form(it.next().unwrap_or(b"")));
55 + }
56 + }
57 + None
58 + }
59 +
60 + /// Decode a urlencoded form value: `+` → space, `%XX` → byte, lossy UTF-8.
61 + fn percent_decode_form(input: &[u8]) -> String {
62 + let mut out = Vec::with_capacity(input.len());
63 + let mut i = 0;
64 + while i < input.len() {
65 + match input[i] {
66 + b'+' => out.push(b' '),
67 + b'%' if i + 2 < input.len() => {
68 + let hi = (input[i + 1] as char).to_digit(16);
69 + let lo = (input[i + 2] as char).to_digit(16);
70 + if let (Some(hi), Some(lo)) = (hi, lo) {
71 + out.push((hi * 16 + lo) as u8);
72 + i += 3;
73 + continue;
74 + }
75 + out.push(b'%');
76 + }
77 + b => out.push(b),
78 + }
79 + i += 1;
80 + }
81 + String::from_utf8_lossy(&out).into_owned()
82 + }
83 +
84 + /// Max body size we'll buffer to recover a CSRF token from a form field on the
85 + /// no-JS fallback path. Forms are tiny; anything larger isn't a urlencoded form
86 + /// we'd be parsing a token out of.
87 + const MAX_FORM_FALLBACK_BYTES: usize = 64 * 1024;
88 +
89 + /// Middleware: validate the CSRF token on POST/PUT/PATCH/DELETE.
90 + ///
91 + /// Two delivery paths, in order:
92 + /// 1. `X-CSRF-Token` header (set by mt.js for every fetch/HTMX request). This
93 + /// is the fast path — the request body is never touched.
94 + /// 2. A hidden `csrf_token` form field, for graceful degradation when mt.js
95 + /// didn't run (JS disabled, asset failure). Only urlencoded bodies are
96 + /// inspected, and only when the header is absent; multipart uploads remain
97 + /// header-only. The body is buffered, validated, and re-attached so the
98 + /// downstream handler still sees it.
47 99 ///
48 100 /// `/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.
101 + /// `refresh`) are same-origin forms that carry the token, so they get CSRF
102 + /// protection like everything else. `login`/`callback` are GET and so never
103 + /// reach this check. `/_test/` is only ever mounted by the integration harness
104 + /// (never in production); `/api/health` is GET-only.
53 105 pub async fn csrf_middleware(request: Request, next: Next) -> Response {
54 106 let method = request.method().clone();
55 107
@@ -72,35 +124,63 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response {
72 124 }
73 125 };
74 126
75 - let provided_token = request
127 + let session_token: Option<String> = session.get(CSRF_SESSION_KEY).await.ok().flatten();
128 +
129 + // Fast path: token in the header (mt.js). Body untouched.
130 + if let Some(header_token) = request
76 131 .headers()
77 132 .get("X-CSRF-Token")
78 133 .and_then(|v| v.to_str().ok())
79 - .map(|s| s.to_string());
134 + .map(|s| s.to_string())
135 + {
136 + return match session_token {
137 + Some(ref expected) if constant_time_compare(expected, &header_token) => {
138 + next.run(request).await
139 + }
140 + _ => {
141 + tracing::warn!(path = %path, "CSRF token mismatch");
142 + (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
143 + }
144 + };
145 + }
80 146
81 - let token = match provided_token {
82 - Some(t) => t,
83 - None => {
84 - tracing::warn!(path = %path, "CSRF token missing");
147 + // Fallback path: no header. Accept a hidden `csrf_token` form field from a
148 + // urlencoded body so the site degrades gracefully without JS.
149 + let is_form = request
150 + .headers()
151 + .get(axum::http::header::CONTENT_TYPE)
152 + .and_then(|v| v.to_str().ok())
153 + .map(|ct| ct.starts_with("application/x-www-form-urlencoded"))
154 + .unwrap_or(false);
155 +
156 + if !is_form {
157 + tracing::warn!(path = %path, "CSRF token missing");
158 + return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
159 + }
160 +
161 + let (parts, body) = request.into_parts();
162 + let bytes = match axum::body::to_bytes(body, MAX_FORM_FALLBACK_BYTES).await {
163 + Ok(b) => b,
164 + Err(_) => {
165 + tracing::warn!(path = %path, "CSRF fallback: body too large or unreadable");
85 166 return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
86 167 }
87 168 };
88 169
89 - let session_token: Option<String> = session
90 - .get(CSRF_SESSION_KEY)
91 - .await
92 - .ok()
93 - .flatten();
170 + let form_token = extract_form_field(&bytes, "csrf_token");
94 171
95 - match session_token {
96 - Some(ref expected) if constant_time_compare(expected, &token) => {
97 - next.run(request).await
98 - }
99 - _ => {
100 - tracing::warn!(path = %path, "CSRF token mismatch");
101 - (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
102 - }
172 + let valid = matches!(
173 + (&session_token, &form_token),
174 + (Some(expected), Some(provided)) if constant_time_compare(expected, provided)
175 + );
176 +
177 + if !valid {
178 + tracing::warn!(path = %path, "CSRF token mismatch (form fallback)");
179 + return (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response();
103 180 }
181 +
182 + next.run(Request::from_parts(parts, axum::body::Body::from(bytes)))
183 + .await
104 184 }
105 185
106 186 #[cfg(test)]
@@ -129,4 +209,26 @@ mod tests {
129 209 assert!(!constant_time_compare("", "a"));
130 210 assert!(constant_time_compare("", ""));
131 211 }
212 +
213 + #[test]
214 + fn extract_form_field_finds_token() {
215 + let body = b"username=alice&csrf_token=deadbeef&duration=1h";
216 + assert_eq!(extract_form_field(body, "csrf_token").as_deref(), Some("deadbeef"));
217 + assert_eq!(extract_form_field(body, "username").as_deref(), Some("alice"));
218 + assert_eq!(extract_form_field(body, "missing"), None);
219 + }
220 +
221 + #[test]
222 + fn extract_form_field_decodes_escapes() {
223 + let body = b"reason=a+b%2Fc&csrf_token=abc123";
224 + assert_eq!(extract_form_field(body, "reason").as_deref(), Some("a b/c"));
225 + assert_eq!(extract_form_field(body, "csrf_token").as_deref(), Some("abc123"));
226 + }
227 +
228 + #[test]
229 + fn extract_form_field_handles_empty_and_valueless() {
230 + assert_eq!(extract_form_field(b"", "csrf_token"), None);
231 + assert_eq!(extract_form_field(b"csrf_token=", "csrf_token").as_deref(), Some(""));
232 + assert_eq!(extract_form_field(b"csrf_token", "csrf_token").as_deref(), Some(""));
233 + }
132 234 }
@@ -44,18 +44,39 @@ const MAX_FUTURE_SKEW_SECS: i64 = 5;
44 44 /// by (request rate × window), and the internal rate limiter caps that. Nonces
45 45 /// are inserted only AFTER the signature verifies, so unauthenticated traffic
46 46 /// can't poison or grow the cache.
47 - static NONCE_CACHE: LazyLock<Mutex<HashMap<String, i64>>> =
48 - LazyLock::new(|| Mutex::new(HashMap::new()));
47 + struct NonceCache {
48 + seen: HashMap<String, i64>,
49 + /// Unix time of the last full sweep; the O(n) `retain` runs at most once per
50 + /// window rather than on every insert.
51 + last_sweep: i64,
52 + }
53 +
54 + static NONCE_CACHE: LazyLock<Mutex<NonceCache>> = LazyLock::new(|| {
55 + Mutex::new(NonceCache {
56 + seen: HashMap::new(),
57 + last_sweep: 0,
58 + })
59 + });
49 60
50 61 /// Record a nonce as seen. Returns `false` if it was already present within the
51 - /// window (a replay). Sweeps aged entries opportunistically.
62 + /// window (a replay).
63 + ///
64 + /// Eviction is time-bucketed: the O(n) sweep of aged entries runs at most once
65 + /// per freshness window, not on every call, so the hot internal path stays
66 + /// effectively O(1) under the lock. Keeping an aged entry slightly longer is
67 + /// harmless — a request old enough to evict is already rejected by the timestamp
68 + /// freshness check before it ever reaches here, so it can't be the nonce we'd
69 + /// have swept. Worst-case memory is ~2× the window's traffic instead of 1×.
52 70 fn record_nonce(nonce: &str, now_unix: i64) -> bool {
53 71 let mut cache = NONCE_CACHE.lock().unwrap_or_else(|e| e.into_inner());
54 - cache.retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS);
55 - if cache.contains_key(nonce) {
72 + if now_unix - cache.last_sweep >= MAX_TIMESTAMP_AGE_SECS {
73 + cache.seen.retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS);
74 + cache.last_sweep = now_unix;
75 + }
76 + if cache.seen.contains_key(nonce) {
56 77 return false;
57 78 }
58 - cache.insert(nonce.to_string(), now_unix);
79 + cache.seen.insert(nonce.to_string(), now_unix);
59 80 true
60 81 }
61 82
@@ -45,15 +45,30 @@ fn validate_url(url: &str) -> bool {
45 45 .or_else(|| lower.strip_prefix("https://"))
46 46 .unwrap_or("");
47 47 let host_and_port = host_part.split('/').next().unwrap_or("");
48 - let host = if host_and_port.starts_with('[') {
49 - host_and_port
50 - .split(']')
51 - .next()
52 - .map(|s| format!("{}]", s))
53 - .unwrap_or_default()
48 + // Split host and optional port (`[ipv6]:port` or `host:port`).
49 + let (host, port) = if host_and_port.starts_with('[') {
50 + match host_and_port.split_once(']') {
51 + Some((h, rest)) => (
52 + format!("{}]", h),
53 + rest.strip_prefix(':').filter(|p| !p.is_empty()),
54 + ),
55 + None => (host_and_port.to_string(), None),
56 + }
54 57 } else {
55 - host_and_port.split(':').next().unwrap_or("").to_string()
58 + match host_and_port.split_once(':') {
59 + Some((h, p)) => (h.to_string(), Some(p).filter(|p| !p.is_empty())),
60 + None => (host_and_port.to_string(), None),
61 + }
56 62 };
63 + // Only allow the standard web ports. A public host that legitimately serves
64 + // OG metadata does so on 80/443; an explicit non-web port (e.g. :6379) means
65 + // the link is trying to use us as a request-forwarder to some other service.
66 + if let Some(p) = port
67 + && p != "80"
68 + && p != "443"
69 + {
70 + return false;
71 + }
57 72 let host = host.as_str();
58 73
59 74 // Quick string-based check for common private patterns
@@ -115,9 +115,20 @@ async fn main() {
115 115 ))
116 116 .with_secure(state.config.cookie_secure);
117 117
118 - let app = multithreaded::routes::forum_routes(state.clone())
118 + // CSRF + session are scoped to the forum routes only; the internal API uses
119 + // HMAC auth and must not run them.
120 + let forum = multithreaded::routes::forum_routes(state.clone())
119 121 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
120 - .layer(session_layer)
122 + .layer(session_layer);
123 +
124 + // Security headers (CSP, nosniff, X-Frame, cache-control) wrap the WHOLE app,
125 + // applied outermost so `/static` assets and the internal API get them too —
126 + // not just forum routes (the layers used to sit inside `forum_routes`, before
127 + // the merge/nest, so static responses shipped without nosniff/X-Frame/CSP).
128 + let app = forum
129 + // Internal API routes — HMAC auth only, no CSRF/session middleware
130 + .merge(multithreaded::routes::internal::internal_routes(state))
131 + .nest_service("/static", ServeDir::new("static"))
121 132 .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
122 133 axum::http::header::CONTENT_SECURITY_POLICY,
123 134 axum::http::HeaderValue::from_static(
@@ -135,10 +146,7 @@ async fn main() {
135 146 .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(
136 147 axum::http::header::CACHE_CONTROL,
137 148 axum::http::HeaderValue::from_static("private, no-cache"),
138 - ))
139 - // Internal API routes — HMAC auth only, no CSRF/session middleware
140 - .merge(multithreaded::routes::internal::internal_routes(state))
141 - .nest_service("/static", ServeDir::new("static"));
149 + ));
142 150
143 151 // Default to loopback. Rate limiting uses TrustedProxyKeyExtractor, which
144 152 // honors X-Forwarded-For only from a configured trusted proxy (TRUSTED_PROXIES,
@@ -126,6 +126,12 @@ pub(in crate::routes) async fn add_footnote_handler(
126 126
127 127 // Check write access (suspension + ban + mute)
128 128 let community = get_community(&state.db, &slug).await?;
129 + // Authorize against the post's *own* community, not just the URL slug, so a
130 + // user banned/muted in the post's community can't route the footnote through
131 + // a different slug to evade the check (matches the flag/endorse/remove paths).
132 + if post_data.community_id != community.id {
133 + return Err((StatusCode::NOT_FOUND, "Not found").into_response());
134 + }
129 135
130 136 check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
131 137 check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
@@ -19,7 +19,7 @@ use mt_core::types::{SortColumn, SortOrder};
19 19
20 20 use super::super::{
21 21 check_community_access, get_community, get_role, is_mod_or_owner, is_owner,
22 - parse_uuid, template_user, CategoryQuery, ForumDirectoryQuery,
22 + parse_uuid, template_user, CategoryQuery, ForumDirectoryQuery, PageQuery,
23 23 };
24 24
25 25 /// Forum directory — lists local communities (paginated).
@@ -137,6 +137,7 @@ pub(in crate::routes) async fn project_forum(
137 137 pub(in crate::routes) async fn community_members(
138 138 axum::extract::State(state): axum::extract::State<AppState>,
139 139 Path(slug): Path<String>,
140 + Query(page_query): Query<PageQuery>,
140 141 session: Session,
141 142 MaybeUser(session_user): MaybeUser,
142 143 ) -> Result<impl IntoResponse, Response> {
@@ -145,7 +146,17 @@ pub(in crate::routes) async fn community_members(
145 146
146 147 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
147 148
148 - let db_members = mt_db::queries::list_community_members(&state.db, community.id, 500, 0)
149 + let per_page: i64 = 100;
150 + let total = mt_db::queries::count_community_members(&state.db, community.id)
151 + .await
152 + .map_err(|e| {
153 + tracing::error!(error = ?e, "db error counting members");
154 + crate::error_page::internal_error()
155 + })?;
156 + let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
157 + let offset = pagination.offset(per_page);
158 +
159 + let db_members = mt_db::queries::list_community_members(&state.db, community.id, per_page, offset)
149 160 .await
150 161 .map_err(|e| {
151 162 tracing::error!(error = ?e, "db error listing members");
@@ -171,6 +182,7 @@ pub(in crate::routes) async fn community_members(
171 182 community_name: community.name,
172 183 community_slug: slug,
173 184 members,
185 + pagination,
174 186 })
175 187 }
176 188
@@ -178,12 +178,19 @@ pub(super) async fn moderation_page(
178 178 tracing::error!(error = %e, "failed to clean up expired bans");
179 179 }
180 180
181 - let db_bans = mt_db::queries::list_community_bans(&state.db, community.id)
181 + // Cap both moderation reads so a large ban/flag backlog can't make one page
182 + // load materialize an unbounded result set. Fetch CAP+1 to detect whether
183 + // more exist than we show, then surface that rather than silently truncating.
184 + const MOD_LIST_CAP: usize = 200;
185 +
186 + let mut db_bans = mt_db::queries::list_community_bans(&state.db, community.id, MOD_LIST_CAP as i64 + 1)
182 187 .await
183 188 .map_err(|e| {
184 189 tracing::error!(error = ?e, "db error listing bans");
185 190 StatusCode::INTERNAL_SERVER_ERROR.into_response()
186 191 })?;
192 + let bans_truncated = db_bans.len() > MOD_LIST_CAP;
193 + db_bans.truncate(MOD_LIST_CAP);
187 194
188 195 let bans = db_bans
189 196 .into_iter()
@@ -198,12 +205,14 @@ pub(super) async fn moderation_page(
198 205 })
199 206 .collect();
200 207
201 - let db_flags = mt_db::queries::list_pending_flags(&state.db, community.id)
208 + let mut db_flags = mt_db::queries::list_pending_flags(&state.db, community.id, MOD_LIST_CAP as i64 + 1)
202 209 .await
203 210 .map_err(|e| {
204 211 tracing::error!(error = ?e, "db error listing flags");
205 212 StatusCode::INTERNAL_SERVER_ERROR.into_response()
206 213 })?;
214 + let flags_truncated = db_flags.len() > MOD_LIST_CAP;
215 + db_flags.truncate(MOD_LIST_CAP);
207 216
208 217 let pending_flags = db_flags
209 218 .into_iter()
@@ -227,7 +236,9 @@ pub(super) async fn moderation_page(
227 236 community_name: community.name,
228 237 community_slug: slug,
229 238 bans,
239 + bans_truncated,
230 240 pending_flags,
241 + flags_truncated,
231 242 is_owner: is_owner(&role),
232 243 })
233 244 }
@@ -14,6 +14,14 @@ pub struct S3Storage {
14 14 /// Maximum image size: 5 MB.
15 15 pub const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024;
16 16
17 + /// Maximum decoded image dimensions (pixels). The 5 MB byte cap does NOT bound
18 + /// the decoded pixel count — a lossless PNG/WebP of a solid color compresses a
19 + /// gigapixel canvas into a few KB (a decompression / "pixel bomb"). We never
20 + /// decode server-side, but a viewer's browser would, so we reject absurd
21 + /// declared dimensions up front. 50 MP comfortably clears any real photo
22 + /// (an 8000x6000 shot is 48 MP).
23 + pub const MAX_IMAGE_PIXELS: u64 = 50_000_000;
24 +
17 25 /// Allowed image content types.
18 26 const ALLOWED_CONTENT_TYPES: &[&str] = &[
19 27 "image/png",
@@ -102,6 +110,92 @@ pub fn sniff_image_format(data: &[u8]) -> Option<&'static str> {
102 110 None
103 111 }
104 112
113 + /// Read the declared pixel dimensions `(width, height)` straight from the format
114 + /// header, without decoding the image. Returns `None` if the header is too short
115 + /// or the dimensions can't be located — callers treat that as "can't tell",
116 + /// which is safe because the format was already authenticated by
117 + /// [`sniff_image_format`] and a real pixel bomb carries parseable dimensions.
118 + fn image_dimensions(format: &str, data: &[u8]) -> Option<(u32, u32)> {
119 + let be16 = |i: usize| -> Option<u32> {
120 + Some(u32::from(u16::from_be_bytes([*data.get(i)?, *data.get(i + 1)?])))
121 + };
122 + let le16 = |i: usize| -> Option<u32> {
123 + Some(u32::from(u16::from_le_bytes([*data.get(i)?, *data.get(i + 1)?])))
124 + };
125 + match format {
126 + // PNG: IHDR is the first chunk; width/height are big-endian u32 at 16/20.
127 + "png" => {
128 + let w = u32::from_be_bytes([
129 + *data.get(16)?, *data.get(17)?, *data.get(18)?, *data.get(19)?,
130 + ]);
131 + let h = u32::from_be_bytes([
132 + *data.get(20)?, *data.get(21)?, *data.get(22)?, *data.get(23)?,
133 + ]);
134 + Some((w, h))
135 + }
136 + // GIF: logical-screen width/height are little-endian u16 at 6/8.
137 + "gif" => Some((le16(6)?, le16(8)?)),
138 + // JPEG: scan segments for a Start-Of-Frame marker (C0-CF except the
139 + // non-SOF C4/C8/CC); height/width are big-endian u16 right after the
140 + // 3-byte (length + precision) preamble.
141 + "jpg" => {
142 + let mut i = 2; // skip SOI (FF D8)
143 + while i + 9 < data.len() {
144 + if data[i] != 0xFF {
145 + i += 1;
146 + continue;
147 + }
148 + let marker = data[i + 1];
149 + if (0xC0..=0xCF).contains(&marker)
150 + && marker != 0xC4 && marker != 0xC8 && marker != 0xCC
151 + {
152 + return Some((be16(i + 7)?, be16(i + 5)?));
153 + }
154 + // Standalone markers (RSTn, SOI, EOI, TEM) carry no length.
155 + if marker == 0xD8 || marker == 0xD9 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 {
156 + i += 2;
157 + continue;
158 + }
159 + let seg_len = be16(i + 2)? as usize;
160 + if seg_len < 2 {
161 + return None;
162 + }
163 + i += 2 + seg_len;
164 + }
165 + None
166 + }
167 + // WebP: three sub-chunk layouts after the "WEBP" tag at byte 12.
168 + "webp" => match data.get(12..16)? {
169 + b"VP8X" => {
170 + // Canvas width/height minus one, 24-bit little-endian at 24/27.
171 + let w = 1 + (u32::from(*data.get(24)?)
172 + | u32::from(*data.get(25)?) << 8
173 + | u32::from(*data.get(26)?) << 16);
174 + let h = 1 + (u32::from(*data.get(27)?)
175 + | u32::from(*data.get(28)?) << 8
176 + | u32::from(*data.get(29)?) << 16);
177 + Some((w, h))
178 + }
179 + b"VP8L" => {
180 + // 14-bit width/height minus one, packed after the 0x2F signature.
181 + let b = data.get(21..25)?;
182 + let bits = u32::from(b[0])
183 + | u32::from(b[1]) << 8
184 + | u32::from(b[2]) << 16
185 + | u32::from(b[3]) << 24;
186 + Some((1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)))
187 + }
188 + b"VP8 " => {
189 + // Lossy: 14-bit width/height little-endian at 26/28 (after the
190 + // 3-byte start code 9D 01 2A).
191 + Some((le16(26)? & 0x3FFF, le16(28)? & 0x3FFF))
192 + }
193 + _ => None,
194 + },
195 + _ => None,
196 + }
197 + }
198 +
105 199 /// Validate an uploaded file. Returns the sanitized extension and content type.
106 200 ///
107 201 /// Defence in depth: the filename extension and the multipart Content-Type must
@@ -158,7 +252,17 @@ pub fn validate_image(
158 252
159 253 // Authoritative check: the bytes themselves must be the declared format.
160 254 match sniff_image_format(data) {
161 - Some(sniffed) if sniffed == ext_str => Ok((ext_str, ct)),
255 + Some(sniffed) if sniffed == ext_str => {
256 + // Reject decompression / pixel bombs: a tiny file can still declare a
257 + // gigapixel canvas that detonates in a viewer's browser. If we can
258 + // read the dimensions and they exceed the cap, refuse.
259 + if let Some((w, h)) = image_dimensions(ext_str, data)
260 + && u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS
261 + {
262 + return Err("Image dimensions are too large.");
263 + }
264 + Ok((ext_str, ct))
265 + }
162 266 Some(_) => Err("File contents do not match the declared image type."),
163 267 None => Err("File is not a valid PNG, JPEG, GIF, or WebP image."),
164 268 }
@@ -284,6 +388,26 @@ mod tests {
284 388 }
285 389
286 390 #[test]
391 + fn validate_rejects_png_pixel_bomb() {
392 + // A tiny, valid-looking PNG that declares a 30000x30000 (900 MP) canvas.
393 + let mut bomb = png_bytes();
394 + let w = 30_000u32.to_be_bytes();
395 + let h = 30_000u32.to_be_bytes();
396 + bomb[16..20].copy_from_slice(&w);
397 + bomb[20..24].copy_from_slice(&h);
398 + let err = validate_image("bomb.png", "image/png", &bomb).unwrap_err();
399 + assert!(err.contains("dimensions are too large"));
400 + }
401 +
402 + #[test]
403 + fn validate_accepts_reasonable_png_dimensions() {
404 + let mut img = png_bytes();
405 + img[16..20].copy_from_slice(&1920u32.to_be_bytes());
406 + img[20..24].copy_from_slice(&1080u32.to_be_bytes());
407 + assert!(validate_image("ok.png", "image/png", &img).is_ok());
408 + }
409 +
410 + #[test]
287 411 fn validate_rejects_empty() {
288 412 let err = validate_image("photo.png", "image/png", &[]).unwrap_err();
289 413 assert!(err.contains("Empty"));
@@ -442,6 +442,7 @@ pub struct MembersTemplate {
442 442 pub community_name: String,
443 443 pub community_slug: String,
444 444 pub members: Vec<MemberListRow>,
445 + pub pagination: Pagination,
445 446 }
446 447
447 448 /// Activity row for user profile page.
@@ -593,7 +594,11 @@ pub struct ModerationTemplate {
593 594 pub community_name: String,
594 595 pub community_slug: String,
595 596 pub bans: Vec<BanListRow>,
597 + /// True when more active bans exist than the page renders (capped read).
598 + pub bans_truncated: bool,
596 599 pub pending_flags: Vec<FlagViewRow>,
600 + /// True when more pending flags exist than the page renders (capped read).
601 + pub flags_truncated: bool,
597 602 pub is_owner: bool,
598 603 }
599 604
@@ -19,6 +19,7 @@
19 19
20 20 {% if has_plus %}
21 21 <form method="post" action="/account/signature" class="form-container">
22 + {% include "partials/csrf_input.html" %}
22 23 <div class="form-group">
23 24 <label for="signature">Signature (markdown, up to 1024 characters)</label>
24 25 <textarea id="signature" name="signature" class="textarea-medium" maxlength="1024">{{ signature_markdown.as_deref().unwrap_or("") }}</textarea>
@@ -51,6 +52,7 @@
51 52 <li>+ perks available: <strong>{% if has_plus %}yes{% else %}no{% endif %}</strong></li>
52 53 </ul>
53 54 <form method="post" action="/auth/refresh" class="form-inline">
55 + {% include "partials/csrf_input.html" %}
54 56 <button type="submit" class="secondary">Refresh from MNW</button>
55 57 </form>
56 58 <span class="form-help">
@@ -55,10 +55,12 @@
55 55 <td class="settings-actions">
56 56 {% if user.is_suspended %}
57 57 <form method="post" action="/_admin/users/{{ user.id }}/unsuspend" class="form-inline">
58 + {% include "partials/csrf_input.html" %}
58 59 <button type="submit" class="post-action-link">unsuspend</button>
59 60 </form>
60 61 {% else %}
61 62 <form method="post" action="/_admin/users/{{ user.id }}/suspend" class="form-inline-row">
63 + {% include "partials/csrf_input.html" %}
62 64 <input type="text" name="reason" placeholder="Reason" class="input-compact">
63 65 <button type="submit" class="post-action-link post-action-delete">suspend</button>
64 66 </form>
@@ -100,10 +102,12 @@
100 102 <td class="settings-actions">
101 103 {% if c.is_suspended %}
102 104 <form method="post" action="/_admin/communities/{{ c.id }}/unsuspend" class="form-inline">
105 + {% include "partials/csrf_input.html" %}
103 106 <button type="submit" class="post-action-link">unsuspend</button>
104 107 </form>
105 108 {% else %}
106 109 <form method="post" action="/_admin/communities/{{ c.id }}/suspend" class="form-inline-row">
110 + {% include "partials/csrf_input.html" %}
107 111 <input type="text" name="reason" placeholder="Reason" class="input-compact">
108 112 <button type="submit" class="post-action-link post-action-delete">suspend</button>
109 113 </form>
@@ -40,6 +40,7 @@
40 40 settings page; admin changes are logged the same way.
41 41 </p>
42 42 <form method="post" action="/p/{{ community_slug }}/settings/state" class="form-container">
43 + {% include "partials/csrf_input.html" %}
43 44 <div class="form-group">
44 45 <label for="state">State</label>
45 46 <select id="state" name="state">
@@ -67,6 +68,7 @@
67 68 <code>{{ community_slug }}</code> in the box below.
68 69 </p>
69 70 <form method="post" action="/_admin/communities/{{ community_slug }}/clean-slate" class="form-container">
71 + {% include "partials/csrf_input.html" %}
70 72 <div class="form-group">
71 73 <label for="confirm">Confirmation</label>
72 74 <input type="text" id="confirm" name="confirm" placeholder="{{ community_slug }}" autocomplete="off" required>
@@ -21,6 +21,7 @@
21 21 <h3>Community Details</h3>
22 22 <div class="form-container">
23 23 <form method="post" action="/p/{{ community_slug }}/settings">
24 + {% include "partials/csrf_input.html" %}
24 25 <div class="form-group">
25 26 <label for="name">Name</label>
26 27 <input type="text" id="name" name="name" value="{{ community_name }}" required>
@@ -70,12 +71,14 @@
70 71 <a href="/p/{{ community_slug }}/settings/categories/{{ cat.id }}/edit" class="post-action-link">edit</a>
71 72 {% if !cat.is_first %}
72 73 <form method="post" action="/p/{{ community_slug }}/settings/categories/{{ cat.id }}/move" class="form-inline">
74 + {% include "partials/csrf_input.html" %}
73 75 <input type="hidden" name="direction" value="up">
74 76 <button type="submit" class="post-action-link">up</button>
75 77 </form>
76 78 {% endif %}
77 79 {% if !cat.is_last %}
78 80 <form method="post" action="/p/{{ community_slug }}/settings/categories/{{ cat.id }}/move" class="form-inline">
81 + {% include "partials/csrf_input.html" %}
79 82 <input type="hidden" name="direction" value="down">
80 83 <button type="submit" class="post-action-link">down</button>
81 84 </form>
@@ -90,6 +93,7 @@
90 93 <h3 class="section-heading-top">Add Category</h3>
91 94 <div class="form-container">
92 95 <form method="post" action="/p/{{ community_slug }}/settings/categories/new">
96 + {% include "partials/csrf_input.html" %}
93 97 <div class="form-row">
94 98 <div class="form-group">
95 99 <label for="cat-name">Name</label>
@@ -131,6 +135,7 @@
131 135 <td class="settings-mono">{{ tag.slug }}</td>
132 136 <td class="settings-actions">
133 137 <form method="post" action="/p/{{ community_slug }}/settings/tags/delete" class="form-inline" data-confirm="Delete this tag?">
138 + {% include "partials/csrf_input.html" %}
134 139 <input type="hidden" name="tag_id" value="{{ tag.id }}">
135 140 <button type="submit" class="post-action-link post-action-delete">delete</button>
136 141 </form>
@@ -144,6 +149,7 @@
144 149 <h3 class="section-heading-top">Add Tag</h3>
145 150 <div class="form-container">
146 151 <form method="post" action="/p/{{ community_slug }}/settings/tags/new">
152 + {% include "partials/csrf_input.html" %}
147 153 <div class="form-row">
148 154 <div class="form-group">
149 155 <label for="tag-name">Name</label>
@@ -20,6 +20,7 @@
20 20 <h2 class="section-heading">Edit Category</h2>
21 21 <div class="form-container">
22 22 <form method="post" action="/p/{{ community_slug }}/settings/categories/{{ category_id }}/edit">
23 + {% include "partials/csrf_input.html" %}
23 24 <div class="form-group">
24 25 <label for="name">Name</label>
25 26 <input type="text" id="name" name="name" value="{{ category_name }}" required>
@@ -22,6 +22,7 @@
22 22 <h2 class="section-heading">Edit Thread Title</h2>
23 23 <div class="form-container">
24 24 <form method="post" action="/p/{{ community_slug }}/{{ category_slug }}/{{ thread_id }}/edit">
25 + {% include "partials/csrf_input.html" %}
25 26 <div class="form-group">
26 27 <label for="title">Title</label>
27 28 <input type="text" id="title" name="title" value="{{ current_title }}" required maxlength="256">
@@ -39,6 +39,7 @@
39 39 {% endfor %}
40 40 </tbody>
41 41 </table>
42 + {% include "partials/pagination.html" %}
42 43 {% endif %}
43 44 </div>
44 45 {% endblock %}
@@ -23,6 +23,7 @@
23 23 <div class="settings-section">
24 24 <h3>Ban User</h3>
25 25 <form method="post" action="/p/{{ community_slug }}/moderation/ban" class="form-container">
26 + {% include "partials/csrf_input.html" %}
26 27 <div class="form-row">
27 28 <div class="form-group">
28 29 <label for="ban-username">Username</label>
@@ -52,6 +53,7 @@
52 53 <div class="settings-section">
53 54 <h3>Mute User</h3>
54 55 <form method="post" action="/p/{{ community_slug }}/moderation/mute" class="form-container">
56 + {% include "partials/csrf_input.html" %}
55 57 <div class="form-row">
56 58 <div class="form-group">
57 59 <label for="mute-username">Username</label>
@@ -104,9 +106,11 @@
104 106 <td class="settings-mono">{{ flag.created }}</td>
105 107 <td class="settings-actions">
106 108 <form method="post" action="/p/{{ community_slug }}/moderation/flags/{{ flag.flag_id }}/dismiss" class="form-inline">
109 + {% include "partials/csrf_input.html" %}
107 110 <button type="submit" class="post-action-link">dismiss</button>
108 111 </form>
109 112 <form method="post" action="/p/{{ community_slug }}/moderation/flags/{{ flag.flag_id }}/remove" class="form-inline" data-confirm="Remove this post and resolve all flags on it?">
113 + {% include "partials/csrf_input.html" %}
110 114 <button type="submit" class="post-action-link post-action-delete">remove post</button>
111 115 </form>
112 116 </td>
@@ -114,6 +118,9 @@
114 118 {% endfor %}
115 119 </tbody>
116 120 </table>
121 + {% if flags_truncated %}
122 + <p class="empty-state">Showing the most recent flags only. Resolve some to reveal older ones.</p>
123 + {% endif %}
117 124 {% endif %}
118 125 </div>
119 126
@@ -150,11 +157,13 @@
150 157 <td class="settings-actions">
151 158 {% if ban.ban_type == "ban" %}
152 159 <form method="post" action="/p/{{ community_slug }}/moderation/unban" class="form-inline">
160 + {% include "partials/csrf_input.html" %}
153 161 <input type="hidden" name="username" value="{{ ban.username }}">
154 162 <button type="submit" class="post-action-link">unban</button>
155 163 </form>
156 164 {% else %}
157 165 <form method="post" action="/p/{{ community_slug }}/moderation/unmute" class="form-inline">
166 + {% include "partials/csrf_input.html" %}
158 167 <input type="hidden" name="username" value="{{ ban.username }}">
159 168 <button type="submit" class="post-action-link">unmute</button>
160 169 </form>
@@ -164,6 +173,9 @@
164 173 {% endfor %}
165 174 </tbody>
166 175 </table>
176 + {% if bans_truncated %}
177 + <p class="empty-state">Showing the most recent bans and mutes only. Lift some to reveal older ones.</p>
178 + {% endif %}
167 179 {% endif %}
168 180 </div>
169 181 </div>
@@ -20,6 +20,7 @@
20 20 <h2 class="section-heading">New Thread in {{ category_name }}</h2>
21 21 <div class="form-container">
22 22 <form method="post" action="/p/{{ community_slug }}/{{ category_slug }}/new">
23 + {% include "partials/csrf_input.html" %}
23 24 <div class="form-group">
24 25 <label for="title">Title</label>
25 26 <input type="text" id="title" name="title" placeholder="Thread title" required maxlength="256">