max / makenotwork
3 files changed,
+70 insertions,
-7 deletions
| @@ -112,6 +112,13 @@ pub(crate) async fn check_write_access( | |||
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | /// Helper: fetch community + verify owner role, returning 403 if not owner. | |
| 115 | + | /// | |
| 116 | + | /// Also rejects a suspended community (403): a suspended-community owner must not | |
| 117 | + | /// be able to reach any settings route, GET or POST. All `require_owner` callers | |
| 118 | + | /// are owner-only settings handlers, so the suspension gate belongs here rather | |
| 119 | + | /// than duplicated per handler — see `docs/audit_review.md` (settings suspended | |
| 120 | + | /// authz gap). The platform admin acts on suspended communities via the `_admin` | |
| 121 | + | /// routes, not these. | |
| 115 | 122 | #[tracing::instrument(skip_all)] | |
| 116 | 123 | pub(crate) async fn require_owner( | |
| 117 | 124 | state: &AppState, | |
| @@ -123,6 +130,9 @@ pub(crate) async fn require_owner( | |||
| 123 | 130 | if !is_owner(&role) { | |
| 124 | 131 | return Err((StatusCode::FORBIDDEN, "Forbidden").into_response()); | |
| 125 | 132 | } | |
| 133 | + | if community.suspended_at.is_some() { | |
| 134 | + | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 135 | + | } | |
| 126 | 136 | Ok(community) | |
| 127 | 137 | } | |
| 128 | 138 |
| @@ -16,9 +16,9 @@ use crate::AppState; | |||
| 16 | 16 | use mt_core::types::{CommunityState, ModAction, ModActor}; | |
| 17 | 17 | ||
| 18 | 18 | use super::{ | |
| 19 | - | audit, begin_tx, commit_tx, db_error, parse_uuid, require_mod_or_superadmin, require_owner, template_user, | |
| 20 | - | validate_title, CreateCategoryForm, CreateTagForm, DeleteTagForm, EditCategoryFormData, | |
| 21 | - | MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, | |
| 19 | + | audit, begin_tx, commit_tx, db_error, is_platform_admin, parse_uuid, require_mod_or_superadmin, | |
| 20 | + | require_owner, template_user, validate_title, CreateCategoryForm, CreateTagForm, DeleteTagForm, | |
| 21 | + | EditCategoryFormData, MoveCategoryForm, SetCommunityStateForm, UpdateCommunityForm, | |
| 22 | 22 | }; | |
| 23 | 23 | ||
| 24 | 24 | #[tracing::instrument(skip_all)] | |
| @@ -29,12 +29,9 @@ pub(super) async fn community_settings( | |||
| 29 | 29 | RequireUser(user): RequireUser, | |
| 30 | 30 | ) -> Result<impl IntoResponse, Response> { | |
| 31 | 31 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 32 | + | // `require_owner` already 403s a suspended community. | |
| 32 | 33 | let community = require_owner(&state, &slug, &user).await?; | |
| 33 | 34 | ||
| 34 | - | if community.suspended_at.is_some() { | |
| 35 | - | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 36 | - | } | |
| 37 | - | ||
| 38 | 35 | let db_categories = mt_db::queries::list_categories_for_settings(&state.db, community.id) | |
| 39 | 36 | .await | |
| 40 | 37 | .map_err(db_error)?; | |
| @@ -357,6 +354,12 @@ pub(super) async fn set_community_state_handler( | |||
| 357 | 354 | ) -> Result<Redirect, Response> { | |
| 358 | 355 | let (community, _role) = require_mod_or_superadmin(&state, &slug, &user).await?; | |
| 359 | 356 | ||
| 357 | + | // A suspended community is frozen to its owner/mods: only the platform admin | |
| 358 | + | // (who manages suspensions via `_admin`) may still change its state. | |
| 359 | + | if community.suspended_at.is_some() && !is_platform_admin(&state, &user) { | |
| 360 | + | return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response()); | |
| 361 | + | } | |
| 362 | + | ||
| 360 | 363 | let new_state = CommunityState::from_db(form.state.trim()).ok_or_else(|| { | |
| 361 | 364 | ( | |
| 362 | 365 | StatusCode::UNPROCESSABLE_ENTITY, |
| @@ -17,6 +17,56 @@ async fn create_tag() { | |||
| 17 | 17 | assert_eq!(tags[0].slug, "bug"); | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | + | /// Regression (audit_review.md — settings suspended authz gap): a suspended | |
| 21 | + | /// community's owner must be blocked from *mutating* settings, not just from | |
| 22 | + | /// viewing the settings page. Prior to the fix `require_owner` let every write | |
| 23 | + | /// handler through because only the GET page checked `suspended_at`. | |
| 24 | + | #[tokio::test] | |
| 25 | + | async fn suspended_community_blocks_owner_settings_mutations() { | |
| 26 | + | let mut h = TestHarness::new().await; | |
| 27 | + | let owner_id = h.login_as("suspsettingsowner").await; | |
| 28 | + | let comm_id = h.create_community("Susp", "susp").await; | |
| 29 | + | h.add_membership(owner_id, comm_id, "owner").await; | |
| 30 | + | let cat_id = h.create_category(comm_id, "General", "general").await; | |
| 31 | + | ||
| 32 | + | // Grab a session CSRF token from the still-live settings page. | |
| 33 | + | let live = h.client.get("/p/susp/settings").await; | |
| 34 | + | assert!(live.status.is_success(), "settings page should render pre-suspension"); | |
| 35 | + | ||
| 36 | + | // Platform-admin suspends the community (simulated directly). | |
| 37 | + | sqlx::query("UPDATE communities SET suspended_at = now() WHERE id = $1") | |
| 38 | + | .bind(comm_id) | |
| 39 | + | .execute(&h.db) | |
| 40 | + | .await | |
| 41 | + | .unwrap(); | |
| 42 | + | ||
| 43 | + | // The GET page 403s (existing behavior)... | |
| 44 | + | assert_eq!(h.client.get("/p/susp/settings").await.status.as_u16(), 403); | |
| 45 | + | ||
| 46 | + | // ...and so must every owner-only mutation. | |
| 47 | + | let mutations: &[(&str, String)] = &[ | |
| 48 | + | ("/p/susp/settings", "name=Renamed&description=x&auto_hide_threshold=".to_string()), | |
| 49 | + | ("/p/susp/settings/categories/new", "name=Cat&slug=cat&description=".to_string()), | |
| 50 | + | (&format!("/p/susp/settings/categories/{cat_id}/edit"), "name=Renamed&description=".to_string()), | |
| 51 | + | (&format!("/p/susp/settings/categories/{cat_id}/move"), "direction=up".to_string()), | |
| 52 | + | ("/p/susp/settings/tags/new", "name=Bug&slug=bug".to_string()), | |
| 53 | + | ("/p/susp/settings/state", "state=frozen".to_string()), | |
| 54 | + | ]; | |
| 55 | + | for (path, body) in mutations { | |
| 56 | + | let resp = h.client.post_form(path, body).await; | |
| 57 | + | assert_eq!( | |
| 58 | + | resp.status.as_u16(), | |
| 59 | + | 403, | |
| 60 | + | "suspended community must 403 the mutation at {path}, got {}", | |
| 61 | + | resp.status | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Nothing leaked through: no tag was created, name unchanged. | |
| 66 | + | let tags = mt_db::queries::list_tags_for_community(&h.db, comm_id).await.unwrap(); | |
| 67 | + | assert!(tags.is_empty(), "no tag should have been created on a suspended community"); | |
| 68 | + | } | |
| 69 | + | ||
| 20 | 70 | #[tokio::test] | |
| 21 | 71 | async fn apply_tag_to_thread() { | |
| 22 | 72 | let mut h = TestHarness::new().await; |