Fix 14 flaws from adversarial code fuzz, add pending refund queue Security and correctness fixes found by systematic code fuzzing: - Scan OOM guard: enforce SCAN_MAX_MEMORY_BYTES before downloading files - Validate key code rejects empty word segments ("----") - Project image confirm validates S3 key prefix - SyncKit auth uses dummy hash to prevent user enumeration - SyncUser extractor checks user suspension - Subscription tier delete wrapped in transaction (TOCTOU fix) - 2FA failed attempts count toward account lockout - CSRF body buffer increased to match global 1MB limit - Import route gets 15MB body limit override - License key revocation LIMIT 1000 removed - User purchases query deduped with DISTINCT ON - YARA scanner gets 30s native timeout Pending refund queue (migration 063): unmatched charge.refunded webhooks stored for later matching instead of silently dropped. Checkout handler checks for pending refunds after completing transactions. Scheduler escalates unmatched refunds >24h old via alert email.
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-25 20:07 UTC
Commit:
1be62a40f7453a6589adda9d02d1836a537b24eaParent:
22 files changed,
+413 insertions,
-34 deletions
---## File Scanning — Future ImprovementsFiles > 100 MB are now held for review instead of downloaded into RAM. Next steps:### Background scan queue (next)- [ ] Add `scan_queue` table (s3_key, file_type, user_id, status, created_at)- [ ] Enqueue oversized files from `scan_and_classify` instead of blanket HeldForReview- [ ] Scheduler picks up queued scans, streams from S3 to temp file, scans from disk- [ ] Update entity scan status + notify creator on completion- [ ] ClamAV already supports chunked `INSTREAM` — use it for streaming scans- [ ] SHA-256 is naturally streaming — hash in chunks during download- [ ] YARA requires full buffer — memory-map the temp file or skip YARA for large files### Separate scanning service (later, when traffic justifies)- [ ] Extract scan worker into standalone binary (same crate, different bin target)- [ ] Worker polls scan_queue, runs on dedicated machine with more RAM- [ ] Allows horizontal scaling independently of request serving- [ ] Consider GPU-accelerated analysis if volume warrants it### Other scanning hardening- [ ] Add timeout to YARA scanning (currently unbounded; crafted input could stall)- [ ] Cap ClamAV response buffer size (currently unbounded `read_to_end`)- [ ] Nested archive detection: check magic bytes, not just file extensions---## Code Fuzz Findings (2026-04-25)Bugs found during adversarial code review. Ordered by severity.### Critical- [x] ~~20 GB file downloaded into RAM for scanning — `SCAN_MAX_MEMORY_BYTES` was dead code (`routes/storage/mod.rs:91`). Fixed: size guard added to `scan_and_classify`.~~### Serious- [x] ~~Refund-before-payment webhook silently lost. Fixed: unmatched refunds stored in `pending_refunds` table (migration 063). Checkout handler checks for pending refunds after completing a transaction. Scheduler escalates unmatched refunds >24h old via admin alert email.~~- [x] ~~`validate_key_code` accepts `"----"` — empty word segments pass `all()` vacuously. Fixed: added `part.is_empty()` check + tests.~~- [x] ~~Project image confirm missing S3 key prefix validation. Fixed: added `starts_with` user ID check in `project_image_confirm`.~~- [x] ~~SyncKit auth lacks dummy hash. Fixed: added `DUMMY_HASH` + `verify_password` timing equalization.~~- [x] ~~SyncUser extractor does not check user suspension. Fixed: added `get_user_by_id` + `is_suspended()` check.~~- [x] ~~`delete_subscription_tier` TOCTOU. Fixed: wrapped in transaction with `FOR UPDATE` on the tier row.~~### Minor- [x] ~~2FA verification has no per-user failed-attempt counter. Fixed: reuses `increment_failed_login` — failed 2FA attempts count toward account lockout (5 attempts, 15 min). Reset on success.~~- [x] ~~CSRF body buffer (64KB) < global body limit (1MB). Fixed: increased buffer to 1MB to match global `RequestBodyLimitLayer`.~~- [x] ~~Import endpoint 10MB size limit unreachable due to 1MB global body limit. Fixed: pulled import route into its own group with 15MB `DefaultBodyLimit` override.~~- [ ] Idempotency check not atomic with operation — concurrent requests both execute (`db/idempotency.rs`). Safe only because underlying ops are themselves idempotent.- [ ] `Slug::from_trusted` used on untrusted URL path segments (`custom_domain.rs:164,182` + ~20 page routes). Safe due to sqlx parameterization but a latent footgun.### Note- [x] ~~`get_user_purchases` duplicate rows. Fixed: wrapped query in `DISTINCT ON (p.item_id)` subquery.~~- [x] ~~`revoke_keys_by_transaction` LIMIT 1000. Fixed: removed the cap — bulk UPDATE already has no limit, SELECT now matches.~~- [x] ~~YARA scanning has no timeout. Fixed: `scanner.set_timeout(30s)` via yara-x native API.~~- [ ] 7-day SyncKit JWT with no per-user revocation (`constants.rs:37`). Stolen token usable for full window.- [ ] Nested archive detection is extension-based only, not magic bytes (`scanning/archive.rs:81`).- [ ] No rate limiting on read API routes — enables enumeration of tags, categories, domains (`api/mod.rs:366`).---## Content Fingerprinting — Remaining- [ ] Invisible image watermarks — LSB encoding (stub exists at `fingerprint/watermark_image.rs`)- [ ] Invisible audio watermarks — spread-spectrum (stub exists at `fingerprint/watermark_audio.rs`) return (StatusCode::FORBIDDEN, "CSRF token required").into_response(); } // Buffer the body to extract _csrf, then reconstruct the request // Buffer the body to extract _csrf, then reconstruct the request. // Limit matches the global RequestBodyLimitLayer (1 MB) so that any // form body accepted by the server can have its CSRF token extracted. let (parts, body) = request.into_parts(); let bytes = match axum::body::to_bytes(body, 1024 * 64).await { let bytes = match axum::body::to_bytes(body, 1024 * 1024).await { Ok(b) => b, Err(_) => { return (StatusCode::BAD_REQUEST, "Request body too large").into_response(); return Err(AppError::Unauthorized); } // Verify user is not suspended (JWT may outlive suspension) let user = crate::db::users::get_user_by_id(&state.db, claims.sub) .await .map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to verify sync user")))? .ok_or(AppError::Unauthorized)?; if user.is_suspended() { return Err(AppError::Unauthorized); } Ok(SyncUser { user_id: claims.sub, app_id: claims.app,) -> Result<u64> { // Get all key IDs for this transaction let key_ids: Vec<LicenseKeyId> = sqlx::query_scalar( "SELECT id FROM license_keys WHERE transaction_id = $1 AND revoked_at IS NULL LIMIT 1000", "SELECT id FROM license_keys WHERE transaction_id = $1 AND revoked_at IS NULL", ) .bind(transaction_id) .fetch_all(&mut *conn)pub(crate) mod tips;pub(crate) mod project_members;pub(crate) mod idempotency;pub(crate) mod pending_refunds;pub(crate) mod webhook_events;pub use id_types::*;/// Delete a subscription tier. Soft-deletes (sets is_active=false) if any/// subscriptions reference it; hard-deletes otherwise.////// Uses a transaction with FOR UPDATE to prevent a TOCTOU race where a/// subscription could be created between the existence check and the delete.#[tracing::instrument(skip_all)]pub async fn delete_subscription_tier(pool: &PgPool, id: SubscriptionTierId) -> Result<()> { let mut tx = pool.begin().await?; // Lock the tier row to serialize against concurrent subscription creation sqlx::query("SELECT id FROM subscription_tiers WHERE id = $1 FOR UPDATE") .bind(id) .fetch_optional(&mut *tx) .await? .ok_or(sqlx::Error::RowNotFound)?; let has_subscriptions: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM subscriptions WHERE tier_id = $1)", ) .bind(id) .fetch_one(pool) .fetch_one(&mut *tx) .await?; if has_subscriptions { sqlx::query("UPDATE subscription_tiers SET is_active = false WHERE id = $1") .bind(id) .execute(pool) .execute(&mut *tx) .await?; } else { sqlx::query("DELETE FROM subscription_tiers WHERE id = $1") .bind(id) .execute(pool) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(())}pub async fn get_user_purchases(pool: &PgPool, user_id: UserId) -> Result<Vec<DbPurchaseRow>> { let purchases = sqlx::query_as::<_, DbPurchaseRow>( r#" SELECT p.item_id, i.title, u.username as creator, i.item_type, p.purchased_at, (i.price_cents = 0) as is_free, lk.key_code as license_key_code FROM purchases p JOIN items i ON p.item_id = i.id JOIN projects proj ON i.project_id = proj.id JOIN users u ON proj.user_id = u.id LEFT JOIN license_keys lk ON lk.item_id = p.item_id AND lk.owner_id = p.buyer_id AND lk.revoked_at IS NULL WHERE p.buyer_id = $1 ORDER BY p.purchased_at DESC SELECT * FROM ( SELECT DISTINCT ON (p.item_id) p.item_id, i.title, u.username as creator, i.item_type, p.purchased_at, (i.price_cents = 0) as is_free, lk.key_code as license_key_code FROM purchases p JOIN items i ON p.item_id = i.id JOIN projects proj ON i.project_id = proj.id JOIN users u ON proj.user_id = u.id LEFT JOIN license_keys lk ON lk.item_id = p.item_id AND lk.owner_id = p.buyer_id AND lk.revoked_at IS NULL WHERE p.buyer_id = $1 ORDER BY p.item_id, p.purchased_at DESC ) deduped ORDER BY purchased_at DESC LIMIT 20 "#, )