max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
10 files changed,
+283 insertions,
-23 deletions
| @@ -247,15 +247,25 @@ | |||
| 247 | 247 | Ok(posts) | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | - | /// Permanently delete a blog post by ID. | |
| 250 | + | /// Permanently delete a blog post owned by `owner_id`. | |
| 251 | + | /// | |
| 252 | + | /// Ownership is scoped IN the SQL (`project_id IN (SELECT id FROM projects WHERE | |
| 253 | + | /// user_id = $2)`) so the delete can't remove another user's post even if a | |
| 254 | + | /// caller skips the upstream ownership check (Sec-M2 defense in depth, mirroring | |
| 255 | + | /// `media_files::delete`). Returns `true` if a post was deleted, `false` if none | |
| 256 | + | /// matched for `owner_id`. | |
| 251 | 257 | #[tracing::instrument(skip_all)] | |
| 252 | - | pub async fn delete_blog_post(pool: &PgPool, id: BlogPostId) -> Result<()> { | |
| 253 | - | sqlx::query("DELETE FROM blog_posts WHERE id = $1") | |
| 254 | - | .bind(id) | |
| 255 | - | .execute(pool) | |
| 256 | - | .await?; | |
| 258 | + | pub async fn delete_blog_post(pool: &PgPool, id: BlogPostId, owner_id: UserId) -> Result<bool> { | |
| 259 | + | let res = sqlx::query( | |
| 260 | + | "DELETE FROM blog_posts \ | |
| 261 | + | WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)", | |
| 262 | + | ) | |
| 263 | + | .bind(id) | |
| 264 | + | .bind(owner_id) | |
| 265 | + | .execute(pool) | |
| 266 | + | .await?; | |
| 257 | 267 | ||
| 258 | - | Ok(()) | |
| 268 | + | Ok(res.rows_affected() > 0) | |
| 259 | 269 | } | |
| 260 | 270 | ||
| 261 | 271 | /// Set the linked MT thread ID for a blog post. |
| @@ -302,18 +302,29 @@ | |||
| 302 | 302 | } | |
| 303 | 303 | ||
| 304 | 304 | /// Set the `listed` flag on an item. UNSCOPED: takes no owner and updates by id | |
| 305 | - | /// alone, so every caller MUST have already verified the item belongs to the | |
| 306 | - | /// acting user (the bundle/project ownership check upstream) before calling — the | |
| 307 | - | /// flag write itself enforces no ownership (Sec-M2). | |
| 305 | + | /// alone. Ownership is scoped IN the SQL (`project_id IN (SELECT id FROM projects | |
| 306 | + | /// WHERE user_id = $3)`) so the toggle can't reach another user's item even if a | |
| 307 | + | /// caller skips the upstream bundle/project ownership check (Sec-M2 defense in | |
| 308 | + | /// depth, mirroring `media_files::delete`). Returns `true` if an owned item was | |
| 309 | + | /// updated, `false` if none matched for `owner_id`. | |
| 308 | 310 | #[tracing::instrument(skip_all, fields(%item_id, listed))] | |
| 309 | - | pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> { | |
| 310 | - | sqlx::query("UPDATE items SET listed = $2 WHERE id = $1") | |
| 311 | - | .bind(item_id) | |
| 312 | - | .bind(listed) | |
| 313 | - | .execute(pool) | |
| 314 | - | .await?; | |
| 311 | + | pub async fn set_item_listed( | |
| 312 | + | pool: &PgPool, | |
| 313 | + | item_id: ItemId, | |
| 314 | + | listed: bool, | |
| 315 | + | owner_id: UserId, | |
| 316 | + | ) -> Result<bool> { | |
| 317 | + | let res = sqlx::query( | |
| 318 | + | "UPDATE items SET listed = $2 \ | |
| 319 | + | WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)", | |
| 320 | + | ) | |
| 321 | + | .bind(item_id) | |
| 322 | + | .bind(listed) | |
| 323 | + | .bind(owner_id) | |
| 324 | + | .execute(pool) | |
| 325 | + | .await?; | |
| 315 | 326 | ||
| 316 | - | Ok(()) | |
| 327 | + | Ok(res.rows_affected() > 0) | |
| 317 | 328 | } | |
| 318 | 329 | ||
| 319 | 330 | #[cfg(test)] |
| @@ -22,7 +22,7 @@ | |||
| 22 | 22 | pub(crate) mod custom_links; | |
| 23 | 23 | pub(crate) mod auth; | |
| 24 | 24 | pub mod waitlist; | |
| 25 | - | pub(crate) mod blog_posts; | |
| 25 | + | pub mod blog_posts; | |
| 26 | 26 | pub mod license_keys; | |
| 27 | 27 | pub mod synckit; // pub so the integration test crate can exercise compaction directly | |
| 28 | 28 | pub mod synckit_billing; |
| @@ -425,4 +425,68 @@ | |||
| 425 | 425 | let path_based = verify_content_type_path(tmp.path(), FileType::Audio); | |
| 426 | 426 | assert_eq!(buffered.verdict, path_based.verdict); | |
| 427 | 427 | } | |
| 428 | + | ||
| 429 | + | // -- Markup / polyglot sniffing (sniff_html_like) -- | |
| 430 | + | // | |
| 431 | + | // For every non-Download type, markup is caught up front regardless of the | |
| 432 | + | // per-type logic, and `infer`-unclassifiable text fails the media arms. The | |
| 433 | + | // one acknowledged gap is the binary-magic-prefixed polyglot (documented at | |
| 434 | + | // the module top): `infer` classifies it by its leading magic, so this layer | |
| 435 | + | // passes it and layers 2-5 are what catch it. These pin both behaviors. | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn script_payload_fails_as_audio() { | |
| 439 | + | let data = b"<script>alert(1)</script>"; | |
| 440 | + | assert_eq!(verify_content_type(data, FileType::Audio).verdict, LayerVerdict::Fail); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn html_after_utf8_bom_fails_as_cover() { | |
| 445 | + | // BOM-prefixed markup must still sniff as markup (the BOM is skipped). | |
| 446 | + | let mut data = vec![0xEF, 0xBB, 0xBF]; | |
| 447 | + | data.extend_from_slice(b"<html><body>x</body></html>"); | |
| 448 | + | assert_eq!(verify_content_type(&data, FileType::Cover).verdict, LayerVerdict::Fail); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | #[test] | |
| 452 | + | fn html_after_leading_whitespace_fails_as_video() { | |
| 453 | + | // Leading whitespace is trimmed before the window scan, so padding the | |
| 454 | + | // front with newlines/spaces does not evade the markup sniff. | |
| 455 | + | let data = b"\n\n\t <html><head></head></html>"; | |
| 456 | + | assert_eq!(verify_content_type(data, FileType::Video).verdict, LayerVerdict::Fail); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn svg_payload_fails_as_image() { | |
| 461 | + | let data = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>x</script></svg>"; | |
| 462 | + | assert_eq!(verify_content_type(data, FileType::MediaImage).verdict, LayerVerdict::Fail); | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | #[test] | |
| 466 | + | fn text_with_markup_past_sniff_window_still_fails_media() { | |
| 467 | + | // Non-whitespace padding pushes the <script> past the 256-byte sniff | |
| 468 | + | // window, so the markup sniff misses it — but the payload is plain text, | |
| 469 | + | // so `infer` returns None and the audio arm rejects it anyway. The file | |
| 470 | + | // never passes as media regardless of which guard fires. | |
| 471 | + | let mut data = vec![b'A'; 300]; | |
| 472 | + | data.extend_from_slice(b"<script>alert(1)</script>"); | |
| 473 | + | assert_eq!(verify_content_type(&data, FileType::Audio).verdict, LayerVerdict::Fail); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[test] | |
| 477 | + | fn png_prefixed_polyglot_past_head_passes_content_type_layer() { | |
| 478 | + | // The acknowledged blind spot: valid PNG magic, then enough binary | |
| 479 | + | // padding to push the script tail past the 1024-byte head the sniff | |
| 480 | + | // inspects. `infer` classifies the file as image/png by its leading | |
| 481 | + | // magic and the sniff never sees the markup, so THIS layer passes it. | |
| 482 | + | // The malicious tail is caught by the structural/archive/YARA/ClamAV | |
| 483 | + | // layers, not here. Pinning this documents the boundary so a future | |
| 484 | + | // change that "tightens" content_type doesn't mistakenly assume it is | |
| 485 | + | // the polyglot defense. (Markup WITHIN the head is caught — see | |
| 486 | + | // `script_payload_fails_as_audio`.) | |
| 487 | + | let mut data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; | |
| 488 | + | data.extend(std::iter::repeat_n(0u8, 1200)); | |
| 489 | + | data.extend_from_slice(b"<script>alert(1)</script>"); | |
| 490 | + | assert_eq!(verify_content_type(&data, FileType::Cover).verdict, LayerVerdict::Pass); | |
| 491 | + | } | |
| 428 | 492 | } |
| @@ -96,3 +96,4 @@ | |||
| 96 | 96 | mod bundles; | |
| 97 | 97 | mod idempotency; | |
| 98 | 98 | mod db_payments_layer; | |
| 99 | + | mod security_idor_moderation; |
| @@ -259,7 +259,7 @@ | |||
| 259 | 259 | user.check_not_suspended()?; | |
| 260 | 260 | let post = verify_blog_post_ownership(&state, id, user.id).await?; | |
| 261 | 261 | ||
| 262 | - | db::blog_posts::delete_blog_post(&state.db, id).await?; | |
| 262 | + | db::blog_posts::delete_blog_post(&state.db, id, user.id).await?; | |
| 263 | 263 | db::projects::bump_cache_generation(&state.db, post.project_id).await?; | |
| 264 | 264 | ||
| 265 | 265 | Ok(htmx_toast_response("Blog post deleted", "success")) |
| @@ -197,7 +197,7 @@ | |||
| 197 | 197 | return Err(AppError::Forbidden); | |
| 198 | 198 | } | |
| 199 | 199 | ||
| 200 | - | db::blog_posts::delete_blog_post(&state.db, post_id).await?; | |
| 200 | + | db::blog_posts::delete_blog_post(&state.db, post_id, query.user_id).await?; | |
| 201 | 201 | ||
| 202 | 202 | tracing::info!(user = %query.user_id, post = %post_id, "blog post deleted via CLI"); | |
| 203 | 203 |
| @@ -87,7 +87,7 @@ | |||
| 87 | 87 | if !db::bundles::is_bundle_member(&state.db, bundle_id, child_id).await? { | |
| 88 | 88 | return Err(AppError::NotFound); | |
| 89 | 89 | } | |
| 90 | - | db::bundles::set_item_listed(&state.db, child_id, req.listed).await?; | |
| 90 | + | db::bundles::set_item_listed(&state.db, child_id, req.listed, user.id).await?; | |
| 91 | 91 | Ok(StatusCode::OK) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| @@ -137,7 +137,7 @@ | |||
| 137 | 137 | // Add to bundle and set unlisted | |
| 138 | 138 | let count = db::bundles::get_bundle_item_count(&state.db, bundle_id).await?; | |
| 139 | 139 | db::bundles::add_item_to_bundle(&state.db, bundle_id, child.id, count as i32).await?; | |
| 140 | - | db::bundles::set_item_listed(&state.db, child.id, false).await?; | |
| 140 | + | db::bundles::set_item_listed(&state.db, child.id, false, user.id).await?; | |
| 141 | 141 | ||
| 142 | 142 | // Publish the child so it's downloadable via the bundle | |
| 143 | 143 | db::items::bulk_publish(&state.db, &[child.id], bundle.project_id, user.id).await?; |