Skip to main content

max / makenotwork

Derive media folder/filename from S3 key; sniff real content type; clamp storage SUMs media_confirm trusted client-supplied folder/filename and content_type: - folder/filename are now derived from the validated s3_key, so the stored row and its (user, folder, filename) unique index always match the object the key points at (the separately-sent fields could disagree). - the declared content_type is client-controlled (bound into the presigned PUT, only echoed by S3 metadata), so a video declared image/png dodged the BigFiles+ video-tier gate. Sniff the object's leading bytes at confirm and reject + orphan when the real audio/visual category differs from declared. Regression test: an MP4 declared image/png is refused and not recorded. Clamp every storage SUM(...)::BIGINT in creator_tiers with GREATEST(0, LEAST(SUM, i64::MAX)) — the dashboard read path was unclamped while versions.rs clamped, so a creator near the storage ceiling could throw numeric field overflow on the dashboard. Matches the versions.rs doctrine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 23:12 UTC
Commit: 9c8aec879e2ee374dedfd686c096d2b767096b29
Parent: abb31f1
3 files changed, +125 insertions, -27 deletions
@@ -372,14 +372,14 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto
372 372 let row: (i64, i64, i64, i64, i64, i64, i64) = sqlx::query_as(
373 373 r#"
374 374 WITH audio_bytes AS (
375 - SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
375 + SELECT COALESCE(GREATEST(0, LEAST(SUM(i.audio_file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total
376 376 FROM items i JOIN projects p ON i.project_id = p.id
377 377 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
378 378 ),
379 379 cover_bytes AS (
380 - SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0)
380 + SELECT COALESCE(GREATEST(0, LEAST(SUM(i.cover_file_size_bytes), 9223372036854775807))::BIGINT, 0)
381 381 + COALESCE((
382 - SELECT SUM(p.cover_image_size_bytes)::BIGINT
382 + SELECT GREATEST(0, LEAST(SUM(p.cover_image_size_bytes), 9223372036854775807))::BIGINT
383 383 FROM projects p
384 384 WHERE p.user_id = $1 AND p.cover_image_size_bytes IS NOT NULL
385 385 ), 0) AS total
@@ -387,33 +387,33 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto
387 387 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
388 388 ),
389 389 version_bytes AS (
390 - SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
390 + SELECT COALESCE(GREATEST(0, LEAST(SUM(v.file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total
391 391 FROM versions v
392 392 JOIN items i ON v.item_id = i.id
393 393 JOIN projects p ON i.project_id = p.id
394 394 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
395 395 ),
396 396 insertion_bytes AS (
397 - SELECT COALESCE(SUM(file_size)::BIGINT, 0) AS total
397 + SELECT COALESCE(GREATEST(0, LEAST(SUM(file_size), 9223372036854775807))::BIGINT, 0) AS total
398 398 FROM content_insertions WHERE user_id = $1
399 399 ),
400 400 video_bytes AS (
401 - SELECT COALESCE(SUM(i.video_file_size_bytes)::BIGINT, 0) AS total
401 + SELECT COALESCE(GREATEST(0, LEAST(SUM(i.video_file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total
402 402 FROM items i JOIN projects p ON i.project_id = p.id
403 403 WHERE p.user_id = $1 AND i.video_file_size_bytes IS NOT NULL
404 404 ),
405 405 media_bytes AS (
406 - SELECT COALESCE(SUM(file_size_bytes)::BIGINT, 0) AS total
406 + SELECT COALESCE(GREATEST(0, LEAST(SUM(file_size_bytes), 9223372036854775807))::BIGINT, 0) AS total
407 407 FROM media_files WHERE user_id = $1
408 408 ),
409 409 gallery_bytes AS (
410 410 SELECT COALESCE((
411 - SELECT SUM(ii.file_size_bytes)::BIGINT
411 + SELECT GREATEST(0, LEAST(SUM(ii.file_size_bytes), 9223372036854775807))::BIGINT
412 412 FROM item_images ii JOIN items i ON ii.item_id = i.id JOIN projects p ON i.project_id = p.id
413 413 WHERE p.user_id = $1
414 414 ), 0)
415 415 + COALESCE((
416 - SELECT SUM(pi.file_size_bytes)::BIGINT
416 + SELECT GREATEST(0, LEAST(SUM(pi.file_size_bytes), 9223372036854775807))::BIGINT
417 417 FROM project_images pi JOIN projects p ON pi.project_id = p.id
418 418 WHERE p.user_id = $1
419 419 ), 0) AS total
@@ -571,31 +571,31 @@ pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> {
571 571 + COALESCE(project_gallery.total, 0) AS total
572 572 FROM users u
573 573 LEFT JOIN LATERAL (
574 - SELECT SUM(i.audio_file_size_bytes)::BIGINT AS total
574 + SELECT GREATEST(0, LEAST(SUM(i.audio_file_size_bytes), 9223372036854775807))::BIGINT AS total
575 575 FROM items i JOIN projects p ON i.project_id = p.id
576 576 WHERE p.user_id = u.id AND i.audio_file_size_bytes IS NOT NULL
577 577 ) audio ON true
578 578 LEFT JOIN LATERAL (
579 - SELECT SUM(i.cover_file_size_bytes)::BIGINT AS total
579 + SELECT GREATEST(0, LEAST(SUM(i.cover_file_size_bytes), 9223372036854775807))::BIGINT AS total
580 580 FROM items i JOIN projects p ON i.project_id = p.id
581 581 WHERE p.user_id = u.id AND i.cover_file_size_bytes IS NOT NULL
582 582 ) cover ON true
583 583 LEFT JOIN LATERAL (
584 - SELECT SUM(i.video_file_size_bytes)::BIGINT AS total
584 + SELECT GREATEST(0, LEAST(SUM(i.video_file_size_bytes), 9223372036854775807))::BIGINT AS total
585 585 FROM items i JOIN projects p ON i.project_id = p.id
586 586 WHERE p.user_id = u.id AND i.video_file_size_bytes IS NOT NULL
587 587 ) video ON true
588 588 LEFT JOIN LATERAL (
589 - SELECT SUM(v.file_size_bytes)::BIGINT AS total
589 + SELECT GREATEST(0, LEAST(SUM(v.file_size_bytes), 9223372036854775807))::BIGINT AS total
590 590 FROM versions v JOIN items i ON v.item_id = i.id JOIN projects p ON i.project_id = p.id
591 591 WHERE p.user_id = u.id AND v.file_size_bytes IS NOT NULL
592 592 ) versions ON true
593 593 LEFT JOIN LATERAL (
594 - SELECT SUM(ci.file_size)::BIGINT AS total
594 + SELECT GREATEST(0, LEAST(SUM(ci.file_size), 9223372036854775807))::BIGINT AS total
595 595 FROM content_insertions ci WHERE ci.user_id = u.id
596 596 ) insertions ON true
597 597 LEFT JOIN LATERAL (
598 - SELECT SUM(mf.file_size_bytes)::BIGINT AS total
598 + SELECT GREATEST(0, LEAST(SUM(mf.file_size_bytes), 9223372036854775807))::BIGINT AS total
599 599 FROM media_files mf WHERE mf.user_id = u.id
600 600 ) media ON true
601 601 -- Project cover images charge storage at confirm but live in their own
@@ -606,17 +606,17 @@ pub async fn recalculate_all_storage_batch(pool: &PgPool) -> Result<u64> {
606 606 -- gallery/project-cover charge and the counter oscillated week to week
607 607 -- (Run #18 Storage B1). Reconcile them from the same rows the charge writes.
608 608 LEFT JOIN LATERAL (
609 - SELECT SUM(p.cover_image_size_bytes)::BIGINT AS total
609 + SELECT GREATEST(0, LEAST(SUM(p.cover_image_size_bytes), 9223372036854775807))::BIGINT AS total
610 610 FROM projects p
611 611 WHERE p.user_id = u.id AND p.cover_image_size_bytes IS NOT NULL
612 612 ) project_cover ON true
613 613 LEFT JOIN LATERAL (
614 - SELECT SUM(ii.file_size_bytes)::BIGINT AS total
614 + SELECT GREATEST(0, LEAST(SUM(ii.file_size_bytes), 9223372036854775807))::BIGINT AS total
615 615 FROM item_images ii JOIN items i ON ii.item_id = i.id JOIN projects p ON i.project_id = p.id
616 616 WHERE p.user_id = u.id
617 617 ) item_gallery ON true
618 618 LEFT JOIN LATERAL (
619 - SELECT SUM(pi.file_size_bytes)::BIGINT AS total
619 + SELECT GREATEST(0, LEAST(SUM(pi.file_size_bytes), 9223372036854775807))::BIGINT AS total
620 620 FROM project_images pi JOIN projects p ON pi.project_id = p.id
621 621 WHERE p.user_id = u.id
622 622 ) project_gallery ON true
@@ -840,7 +840,7 @@ pub async fn get_effective_max_file_bytes(
840 840 pub async fn get_user_content_size(pool: &PgPool, user_id: UserId) -> Result<i64> {
841 841 let version_size: i64 = sqlx::query_scalar(
842 842 r#"
843 - SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0)
843 + SELECT COALESCE(GREATEST(0, LEAST(SUM(v.file_size_bytes), 9223372036854775807))::BIGINT, 0)
844 844 FROM versions v
845 845 JOIN items i ON v.item_id = i.id
846 846 JOIN projects p ON i.project_id = p.id
@@ -852,7 +852,7 @@ pub async fn get_user_content_size(pool: &PgPool, user_id: UserId) -> Result<i64
852 852 .await?;
853 853
854 854 let insertion_size: i64 = sqlx::query_scalar(
855 - "SELECT COALESCE(SUM(file_size)::BIGINT, 0) FROM content_insertions WHERE user_id = $1",
855 + "SELECT COALESCE(GREATEST(0, LEAST(SUM(file_size), 9223372036854775807))::BIGINT, 0) FROM content_insertions WHERE user_id = $1",
856 856 )
857 857 .bind(user_id)
858 858 .fetch_one(pool)
@@ -44,8 +44,9 @@ pub struct MediaConfirmRequest {
44 44 pub s3_key: String,
45 45 pub file_name: String,
46 46 pub content_type: String,
47 - #[serde(default)]
48 - pub folder: String,
47 + // `folder` is no longer accepted at confirm: it is derived from the validated
48 + // s3_key (a client-supplied folder could disagree with the object's actual
49 + // key). Any `folder` field in the request body is ignored by serde.
49 50 }
50 51
51 52 #[derive(Debug, Deserialize)]
@@ -229,6 +230,40 @@ pub(super) async fn media_confirm(
229 230 )));
230 231 }
231 232
233 + // Reconcile the real media category against the declared content_type before
234 + // tier enforcement. The declared type is client-controlled — it is bound into
235 + // the presigned PUT and merely echoed back by S3 metadata — so trusting it
236 + // lets a video be declared `image/png` and dodge the BigFiles+ video-tier
237 + // gate (Run #22 Storage MED). Sniff the object's leading bytes; if it is
238 + // detectably a different audio/visual category than declared, reject + orphan.
239 + {
240 + let mut stream = s3.download_stream(&req.s3_key).await?;
241 + let mut head: Vec<u8> = Vec::with_capacity(4096);
242 + loop {
243 + if head.len() >= 4096 {
244 + break;
245 + }
246 + match stream.try_next().await {
247 + Ok(Some(chunk)) => head.extend_from_slice(&chunk),
248 + Ok(None) => break,
249 + Err(e) => return Err(AppError::Storage(format!("read media header from S3: {e}"))),
250 + }
251 + }
252 + let detected = infer::get(&head).and_then(|kind| match kind.matcher_type() {
253 + infer::MatcherType::Image => Some("image"),
254 + infer::MatcherType::Video => Some("video"),
255 + _ => None,
256 + });
257 + if let Some(real) = detected
258 + && real != media_type
259 + {
260 + super::enqueue_s3_orphan(&state.db, &req.s3_key, "media_content_type_mismatch").await;
261 + return Err(AppError::BadRequest(format!(
262 + "Uploaded file is a {real}, but was declared as a {media_type}."
263 + )));
264 + }
265 + }
266 +
232 267 // Tier enforcement
233 268 let max_storage = match db::creator_tiers::check_upload_allowed(
234 269 &state.db, user.id, file_type, file_size_bytes,
@@ -242,11 +277,20 @@ pub(super) async fn media_confirm(
242 277 }
243 278 };
244 279
245 - let folder = sanitize_folder(&req.folder);
246 - // Use the SAME sanitizer generate_media_key used at presign so the stored
247 - // filename matches the key's tail (incl. the empty-basename fallback). A
248 - // re-derived inline filter diverged on names like "( ).png" (Run #18 B9).
249 - let safe_filename = crate::storage::sanitize_filename(&req.file_name);
280 + // Derive folder + filename from the validated S3 key, not the separately
281 + // supplied req.folder / req.file_name (which can disagree with the key the
282 + // object actually lives under — Run #22 Storage MED: the unique index then
283 + // guards a name that doesn't match the object). The key was built at presign
284 + // as `{user}/media/[{folder}/]{filename}` with these same sanitizers, so its
285 + // tail is authoritative and already sanitized.
286 + let key_tail = req.s3_key.strip_prefix(&expected_prefix).unwrap_or("");
287 + let (folder, safe_filename) = match key_tail.rsplit_once('/') {
288 + Some((f, name)) => (f.to_string(), name.to_string()),
289 + None => (String::new(), key_tail.to_string()),
290 + };
291 + if safe_filename.is_empty() {
292 + return Err(AppError::BadRequest("Invalid upload key".to_string()));
293 + }
250 294
251 295 // Wrap storage credit + pending_uploads clear + media_files INSERT in a
252 296 // single transaction. The Run #5 audit flagged the previous non-atomic
@@ -82,6 +82,60 @@ async fn upload_media(
82 82 (s3_key, file_id)
83 83 }
84 84
85 + /// Minimal MP4 header — `ftyp` box with the `isom` brand. `infer` classifies
86 + /// this as a video, regardless of the declared content type.
87 + const TINY_MP4: &[u8] = &[
88 + 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, // size + "ftyp"
89 + 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00, // "isom" + minor version
90 + 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32, // compatible brands
91 + ];
92 +
93 + // ---------------------------------------------------------------------------
94 + // A video declared as an image is rejected at confirm (tier-gate evasion)
95 + // ---------------------------------------------------------------------------
96 +
97 + #[tokio::test]
98 + async fn video_declared_as_image_is_rejected_at_confirm() {
99 + // Run #22 Storage MED: the declared content_type is client-controlled. A
100 + // video uploaded under an image/png declaration would dodge the BigFiles+
101 + // video-tier gate. The confirm-time byte sniff must catch and reject it.
102 + let mut h = TestHarness::with_storage().await;
103 + setup_basic_creator(&mut h, "ctevasion").await;
104 +
105 + let body = json!({
106 + "file_name": "sneaky.png",
107 + "content_type": "image/png",
108 + "folder": "clips",
109 + });
110 + let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
111 + assert!(resp.status.is_success(), "image presign should succeed: {}", resp.text);
112 + let presign: Value = resp.json();
113 + let s3_key = presign["s3_key"].as_str().unwrap().to_string();
114 +
115 + // Upload actual MP4 bytes under the image key.
116 + h.storage.as_ref().unwrap().put(&s3_key, TINY_MP4.to_vec());
117 +
118 + let body = json!({
119 + "s3_key": s3_key,
120 + "file_name": "sneaky.png",
121 + "content_type": "image/png",
122 + "folder": "clips",
123 + });
124 + let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await;
125 + assert!(
126 + !resp.status.is_success(),
127 + "a video declared as an image must be rejected at confirm, got: {} {}",
128 + resp.status, resp.text
129 + );
130 +
131 + // Nothing was recorded in the library.
132 + let list: Value = h.client.get("/api/media?folder=clips").await.json();
133 + assert!(
134 + list["files"].as_array().map(|f| f.is_empty()).unwrap_or(true),
135 + "rejected upload must not appear in the media library"
136 + );
137 + }
138 +
85 139 // ---------------------------------------------------------------------------
86 140 // Image upload on Basic tier succeeds (images bypass tier check like covers)
87 141 // ---------------------------------------------------------------------------