Skip to main content

max / makenotwork

security: owner-scope blog/item mutations, polyglot + moderation tests Drive Security A- -> A+ (ultra-fuzz Run 7 --deep). Owner-scope the two genuine guard-dependent mutations (Sec-M2 defense in depth, mirroring media_files::delete): delete_blog_post and set_item_listed now enforce ownership IN the SQL (project_id IN (SELECT id FROM projects WHERE user_id = $N)) and return whether an owned row matched, so a missing upstream ownership check can no longer touch another user's post/item. Reconciliation findings (verified against the tree, not patched): - bump_cache_generation is a cache-counter increment, not a security boundary -- the OwnedResource-witness premise was a false positive, so it is left as-is (would be churn across 55 cache-invalidation call sites). - scanning/archive.rs walk_compressed already enforces the per-stream ratio cap (counted > compressed*MAX_RATIO) with an early stop at that cap, structurally mirroring walk_zip; no asymmetry to unify. content_type polyglot/markup tests: script/SVG payloads, BOM- and whitespace-prefixed markup, markup pushed past the sniff window, and the acknowledged binary-magic-prefixed blind spot (pins the documented defense-in-depth boundary so a future tightening doesn't misread it). Moderation coverage for the previously untested admin handlers: admin_remove_item/admin_restore_item and admin_decide_appeal (denied). Gate: cargo clippy --all-targets clean, cargo test --lib (1770), cargo test --test integration (987). sqlx offline unchanged (runtime queries only). No version bump, no deploy.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-25 00:42 UTC
Signed with PGP, not checked
Commit: 63e6a806cb95631487f66e639e0d29bb70a7b2d5
Parent: b412e48
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?;