Skip to main content

max / makenotwork

Gate flagging and image upload on write-level authz Both called check_community_access, which sees only community suspension and community ban. check_write_access additionally checks platform suspension and community mute, so a user who could not post could still flag, and enough flags trip auto_hide_if_threshold_met on another member's post. Upload had the same gap: a suspended or muted user could store an S3 object, spend rate-limit budget, and get back a hosted URL. The two functions do not share a signature, so flagging destructures CommunityScope before the call to supply community.id and the suspension flag. Sibling audit of the remaining check_community_access callsites: the five in forum/views.rs plus forum/thread.rs and uploads.rs:198 are read handlers; tracking.rs documents its read-level access deliberately; forum/actions.rs (endorsement) checks suspension separately and exempts mute on purpose. All left as they are. Tests cover the flagging cases, both reproduce as a 303 without the fix. Upload is not covered: the harness has no S3, so the handler returns 503 before reaching the gate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-28 01:24 UTC
Signed with PGP, not checked
Commit: 1cd2823e2f1a26a387be792f59b95ef592ad48da
Parent: 6cda24f
3 files changed, +117 insertions, -4 deletions
@@ -14,7 +14,7 @@
14 14 use mt_core::types::{ModAction, ModActor};
15 15
16 16 use super::{
17 - CommunityScope, audit, begin_tx, check_community_access, commit_tx, field_error, parse_uuid,
17 + CommunityScope, audit, begin_tx, check_write_access, commit_tx, field_error, parse_uuid,
18 18 require_mod_or_owner,
19 19 };
20 20 use mt_db::queries::PostForEdit;
@@ -50,12 +50,23 @@
50 50 return Err((StatusCode::FORBIDDEN, "You cannot flag your own post.").into_response());
51 51 }
52 52
53 - check_community_access(&state.db, &scope.community, Some(user.user_id)).await?;
54 53 let CommunityScope {
55 54 community,
56 55 resource: post_data,
57 56 } = scope;
58 57
58 + // Flagging is a write, not a read: enough flags trip `auto_hide_if_threshold_met`
59 + // on someone else's post. Gate it on `check_write_access` so platform suspension
60 + // and community mute apply, a user who cannot post must not be able to flag
61 + // either (the read-level `check_community_access` sees neither).
62 + check_write_access(
63 + &state.db,
64 + community.id,
65 + user.user_id,
66 + community.suspended_at.is_some(),
67 + )
68 + .await?;
69 +
59 70 let detail = form.detail.as_deref().filter(|d| !d.trim().is_empty());
60 71
61 72 if let Some(d) = detail
@@ -14,7 +14,9 @@
14 14 use crate::auth::MaybeUser;
15 15 use crate::storage;
16 16
17 - use super::{check_community_access, db_error, get_community, get_role, is_mod_or_owner};
17 + use super::{
18 + check_community_access, check_write_access, db_error, get_community, get_role, is_mod_or_owner,
19 + };
18 20
19 21 /// Max uploads per user per hour.
20 22 const UPLOAD_RATE_LIMIT: i64 = 20;
@@ -39,7 +41,17 @@
39 41 })?;
40 42
41 43 let community = get_community(&state.db, &slug).await?;
42 - check_community_access(&state.db, &community, Some(user.user_id)).await?;
44 + // Uploading is a write: it stores an object, spends the caller's rate-limit
45 + // budget, and yields a hosted URL to embed in a post. Same gate as posting,
46 + // so a platform-suspended or muted user cannot stage images they are not
47 + // allowed to publish.
48 + check_write_access(
49 + &state.db,
50 + community.id,
51 + user.user_id,
52 + community.suspended_at.is_some(),
53 + )
54 + .await?;
43 55
44 56 // Check membership
45 57 let role = get_role(&state.db, user.user_id, community.id).await?;
@@ -1,4 +1,5 @@
1 1 use crate::harness::TestHarness;
2 + use mt_core::types::BanType;
2 3
3 4 // Auto-hide threshold tests
4 5
@@ -474,6 +475,95 @@
474 475 assert_eq!(resp.status.as_u16(), 403, "Flagging own post should be 403");
475 476 }
476 477
478 + // Flagging gates on write-level access, not read-level. A user who cannot post
479 + // must not be able to flag either, since enough flags trip the auto-hide
480 + // threshold on someone else's post. Both cases below passed under the old
481 + // `check_community_access` gate, which saw neither platform suspension nor mute.
482 +
483 + #[tokio::test]
484 + async fn platform_suspended_user_cannot_flag() {
485 + let mut h = TestHarness::new().await;
486 + let author_id = h.login_as("suspflagauthor").await;
487 + let comm_id = h.create_community("Test", "test").await;
488 + let cat_id = h.create_category(comm_id, "General", "general").await;
489 + h.add_membership(author_id, comm_id, "member").await;
490 +
491 + let thread_id = h
492 + .create_thread_with_post(cat_id, author_id, "Suspended Flag", "Content")
493 + .await;
494 +
495 + let flagger_id = h.login_as("suspflagger").await;
496 + h.add_membership(flagger_id, comm_id, "member").await;
497 +
498 + mt_db::mutations::suspend_user(&h.db, flagger_id, Some("test"))
499 + .await
500 + .unwrap();
501 +
502 + let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
503 + .await
504 + .unwrap();
505 + let post_id = posts[0].id;
506 +
507 + let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
508 + let resp = h.client.post_form(&flag_url, "reason=spam").await;
509 + assert_eq!(
510 + resp.status.as_u16(),
511 + 403,
512 + "A platform-suspended user must not be able to flag"
513 + );
514 +
515 + let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id)
516 + .await
517 + .unwrap();
518 + assert!(!has_flag, "No flag row should have been written");
519 + }
520 +
521 + #[tokio::test]
522 + async fn muted_user_cannot_flag() {
523 + let mut h = TestHarness::new().await;
524 + let author_id = h.login_as("muteflagauthor").await;
525 + let comm_id = h.create_community("Test", "test").await;
526 + let cat_id = h.create_category(comm_id, "General", "general").await;
527 + h.add_membership(author_id, comm_id, "member").await;
528 +
529 + let thread_id = h
530 + .create_thread_with_post(cat_id, author_id, "Muted Flag", "Content")
531 + .await;
532 +
533 + let flagger_id = h.login_as("muteflagger").await;
534 + h.add_membership(flagger_id, comm_id, "member").await;
535 +
536 + mt_db::mutations::create_community_ban(
537 + &h.db,
538 + comm_id,
539 + flagger_id,
540 + author_id,
541 + BanType::Mute,
542 + Some("test"),
543 + None,
544 + )
545 + .await
546 + .unwrap();
547 +
548 + let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
549 + .await
550 + .unwrap();
551 + let post_id = posts[0].id;
552 +
553 + let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
554 + let resp = h.client.post_form(&flag_url, "reason=spam").await;
555 + assert_eq!(
556 + resp.status.as_u16(),
557 + 403,
558 + "A muted user must not be able to flag"
559 + );
560 +
561 + let has_flag = mt_db::queries::has_user_flagged_post(&h.db, post_id, flagger_id)
562 + .await
563 + .unwrap();
564 + assert!(!has_flag, "No flag row should have been written");
565 + }
566 +
477 567 #[tokio::test]
478 568 async fn mod_dismiss_flag() {
479 569 let mut h = TestHarness::new().await;