max / makenotwork
8 files changed,
+95 insertions,
-27 deletions
| @@ -192,7 +192,7 @@ pub async fn discover_items( | |||
| 192 | 192 | JOIN users u ON p.user_id = u.id | |
| 193 | 193 | LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true | |
| 194 | 194 | LEFT JOIN tags pt ON pt.id = pit.tag_id | |
| 195 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 195 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 196 | 196 | "#, | |
| 197 | 197 | ) | |
| 198 | 198 | } else if has_search { | |
| @@ -219,7 +219,7 @@ pub async fn discover_items( | |||
| 219 | 219 | JOIN users u ON p.user_id = u.id | |
| 220 | 220 | LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true | |
| 221 | 221 | LEFT JOIN tags pt ON pt.id = pit.tag_id | |
| 222 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 222 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 223 | 223 | "#, | |
| 224 | 224 | ) | |
| 225 | 225 | } else { | |
| @@ -245,7 +245,7 @@ pub async fn discover_items( | |||
| 245 | 245 | JOIN users u ON p.user_id = u.id | |
| 246 | 246 | LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true | |
| 247 | 247 | LEFT JOIN tags pt ON pt.id = pit.tag_id | |
| 248 | - | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 248 | + | WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL | |
| 249 | 249 | "#, | |
| 250 | 250 | ) | |
| 251 | 251 | }; | |
| @@ -295,7 +295,7 @@ pub async fn count_discover_items( | |||
| 295 | 295 | FROM items i | |
| 296 | 296 | JOIN projects p ON i.project_id = p.id | |
| 297 | 297 | JOIN users u ON p.user_id = u.id | |
| 298 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE | |
| 298 | + | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 299 | 299 | "#, | |
| 300 | 300 | ); | |
| 301 | 301 | ||
| @@ -507,7 +507,7 @@ pub async fn get_item_type_counts( | |||
| 507 | 507 | FROM items i | |
| 508 | 508 | JOIN projects p ON i.project_id = p.id | |
| 509 | 509 | JOIN users u ON p.user_id = u.id | |
| 510 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE | |
| 510 | + | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 511 | 511 | "#, | |
| 512 | 512 | ); | |
| 513 | 513 | ||
| @@ -578,7 +578,7 @@ pub async fn get_price_range_counts( | |||
| 578 | 578 | FROM items i | |
| 579 | 579 | JOIN projects p ON i.project_id = p.id | |
| 580 | 580 | JOIN users u ON p.user_id = u.id | |
| 581 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE | |
| 581 | + | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 582 | 582 | "#, | |
| 583 | 583 | ); | |
| 584 | 584 | ||
| @@ -648,7 +648,7 @@ pub async fn get_ai_tier_counts( | |||
| 648 | 648 | FROM items i | |
| 649 | 649 | JOIN projects p ON i.project_id = p.id | |
| 650 | 650 | JOIN users u ON p.user_id = u.id | |
| 651 | - | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE | |
| 651 | + | WHERE i.is_public = true AND i.listed = true AND i.deleted_at IS NULL AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE | |
| 652 | 652 | "#, | |
| 653 | 653 | ); | |
| 654 | 654 |
| @@ -110,6 +110,9 @@ pub fn spawn_monitor( | |||
| 110 | 110 | .unwrap_or(constants::HEALTH_CHECK_INTERVAL_SECS); | |
| 111 | 111 | ||
| 112 | 112 | let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); | |
| 113 | + | // Skip (not burst) missed ticks so a slow health check doesn't cause a | |
| 114 | + | // catch-up burst on the next wake. | |
| 115 | + | interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); | |
| 113 | 116 | interval.tick().await; // consume immediate first tick | |
| 114 | 117 | ||
| 115 | 118 | let mut previous_status: Option<MonitorStatus> = None; | |
| @@ -155,7 +158,9 @@ pub fn spawn_monitor( | |||
| 155 | 158 | ) | |
| 156 | 159 | }); | |
| 157 | 160 | let should_refresh = { | |
| 158 | - | let mut last = last_lock.lock().expect("storage-fill mutex"); | |
| 161 | + | // Recover from a poisoned lock rather than panicking the monitor | |
| 162 | + | // loop — the critical section is a trivial timestamp swap. | |
| 163 | + | let mut last = last_lock.lock().unwrap_or_else(|e| e.into_inner()); | |
| 159 | 164 | if last.elapsed() >= STORAGE_FILL_TTL { | |
| 160 | 165 | *last = std::time::Instant::now(); | |
| 161 | 166 | true |
| @@ -96,10 +96,10 @@ pub async fn validate_project_slug( | |||
| 96 | 96 | } | |
| 97 | 97 | }; | |
| 98 | 98 | ||
| 99 | - | let is_taken = db::projects::get_project_by_user_and_slug(&state.db, auth.0.id, &slug) | |
| 100 | - | .await | |
| 101 | - | .map(|p| p.is_some()) | |
| 102 | - | .unwrap_or(false); | |
| 99 | + | let is_taken = match db::projects::get_project_by_user_and_slug(&state.db, auth.0.id, &slug).await { | |
| 100 | + | Ok(p) => p.is_some(), | |
| 101 | + | Err(e) => return slug_check_failed(e), | |
| 102 | + | }; | |
| 103 | 103 | ||
| 104 | 104 | if is_taken { | |
| 105 | 105 | let suggestions = suggest_slugs(&state.db, auth.0.id, &form.slug).await; | |
| @@ -155,10 +155,10 @@ pub async fn validate_collection_slug( | |||
| 155 | 155 | }; | |
| 156 | 156 | ||
| 157 | 157 | let is_taken = | |
| 158 | - | db::collections::get_collection_by_user_and_slug(&state.db, auth.0.id, &slug) | |
| 159 | - | .await | |
| 160 | - | .map(|c| c.is_some()) | |
| 161 | - | .unwrap_or(false); | |
| 158 | + | match db::collections::get_collection_by_user_and_slug(&state.db, auth.0.id, &slug).await { | |
| 159 | + | Ok(c) => c.is_some(), | |
| 160 | + | Err(e) => return slug_check_failed(e), | |
| 161 | + | }; | |
| 162 | 162 | ||
| 163 | 163 | Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()) | |
| 164 | 164 | } | |
| @@ -207,9 +207,26 @@ pub async fn validate_blog_slug( | |||
| 207 | 207 | _ => return Html(String::new()), | |
| 208 | 208 | }; | |
| 209 | 209 | ||
| 210 | - | let is_taken = db::blog_posts::blog_post_slug_exists(&state.db, project.id, &slug) | |
| 211 | - | .await | |
| 212 | - | .unwrap_or(false); | |
| 210 | + | let is_taken = match db::blog_posts::blog_post_slug_exists(&state.db, project.id, &slug).await { | |
| 211 | + | Ok(taken) => taken, | |
| 212 | + | Err(e) => return slug_check_failed(e), | |
| 213 | + | }; | |
| 213 | 214 | ||
| 214 | 215 | Html(SlugStatusTemplate { available: !is_taken, suggestions: Vec::new() }.render_string()) | |
| 215 | 216 | } | |
| 217 | + | ||
| 218 | + | /// Fail-closed response for a slug-availability check whose DB query errored. | |
| 219 | + | /// Reporting "available" on error (the old `.unwrap_or(false)`) misleads the | |
| 220 | + | /// creator into thinking a possibly-taken slug is free; the real save then | |
| 221 | + | /// fails on the uniqueness constraint. Surface a retry instead (Run #21 UX LOW, | |
| 222 | + | /// carry-forward from #18). | |
| 223 | + | fn slug_check_failed(e: crate::error::AppError) -> Html<String> { | |
| 224 | + | tracing::warn!(error = ?e, "slug availability check failed; reporting retry"); | |
| 225 | + | Html( | |
| 226 | + | SaveStatusTemplate { | |
| 227 | + | success: false, | |
| 228 | + | message: "Couldn't check availability right now — try again".to_string(), | |
| 229 | + | } | |
| 230 | + | .render_string(), | |
| 231 | + | ) | |
| 232 | + | } |
| @@ -85,9 +85,12 @@ pub(super) async fn stream_url( | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | // Only allow files with Clean scan status to be streamed. | |
| 88 | - | // Blocks Pending (not yet scanned), Quarantined, and HeldForReview. | |
| 89 | - | // Creators can stream their own HeldForReview content for preview. | |
| 90 | - | if item.scan_status != db::FileScanStatus::Clean && !is_creator { | |
| 88 | + | // A creator may preview their own Pending/HeldForReview content, but | |
| 89 | + | // confirmed-malicious (Quarantined) content is never streamable — not even | |
| 90 | + | // by its own creator (Run #21 Security LOW). | |
| 91 | + | if item.scan_status == db::FileScanStatus::Quarantined | |
| 92 | + | || (item.scan_status != db::FileScanStatus::Clean && !is_creator) | |
| 93 | + | { | |
| 91 | 94 | return Err(AppError::NotFound); | |
| 92 | 95 | } | |
| 93 | 96 | ||
| @@ -188,8 +191,12 @@ pub(super) async fn version_download( | |||
| 188 | 191 | return Err(AppError::NotFound); | |
| 189 | 192 | } | |
| 190 | 193 | ||
| 191 | - | // Only allow files with Clean scan status to be downloaded (creators can access their own) | |
| 192 | - | if version.scan_status != db::FileScanStatus::Clean && !is_creator { | |
| 194 | + | // Only allow files with Clean scan status to be downloaded. A creator may | |
| 195 | + | // preview their own Pending/HeldForReview version, but never a Quarantined | |
| 196 | + | // one — not even their own (Run #21 Security LOW). | |
| 197 | + | if version.scan_status == db::FileScanStatus::Quarantined | |
| 198 | + | || (version.scan_status != db::FileScanStatus::Clean && !is_creator) | |
| 199 | + | { | |
| 193 | 200 | return Err(AppError::NotFound); | |
| 194 | 201 | } | |
| 195 | 202 |
| @@ -294,7 +294,8 @@ fn inspect_zip<R: std::io::Read + std::io::Seek>(reader: R) -> LayerResult { | |||
| 294 | 294 | }; | |
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | - | total_compressed += entry.compressed_size(); | |
| 297 | + | let entry_compressed = entry.compressed_size(); | |
| 298 | + | total_compressed += entry_compressed; | |
| 298 | 299 | let claimed_size = entry.size(); | |
| 299 | 300 | drop(entry); | |
| 300 | 301 | ||
| @@ -348,6 +349,27 @@ fn inspect_zip<R: std::io::Read + std::io::Seek>(reader: R) -> LayerResult { | |||
| 348 | 349 | }; | |
| 349 | 350 | total_uncompressed += actual_size; | |
| 350 | 351 | ||
| 352 | + | // Per-entry ratio cap. The aggregate ratio check below can be diluted by | |
| 353 | + | // an incompressible padding entry masking a single bomb entry, so flag a | |
| 354 | + | // single entry whose own ratio is extreme (Run #21 Security MODERATE). | |
| 355 | + | // Gated on a 1 MB floor so small highly-compressible files (text/JSON) | |
| 356 | + | // don't false-positive — absolute size is already bounded per-entry (the | |
| 357 | + | // `counted > limit` guard above) and in total (below); this is bomb | |
| 358 | + | // detection, not the resource cap. | |
| 359 | + | if entry_compressed > 0 && actual_size >= 1024 * 1024 { | |
| 360 | + | let entry_ratio = actual_size as f64 / entry_compressed as f64; | |
| 361 | + | if entry_ratio > constants::SCAN_ZIP_MAX_RATIO { | |
| 362 | + | return LayerResult { | |
| 363 | + | layer: "archive", | |
| 364 | + | verdict: LayerVerdict::Fail, | |
| 365 | + | detail: Some(format!( | |
| 366 | + | "Entry {} compression ratio {:.1}x exceeds limit of {:.0}x (possible ZIP bomb)", | |
| 367 | + | name, entry_ratio, constants::SCAN_ZIP_MAX_RATIO | |
| 368 | + | )), | |
| 369 | + | }; | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 351 | 373 | // Check for nested archives — extension check first, then magic bytes | |
| 352 | 374 | // from the first decompression pass (no re-read needed). | |
| 353 | 375 | let lower_name = name.to_lowercase(); |
| @@ -301,14 +301,22 @@ async fn run_pipeline_and_decide( | |||
| 301 | 301 | .filter(|l| l.verdict == LayerVerdict::Fail) | |
| 302 | 302 | .map(|l| l.layer) | |
| 303 | 303 | .collect(); | |
| 304 | - | if let Some(ref wam) = ctx.wam { | |
| 304 | + | if let Some(wam) = ctx.wam.clone() { | |
| 305 | + | // Fire-and-forget: the WAM call has a multi-second timeout, and the | |
| 306 | + | // verdict enforcement below (row purge + object delete) must not wait | |
| 307 | + | // on it — otherwise a WAM outage stalls every quarantine ~5s before | |
| 308 | + | // the malicious object is removed (Run #21 Performance MODERATE). | |
| 309 | + | // Matches the spawned-ticket pattern the checkout path already uses. | |
| 305 | 310 | let title = format!("File quarantined: {}", job.s3_key); | |
| 306 | 311 | let body = format!( | |
| 307 | 312 | "Upload by user {} flagged as malicious.\n\ | |
| 308 | 313 | Failed layers: {}\nFile type: {file_type:?}\nSize: {}", | |
| 309 | 314 | job.user_id, failed_layers.join(", "), job.file_size_bytes, | |
| 310 | 315 | ); | |
| 311 | - | wam.create_ticket(&title, Some(&body), "high", "malware-quarantine", Some(&job.s3_key)).await; | |
| 316 | + | let s3_key = job.s3_key.clone(); | |
| 317 | + | tokio::spawn(async move { | |
| 318 | + | wam.create_ticket(&title, Some(&body), "high", "malware-quarantine", Some(&s3_key)).await; | |
| 319 | + | }); | |
| 312 | 320 | } | |
| 313 | 321 | ||
| 314 | 322 | // Enforce the verdict by removing the malicious content. Two parts: |
| @@ -71,6 +71,10 @@ pub fn spawn_scheduler( | |||
| 71 | 71 | let mut interval = tokio::time::interval( | |
| 72 | 72 | std::time::Duration::from_secs(constants::SCHEDULER_INTERVAL_SECS), | |
| 73 | 73 | ); | |
| 74 | + | // Skip (not burst) missed ticks: a long-running pass (storage recalc, | |
| 75 | + | // drift checks under the advisory lock) must not trigger a catch-up | |
| 76 | + | // burst of back-to-back ticks on the next wake. | |
| 77 | + | interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); | |
| 74 | 78 | interval.tick().await; // consume immediate first tick | |
| 75 | 79 | ||
| 76 | 80 | let mut tick_count: u64 = 0; |
| @@ -79,6 +79,11 @@ pub fn img_to_video(html: &str) -> String { | |||
| 79 | 79 | .map(|c| c[1].to_string()) | |
| 80 | 80 | .unwrap_or_default(); | |
| 81 | 81 | ||
| 82 | + | // Escape `src` too, not just `alt`. The regex capture is `[^"]*` and | |
| 83 | + | // ammonia (which ran before this) entity-escapes `"`/`<`/`>` inside | |
| 84 | + | // attribute values, so a breakout isn't representable today — but | |
| 85 | + | // don't depend on that invariant holding upstream (Run #21 UX NOTE). | |
| 86 | + | let src = crate::escape::html_escape(src); | |
| 82 | 87 | if alt.is_empty() { | |
| 83 | 88 | format!(r#"<video controls src="{}">Your browser does not support video.</video>"#, src) | |
| 84 | 89 | } else { |