Skip to main content

max / makenotwork

Fix cross-community IDOR in flag, category, and tag handlers Scope flag dismiss/removal to community via JOIN through posts→threads→categories. Add community_id check to update_category and delete_tag mutations. Return 404 when target doesn't belong to the authenticated community. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 20:06 UTC
Commit: ab894e4fd90ce9758bf2772668e20297d5e72f1e
Parent: abeb793
5 files changed, +71 insertions, -20 deletions
@@ -344,21 +344,25 @@ pub async fn create_category(
344 344 Ok(row.0)
345 345 }
346 346
347 - /// Update a category's name and description.
347 + /// Update a category's name and description (scoped to community).
348 348 #[tracing::instrument(skip_all)]
349 349 pub async fn update_category(
350 350 pool: &PgPool,
351 351 category_id: Uuid,
352 + community_id: Uuid,
352 353 name: &str,
353 354 description: Option<&str>,
354 - ) -> Result<(), sqlx::Error> {
355 - sqlx::query("UPDATE categories SET name = $2, description = $3 WHERE id = $1")
356 - .bind(category_id)
357 - .bind(name)
358 - .bind(description)
359 - .execute(pool)
360 - .await?;
361 - Ok(())
355 + ) -> Result<bool, sqlx::Error> {
356 + let result = sqlx::query(
357 + "UPDATE categories SET name = $2, description = $3 WHERE id = $1 AND community_id = $4",
358 + )
359 + .bind(category_id)
360 + .bind(name)
361 + .bind(description)
362 + .bind(community_id)
363 + .execute(pool)
364 + .await?;
365 + Ok(result.rows_affected() > 0)
362 366 }
363 367
364 368 /// Swap the sort_order of two categories atomically.
@@ -661,14 +665,15 @@ pub async fn create_tag(
661 665 Ok(row.0)
662 666 }
663 667
664 - /// Delete a tag (CASCADE removes thread_tags rows).
668 + /// Delete a tag (CASCADE removes thread_tags rows), scoped to community.
665 669 #[tracing::instrument(skip_all)]
666 - pub async fn delete_tag(pool: &PgPool, tag_id: Uuid) -> Result<(), sqlx::Error> {
667 - sqlx::query("DELETE FROM tags WHERE id = $1")
670 + pub async fn delete_tag(pool: &PgPool, tag_id: Uuid, community_id: Uuid) -> Result<bool, sqlx::Error> {
671 + let result = sqlx::query("DELETE FROM tags WHERE id = $1 AND community_id = $2")
668 672 .bind(tag_id)
673 + .bind(community_id)
669 674 .execute(pool)
670 675 .await?;
671 - Ok(())
676 + Ok(result.rows_affected() > 0)
672 677 }
673 678
674 679 /// Set the tags for a thread (delete existing + batch insert new, in a transaction).
@@ -1319,6 +1319,28 @@ pub async fn has_user_flagged_post(
1319 1319 .await
1320 1320 }
1321 1321
1322 + /// Check if a flag belongs to a given community (via post → thread → category chain).
1323 + #[tracing::instrument(skip_all)]
1324 + pub async fn flag_belongs_to_community(
1325 + pool: &PgPool,
1326 + flag_id: Uuid,
1327 + community_id: Uuid,
1328 + ) -> Result<bool, sqlx::Error> {
1329 + sqlx::query_scalar(
1330 + "SELECT EXISTS(
1331 + SELECT 1 FROM post_flags pf
1332 + JOIN posts p ON p.id = pf.post_id
1333 + JOIN threads t ON t.id = p.thread_id
1334 + JOIN categories c ON c.id = t.category_id
1335 + WHERE pf.id = $1 AND c.community_id = $2
1336 + )",
1337 + )
1338 + .bind(flag_id)
1339 + .bind(community_id)
1340 + .fetch_one(pool)
1341 + .await
1342 + }
1343 +
1322 1344
1323 1345 // ============================================================================
1324 1346 // Endorsement queries
@@ -107,9 +107,20 @@ pub(super) async fn dismiss_flag_handler(
107 107 let user = session_user
108 108 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
109 109
110 - let (_community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
110 + let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
111 111 let flag_id = parse_uuid(&flag_id_str)?;
112 112
113 + // Verify flag belongs to this community before acting on it
114 + let flag_exists = mt_db::queries::flag_belongs_to_community(&state.db, flag_id, community.id)
115 + .await
116 + .map_err(|e| {
117 + tracing::error!(error = ?e, "db error checking flag community");
118 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
119 + })?;
120 + if !flag_exists {
121 + return Err((StatusCode::NOT_FOUND, "Not found").into_response());
122 + }
123 +
113 124 mt_db::mutations::resolve_flag(&state.db, flag_id, user.user_id, "dismissed")
114 125 .await
115 126 .map_err(|e| {
@@ -136,11 +147,17 @@ pub(super) async fn remove_flagged_post_handler(
136 147 let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
137 148 let flag_id = parse_uuid(&flag_id_str)?;
138 149
139 - // Get the flag to find the post_id
150 + // Get the flag to find the post_id — scoped to this community
140 151 let flag_row: Option<(uuid::Uuid, uuid::Uuid)> = sqlx::query_as(
141 - "SELECT post_id, (SELECT author_id FROM posts WHERE id = post_flags.post_id) FROM post_flags WHERE id = $1",
152 + "SELECT pf.post_id, p.author_id
153 + FROM post_flags pf
154 + JOIN posts p ON p.id = pf.post_id
155 + JOIN threads t ON t.id = p.thread_id
156 + JOIN categories c ON c.id = t.category_id
157 + WHERE pf.id = $1 AND c.community_id = $2",
142 158 )
143 159 .bind(flag_id)
160 + .bind(community.id)
144 161 .fetch_optional(&state.db)
145 162 .await
146 163 .map_err(|e| {
@@ -253,12 +253,15 @@ pub(super) async fn edit_category_handler(
253 253 }
254 254 let desc_opt = if description.is_empty() { None } else { Some(description) };
255 255
256 - mt_db::mutations::update_category(&state.db, cat_id, name, desc_opt)
256 + let updated = mt_db::mutations::update_category(&state.db, cat_id, community.id, name, desc_opt)
257 257 .await
258 258 .map_err(|e| {
259 259 tracing::error!(error = ?e, "db error updating category");
260 260 StatusCode::INTERNAL_SERVER_ERROR.into_response()
261 261 })?;
262 + if !updated {
263 + return Err(StatusCode::NOT_FOUND.into_response());
264 + }
262 265
263 266 log_mod_action(
264 267 &state.db, Some(community.id), user.user_id,
@@ -369,16 +372,19 @@ pub(super) async fn delete_tag_handler(
369 372 let user = session_user
370 373 .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
371 374
372 - let _community = require_owner(&state, &slug, &user).await?;
375 + let community = require_owner(&state, &slug, &user).await?;
373 376
374 377 let tag_id = parse_uuid(&form.tag_id)?;
375 378
376 - mt_db::mutations::delete_tag(&state.db, tag_id)
379 + let deleted = mt_db::mutations::delete_tag(&state.db, tag_id, community.id)
377 380 .await
378 381 .map_err(|e| {
379 382 tracing::error!(error = ?e, "db error deleting tag");
380 383 StatusCode::INTERNAL_SERVER_ERROR.into_response()
381 384 })?;
385 + if !deleted {
386 + return Err(StatusCode::NOT_FOUND.into_response());
387 + }
382 388
383 389 Ok(Redirect::to(&format!(
384 390 "/p/{slug}/settings?toast=Tag+deleted"
@@ -238,9 +238,10 @@ async fn update_category_updates_fields() {
238 238 .await
239 239 .unwrap();
240 240
241 - mt_db::mutations::update_category(&h.db, cat_id, "New Name", Some("New desc"))
241 + let updated = mt_db::mutations::update_category(&h.db, cat_id, comm_id, "New Name", Some("New desc"))
242 242 .await
243 243 .unwrap();
244 + assert!(updated);
244 245
245 246 let (name, desc): (String, Option<String>) = sqlx::query_as(
246 247 "SELECT name, description FROM categories WHERE id = $1",