max / makenotwork
22 files changed,
+413 insertions,
-34 deletions
| @@ -92,6 +92,64 @@ v0.3.23. Audit grade A. ~1,233 tests. | |||
| 92 | 92 | ||
| 93 | 93 | --- | |
| 94 | 94 | ||
| 95 | + | ## File Scanning — Future Improvements | |
| 96 | + | ||
| 97 | + | Files > 100 MB are now held for review instead of downloaded into RAM. Next steps: | |
| 98 | + | ||
| 99 | + | ### Background scan queue (next) | |
| 100 | + | - [ ] Add `scan_queue` table (s3_key, file_type, user_id, status, created_at) | |
| 101 | + | - [ ] Enqueue oversized files from `scan_and_classify` instead of blanket HeldForReview | |
| 102 | + | - [ ] Scheduler picks up queued scans, streams from S3 to temp file, scans from disk | |
| 103 | + | - [ ] Update entity scan status + notify creator on completion | |
| 104 | + | - [ ] ClamAV already supports chunked `INSTREAM` — use it for streaming scans | |
| 105 | + | - [ ] SHA-256 is naturally streaming — hash in chunks during download | |
| 106 | + | - [ ] YARA requires full buffer — memory-map the temp file or skip YARA for large files | |
| 107 | + | ||
| 108 | + | ### Separate scanning service (later, when traffic justifies) | |
| 109 | + | - [ ] Extract scan worker into standalone binary (same crate, different bin target) | |
| 110 | + | - [ ] Worker polls scan_queue, runs on dedicated machine with more RAM | |
| 111 | + | - [ ] Allows horizontal scaling independently of request serving | |
| 112 | + | - [ ] Consider GPU-accelerated analysis if volume warrants it | |
| 113 | + | ||
| 114 | + | ### Other scanning hardening | |
| 115 | + | - [ ] Add timeout to YARA scanning (currently unbounded; crafted input could stall) | |
| 116 | + | - [ ] Cap ClamAV response buffer size (currently unbounded `read_to_end`) | |
| 117 | + | - [ ] Nested archive detection: check magic bytes, not just file extensions | |
| 118 | + | ||
| 119 | + | --- | |
| 120 | + | ||
| 121 | + | ## Code Fuzz Findings (2026-04-25) | |
| 122 | + | ||
| 123 | + | Bugs found during adversarial code review. Ordered by severity. | |
| 124 | + | ||
| 125 | + | ### Critical | |
| 126 | + | - [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`.~~ | |
| 127 | + | ||
| 128 | + | ### Serious | |
| 129 | + | - [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.~~ | |
| 130 | + | - [x] ~~`validate_key_code` accepts `"----"` — empty word segments pass `all()` vacuously. Fixed: added `part.is_empty()` check + tests.~~ | |
| 131 | + | - [x] ~~Project image confirm missing S3 key prefix validation. Fixed: added `starts_with` user ID check in `project_image_confirm`.~~ | |
| 132 | + | - [x] ~~SyncKit auth lacks dummy hash. Fixed: added `DUMMY_HASH` + `verify_password` timing equalization.~~ | |
| 133 | + | - [x] ~~SyncUser extractor does not check user suspension. Fixed: added `get_user_by_id` + `is_suspended()` check.~~ | |
| 134 | + | - [x] ~~`delete_subscription_tier` TOCTOU. Fixed: wrapped in transaction with `FOR UPDATE` on the tier row.~~ | |
| 135 | + | ||
| 136 | + | ### Minor | |
| 137 | + | - [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.~~ | |
| 138 | + | - [x] ~~CSRF body buffer (64KB) < global body limit (1MB). Fixed: increased buffer to 1MB to match global `RequestBodyLimitLayer`.~~ | |
| 139 | + | - [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.~~ | |
| 140 | + | - [ ] Idempotency check not atomic with operation — concurrent requests both execute (`db/idempotency.rs`). Safe only because underlying ops are themselves idempotent. | |
| 141 | + | - [ ] `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. | |
| 142 | + | ||
| 143 | + | ### Note | |
| 144 | + | - [x] ~~`get_user_purchases` duplicate rows. Fixed: wrapped query in `DISTINCT ON (p.item_id)` subquery.~~ | |
| 145 | + | - [x] ~~`revoke_keys_by_transaction` LIMIT 1000. Fixed: removed the cap — bulk UPDATE already has no limit, SELECT now matches.~~ | |
| 146 | + | - [x] ~~YARA scanning has no timeout. Fixed: `scanner.set_timeout(30s)` via yara-x native API.~~ | |
| 147 | + | - [ ] 7-day SyncKit JWT with no per-user revocation (`constants.rs:37`). Stolen token usable for full window. | |
| 148 | + | - [ ] Nested archive detection is extension-based only, not magic bytes (`scanning/archive.rs:81`). | |
| 149 | + | - [ ] No rate limiting on read API routes — enables enumeration of tags, categories, domains (`api/mod.rs:366`). | |
| 150 | + | ||
| 151 | + | --- | |
| 152 | + | ||
| 95 | 153 | ## Content Fingerprinting — Remaining | |
| 96 | 154 | - [ ] Invisible image watermarks — LSB encoding (stub exists at `fingerprint/watermark_image.rs`) | |
| 97 | 155 | - [ ] Invisible audio watermarks — spread-spectrum (stub exists at `fingerprint/watermark_audio.rs`) |
| @@ -0,0 +1,18 @@ | |||
| 1 | + | -- Pending refunds queue: stores charge.refunded webhook data when no matching | |
| 2 | + | -- completed transaction exists yet (out-of-order webhook delivery). | |
| 3 | + | -- | |
| 4 | + | -- The scheduler checks for matches periodically and escalates stale entries. | |
| 5 | + | ||
| 6 | + | CREATE TABLE pending_refunds ( | |
| 7 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 8 | + | payment_intent_id TEXT NOT NULL, | |
| 9 | + | amount BIGINT NOT NULL, | |
| 10 | + | amount_refunded BIGINT NOT NULL, | |
| 11 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 12 | + | matched_at TIMESTAMPTZ, | |
| 13 | + | escalated_at TIMESTAMPTZ | |
| 14 | + | ); | |
| 15 | + | ||
| 16 | + | CREATE INDEX idx_pending_refunds_payment_intent | |
| 17 | + | ON pending_refunds (payment_intent_id) | |
| 18 | + | WHERE matched_at IS NULL; |
| @@ -201,9 +201,11 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response { | |||
| 201 | 201 | return (StatusCode::FORBIDDEN, "CSRF token required").into_response(); | |
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | - | // Buffer the body to extract _csrf, then reconstruct the request | |
| 204 | + | // Buffer the body to extract _csrf, then reconstruct the request. | |
| 205 | + | // Limit matches the global RequestBodyLimitLayer (1 MB) so that any | |
| 206 | + | // form body accepted by the server can have its CSRF token extracted. | |
| 205 | 207 | let (parts, body) = request.into_parts(); | |
| 206 | - | let bytes = match axum::body::to_bytes(body, 1024 * 64).await { | |
| 208 | + | let bytes = match axum::body::to_bytes(body, 1024 * 1024).await { | |
| 207 | 209 | Ok(b) => b, | |
| 208 | 210 | Err(_) => { | |
| 209 | 211 | return (StatusCode::BAD_REQUEST, "Request body too large").into_response(); |
| @@ -307,7 +307,7 @@ pub async fn revoke_keys_by_transaction( | |||
| 307 | 307 | ) -> Result<u64> { | |
| 308 | 308 | // Get all key IDs for this transaction | |
| 309 | 309 | let key_ids: Vec<LicenseKeyId> = sqlx::query_scalar( | |
| 310 | - | "SELECT id FROM license_keys WHERE transaction_id = $1 AND revoked_at IS NULL LIMIT 1000", | |
| 310 | + | "SELECT id FROM license_keys WHERE transaction_id = $1 AND revoked_at IS NULL", | |
| 311 | 311 | ) | |
| 312 | 312 | .bind(transaction_id) | |
| 313 | 313 | .fetch_all(&mut *conn) |
| @@ -59,6 +59,7 @@ pub(crate) mod media_files; | |||
| 59 | 59 | pub(crate) mod tips; | |
| 60 | 60 | pub(crate) mod project_members; | |
| 61 | 61 | pub(crate) mod idempotency; | |
| 62 | + | pub(crate) mod pending_refunds; | |
| 62 | 63 | pub(crate) mod webhook_events; | |
| 63 | 64 | ||
| 64 | 65 | pub use id_types::*; |
| @@ -0,0 +1,110 @@ | |||
| 1 | + | //! Pending refunds queue for out-of-order webhook delivery. | |
| 2 | + | //! | |
| 3 | + | //! When a `charge.refunded` webhook arrives before its matching | |
| 4 | + | //! `checkout.session.completed`, the refund data is stored here. | |
| 5 | + | //! The scheduler and checkout handler both check for pending matches. | |
| 6 | + | ||
| 7 | + | use sqlx::PgPool; | |
| 8 | + | ||
| 9 | + | use crate::error::Result; | |
| 10 | + | ||
| 11 | + | /// Insert a pending refund for later matching. | |
| 12 | + | pub async fn insert_pending_refund( | |
| 13 | + | pool: &PgPool, | |
| 14 | + | payment_intent_id: &str, | |
| 15 | + | amount: i64, | |
| 16 | + | amount_refunded: i64, | |
| 17 | + | ) -> Result<()> { | |
| 18 | + | sqlx::query( | |
| 19 | + | r#" | |
| 20 | + | INSERT INTO pending_refunds (payment_intent_id, amount, amount_refunded) | |
| 21 | + | VALUES ($1, $2, $3) | |
| 22 | + | "#, | |
| 23 | + | ) | |
| 24 | + | .bind(payment_intent_id) | |
| 25 | + | .bind(amount) | |
| 26 | + | .bind(amount_refunded) | |
| 27 | + | .execute(pool) | |
| 28 | + | .await?; | |
| 29 | + | ||
| 30 | + | Ok(()) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /// Row from the pending_refunds table. | |
| 34 | + | #[derive(Debug, sqlx::FromRow)] | |
| 35 | + | pub struct PendingRefund { | |
| 36 | + | pub id: uuid::Uuid, | |
| 37 | + | pub payment_intent_id: String, | |
| 38 | + | pub amount: i64, | |
| 39 | + | pub amount_refunded: i64, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// Claim a pending refund matching a payment intent ID. | |
| 43 | + | /// | |
| 44 | + | /// Atomically marks it as matched (so it is only processed once). | |
| 45 | + | /// Returns `None` if no unmatched pending refund exists. | |
| 46 | + | pub async fn claim_pending_refund( | |
| 47 | + | pool: &PgPool, | |
| 48 | + | payment_intent_id: &str, | |
| 49 | + | ) -> Result<Option<PendingRefund>> { | |
| 50 | + | let row = sqlx::query_as::<_, PendingRefund>( | |
| 51 | + | r#" | |
| 52 | + | UPDATE pending_refunds | |
| 53 | + | SET matched_at = NOW() | |
| 54 | + | WHERE id = ( | |
| 55 | + | SELECT id FROM pending_refunds | |
| 56 | + | WHERE payment_intent_id = $1 AND matched_at IS NULL | |
| 57 | + | LIMIT 1 | |
| 58 | + | FOR UPDATE SKIP LOCKED | |
| 59 | + | ) | |
| 60 | + | RETURNING id, payment_intent_id, amount, amount_refunded | |
| 61 | + | "#, | |
| 62 | + | ) | |
| 63 | + | .bind(payment_intent_id) | |
| 64 | + | .fetch_optional(pool) | |
| 65 | + | .await?; | |
| 66 | + | ||
| 67 | + | Ok(row) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Row for stale pending refunds that need escalation. | |
| 71 | + | #[derive(Debug, sqlx::FromRow)] | |
| 72 | + | pub struct StaleRefund { | |
| 73 | + | pub id: uuid::Uuid, | |
| 74 | + | pub payment_intent_id: String, | |
| 75 | + | pub amount: i64, | |
| 76 | + | pub amount_refunded: i64, | |
| 77 | + | pub created_at: chrono::DateTime<chrono::Utc>, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// Get pending refunds older than `age` that have not been matched or escalated. | |
| 81 | + | pub async fn get_stale_refunds( | |
| 82 | + | pool: &PgPool, | |
| 83 | + | age: chrono::Duration, | |
| 84 | + | ) -> Result<Vec<StaleRefund>> { | |
| 85 | + | let cutoff = chrono::Utc::now() - age; | |
| 86 | + | let rows = sqlx::query_as::<_, StaleRefund>( | |
| 87 | + | r#" | |
| 88 | + | SELECT id, payment_intent_id, amount, amount_refunded, created_at | |
| 89 | + | FROM pending_refunds | |
| 90 | + | WHERE matched_at IS NULL | |
| 91 | + | AND escalated_at IS NULL | |
| 92 | + | AND created_at < $1 | |
| 93 | + | ORDER BY created_at | |
| 94 | + | "#, | |
| 95 | + | ) | |
| 96 | + | .bind(cutoff) | |
| 97 | + | .fetch_all(pool) | |
| 98 | + | .await?; | |
| 99 | + | ||
| 100 | + | Ok(rows) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// Mark a pending refund as escalated (alert sent, won't be re-alerted). | |
| 104 | + | pub async fn mark_escalated(pool: &PgPool, id: uuid::Uuid) -> Result<()> { | |
| 105 | + | sqlx::query("UPDATE pending_refunds SET escalated_at = NOW() WHERE id = $1") | |
| 106 | + | .bind(id) | |
| 107 | + | .execute(pool) | |
| 108 | + | .await?; | |
| 109 | + | Ok(()) | |
| 110 | + | } |
| @@ -137,27 +137,40 @@ pub async fn update_tier_stripe_ids( | |||
| 137 | 137 | ||
| 138 | 138 | /// Delete a subscription tier. Soft-deletes (sets is_active=false) if any | |
| 139 | 139 | /// subscriptions reference it; hard-deletes otherwise. | |
| 140 | + | /// | |
| 141 | + | /// Uses a transaction with FOR UPDATE to prevent a TOCTOU race where a | |
| 142 | + | /// subscription could be created between the existence check and the delete. | |
| 140 | 143 | #[tracing::instrument(skip_all)] | |
| 141 | 144 | pub async fn delete_subscription_tier(pool: &PgPool, id: SubscriptionTierId) -> Result<()> { | |
| 145 | + | let mut tx = pool.begin().await?; | |
| 146 | + | ||
| 147 | + | // Lock the tier row to serialize against concurrent subscription creation | |
| 148 | + | sqlx::query("SELECT id FROM subscription_tiers WHERE id = $1 FOR UPDATE") | |
| 149 | + | .bind(id) | |
| 150 | + | .fetch_optional(&mut *tx) | |
| 151 | + | .await? | |
| 152 | + | .ok_or(sqlx::Error::RowNotFound)?; | |
| 153 | + | ||
| 142 | 154 | let has_subscriptions: bool = sqlx::query_scalar( | |
| 143 | 155 | "SELECT EXISTS(SELECT 1 FROM subscriptions WHERE tier_id = $1)", | |
| 144 | 156 | ) | |
| 145 | 157 | .bind(id) | |
| 146 | - | .fetch_one(pool) | |
| 158 | + | .fetch_one(&mut *tx) | |
| 147 | 159 | .await?; | |
| 148 | 160 | ||
| 149 | 161 | if has_subscriptions { | |
| 150 | 162 | sqlx::query("UPDATE subscription_tiers SET is_active = false WHERE id = $1") | |
| 151 | 163 | .bind(id) | |
| 152 | - | .execute(pool) | |
| 164 | + | .execute(&mut *tx) | |
| 153 | 165 | .await?; | |
| 154 | 166 | } else { | |
| 155 | 167 | sqlx::query("DELETE FROM subscription_tiers WHERE id = $1") | |
| 156 | 168 | .bind(id) | |
| 157 | - | .execute(pool) | |
| 169 | + | .execute(&mut *tx) | |
| 158 | 170 | .await?; | |
| 159 | 171 | } | |
| 160 | 172 | ||
| 173 | + | tx.commit().await?; | |
| 161 | 174 | Ok(()) | |
| 162 | 175 | } | |
| 163 | 176 |
| @@ -312,21 +312,24 @@ pub async fn create_project_transaction( | |||
| 312 | 312 | pub async fn get_user_purchases(pool: &PgPool, user_id: UserId) -> Result<Vec<DbPurchaseRow>> { | |
| 313 | 313 | let purchases = sqlx::query_as::<_, DbPurchaseRow>( | |
| 314 | 314 | r#" | |
| 315 | - | SELECT | |
| 316 | - | p.item_id, | |
| 317 | - | i.title, | |
| 318 | - | u.username as creator, | |
| 319 | - | i.item_type, | |
| 320 | - | p.purchased_at, | |
| 321 | - | (i.price_cents = 0) as is_free, | |
| 322 | - | lk.key_code as license_key_code | |
| 323 | - | FROM purchases p | |
| 324 | - | JOIN items i ON p.item_id = i.id | |
| 325 | - | JOIN projects proj ON i.project_id = proj.id | |
| 326 | - | JOIN users u ON proj.user_id = u.id | |
| 327 | - | 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 | |
| 328 | - | WHERE p.buyer_id = $1 | |
| 329 | - | ORDER BY p.purchased_at DESC | |
| 315 | + | SELECT * FROM ( | |
| 316 | + | SELECT DISTINCT ON (p.item_id) | |
| 317 | + | p.item_id, | |
| 318 | + | i.title, | |
| 319 | + | u.username as creator, | |
| 320 | + | i.item_type, | |
| 321 | + | p.purchased_at, | |
| 322 | + | (i.price_cents = 0) as is_free, | |
| 323 | + | lk.key_code as license_key_code | |
| 324 | + | FROM purchases p | |
| 325 | + | JOIN items i ON p.item_id = i.id | |
| 326 | + | JOIN projects proj ON i.project_id = proj.id | |
| 327 | + | JOIN users u ON proj.user_id = u.id | |
| 328 | + | 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 | |
| 329 | + | WHERE p.buyer_id = $1 | |
| 330 | + | ORDER BY p.item_id, p.purchased_at DESC | |
| 331 | + | ) deduped | |
| 332 | + | ORDER BY purchased_at DESC | |
| 330 | 333 | LIMIT 20 | |
| 331 | 334 | "#, | |
| 332 | 335 | ) |
| @@ -155,7 +155,7 @@ pub(super) async fn confirm_upload( | |||
| 155 | 155 | ||
| 156 | 156 | // Scan + classify | |
| 157 | 157 | let (status, malware_err) = | |
| 158 | - | crate::routes::storage::scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, req.user_id) | |
| 158 | + | crate::routes::storage::scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, req.user_id, file_size_bytes) | |
| 159 | 159 | .await?; | |
| 160 | 160 | db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?; | |
| 161 | 161 | if let Some(err) = malware_err { |
| @@ -327,8 +327,6 @@ pub fn api_routes() -> Router<AppState> { | |||
| 327 | 327 | .route("/api/domains/{id}", delete(domains::remove_domain)) | |
| 328 | 328 | // Invite codes | |
| 329 | 329 | .route("/api/invites/create", post(users::create_invite)) | |
| 330 | - | // Import system | |
| 331 | - | .route("/api/users/me/import", post(imports::start_import)) | |
| 332 | 330 | // Email signup (public, landing page notify-me) | |
| 333 | 331 | .route("/api/email-signup", post(email_signup)) | |
| 334 | 332 | .route_layer(GovernorLayer { | |
| @@ -411,11 +409,22 @@ pub fn api_routes() -> Router<AppState> { | |||
| 411 | 409 | config: validate_rate_limit, | |
| 412 | 410 | }); | |
| 413 | 411 | ||
| 412 | + | // Import route needs a higher body limit (base64-encoded CSV up to 10 MB | |
| 413 | + | // ≈ 14 MB encoded). The global 1 MB RequestBodyLimitLayer would reject it, | |
| 414 | + | // so we override with a per-route layer. | |
| 415 | + | let import_routes = Router::new() | |
| 416 | + | .route("/api/users/me/import", post(imports::start_import)) | |
| 417 | + | .layer(axum::extract::DefaultBodyLimit::max(15 * 1024 * 1024)) | |
| 418 | + | .route_layer(GovernorLayer { | |
| 419 | + | config: crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST), | |
| 420 | + | }); | |
| 421 | + | ||
| 414 | 422 | write_routes | |
| 415 | 423 | .merge(export_routes) | |
| 416 | 424 | .merge(key_routes) | |
| 417 | 425 | .merge(validate_routes) | |
| 418 | 426 | .merge(read_routes) | |
| 427 | + | .merge(import_routes) | |
| 419 | 428 | .merge(internal::internal_routes()) | |
| 420 | 429 | .layer(axum::middleware::from_fn(json_error_layer)) | |
| 421 | 430 | } |
| @@ -92,6 +92,37 @@ pub(super) async fn verify_two_factor( | |||
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | if !verified { | |
| 95 | + | // Track failed 2FA attempts toward account lockout (same counter as | |
| 96 | + | // failed password attempts — prevents brute-forcing 6-digit TOTP codes) | |
| 97 | + | db::auth::increment_failed_login( | |
| 98 | + | &state.db, | |
| 99 | + | user_id, | |
| 100 | + | constants::MAX_LOGIN_ATTEMPTS, | |
| 101 | + | constants::LOCKOUT_MINUTES, | |
| 102 | + | ) | |
| 103 | + | .await?; | |
| 104 | + | ||
| 105 | + | // Check if this attempt triggered a lockout | |
| 106 | + | let user_after = db::users::get_user_by_id(&state.db, user_id).await?; | |
| 107 | + | if let Some(ref u) = user_after | |
| 108 | + | && let Some(locked_until) = u.locked_until | |
| 109 | + | && locked_until > chrono::Utc::now() | |
| 110 | + | { | |
| 111 | + | // Clear the 2FA flow — account is now locked | |
| 112 | + | session.remove::<UserId>(PENDING_2FA_KEY).await.ok(); | |
| 113 | + | let remaining = (locked_until - chrono::Utc::now()).num_minutes() + 1; | |
| 114 | + | let csrf_token = get_csrf_token(&session).await; | |
| 115 | + | return Ok(TwoFactorTemplate { | |
| 116 | + | csrf_token, | |
| 117 | + | session_user: None, | |
| 118 | + | error: Some(format!( | |
| 119 | + | "Too many failed attempts. Account locked for {} minute(s).", | |
| 120 | + | remaining | |
| 121 | + | )), | |
| 122 | + | } | |
| 123 | + | .into_response()); | |
| 124 | + | } | |
| 125 | + | ||
| 95 | 126 | let csrf_token = get_csrf_token(&session).await; | |
| 96 | 127 | return Ok(TwoFactorTemplate { | |
| 97 | 128 | csrf_token, | |
| @@ -101,6 +132,9 @@ pub(super) async fn verify_two_factor( | |||
| 101 | 132 | .into_response()); | |
| 102 | 133 | } | |
| 103 | 134 | ||
| 135 | + | // Successful 2FA — reset failed login counter | |
| 136 | + | db::auth::reset_failed_login(&state.db, user_id).await?; | |
| 137 | + | ||
| 104 | 138 | // Retrieve stored notification info | |
| 105 | 139 | let notify_enabled: bool = session | |
| 106 | 140 | .get(PENDING_2FA_NOTIFY_ENABLED) |
| @@ -121,6 +121,14 @@ pub(super) async fn project_image_confirm( | |||
| 121 | 121 | return Err(AppError::Forbidden); | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | // Validate S3 key belongs to this user (prevent cross-user file reference) | |
| 125 | + | let expected_prefix = format!("{}/", user.id); | |
| 126 | + | if !req.s3_key.starts_with(&expected_prefix) { | |
| 127 | + | return Err(AppError::BadRequest( | |
| 128 | + | "Invalid upload key".to_string(), | |
| 129 | + | )); | |
| 130 | + | } | |
| 131 | + | ||
| 124 | 132 | // Verify the object exists in S3 | |
| 125 | 133 | if !s3.object_exists(&req.s3_key).await? { | |
| 126 | 134 | return Err(AppError::BadRequest( | |
| @@ -150,7 +158,7 @@ pub(super) async fn project_image_confirm( | |||
| 150 | 158 | }; | |
| 151 | 159 | ||
| 152 | 160 | // Scan + classify | |
| 153 | - | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id).await?; | |
| 161 | + | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id, file_size_bytes).await?; | |
| 154 | 162 | // Project images don't have an item-level scan status, just log it | |
| 155 | 163 | if status == db::FileScanStatus::Quarantined | |
| 156 | 164 | && let Some(err) = malware_err | |
| @@ -283,7 +291,7 @@ pub(super) async fn item_image_confirm( | |||
| 283 | 291 | }; | |
| 284 | 292 | ||
| 285 | 293 | // Scan + classify | |
| 286 | - | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id).await?; | |
| 294 | + | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id, file_size_bytes).await?; | |
| 287 | 295 | db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?; | |
| 288 | 296 | if let Some(err) = malware_err { | |
| 289 | 297 | return Err(err); |
| @@ -233,7 +233,7 @@ pub(super) async fn media_confirm( | |||
| 233 | 233 | ||
| 234 | 234 | // Scan | |
| 235 | 235 | let (_status, malware_err) = | |
| 236 | - | scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, user.id).await?; | |
| 236 | + | scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, user.id, file_size_bytes).await?; | |
| 237 | 237 | if let Some(err) = malware_err { | |
| 238 | 238 | return Err(err); | |
| 239 | 239 | } |
| @@ -86,8 +86,22 @@ pub(crate) async fn scan_and_classify( | |||
| 86 | 86 | s3_key: &str, | |
| 87 | 87 | file_type: FileType, | |
| 88 | 88 | user_id: db::UserId, | |
| 89 | + | file_size_bytes: i64, | |
| 89 | 90 | ) -> Result<(db::FileScanStatus, Option<AppError>)> { | |
| 90 | 91 | if let Some(ref scanner) = state.scanner { | |
| 92 | + | // Guard against OOM: files larger than SCAN_MAX_MEMORY_BYTES cannot | |
| 93 | + | // be loaded into RAM for in-process scanning. Hold them for admin | |
| 94 | + | // review instead of skipping the scan entirely (fail closed). | |
| 95 | + | if file_size_bytes as usize > constants::SCAN_MAX_MEMORY_BYTES { | |
| 96 | + | tracing::warn!( | |
| 97 | + | s3_key, | |
| 98 | + | file_size_bytes, | |
| 99 | + | max = constants::SCAN_MAX_MEMORY_BYTES, | |
| 100 | + | "file too large for in-memory scan, holding for review" | |
| 101 | + | ); | |
| 102 | + | return Ok((db::FileScanStatus::HeldForReview, None)); | |
| 103 | + | } | |
| 104 | + | ||
| 91 | 105 | let data = s3.download_object(s3_key).await?; | |
| 92 | 106 | let result = scanner.scan(&data, file_type).await; | |
| 93 | 107 | ||
| @@ -100,6 +114,15 @@ pub(crate) async fn scan_and_classify( | |||
| 100 | 114 | .filter(|l| l.verdict == crate::scanning::LayerVerdict::Fail) | |
| 101 | 115 | .map(|l| l.layer) | |
| 102 | 116 | .collect(); | |
| 117 | + | if let Some(ref wam) = state.wam { | |
| 118 | + | let title = format!("File quarantined: {s3_key}"); | |
| 119 | + | let body = format!( | |
| 120 | + | "Upload by user {user_id} flagged as malicious.\n\ | |
| 121 | + | Failed layers: {}\nFile type: {file_type:?}\nSize: {file_size_bytes}", | |
| 122 | + | failed_layers.join(", "), | |
| 123 | + | ); | |
| 124 | + | wam.create_ticket(&title, Some(&body), "high", "malware-quarantine", Some(s3_key)).await; | |
| 125 | + | } | |
| 103 | 126 | return Ok(( | |
| 104 | 127 | db::FileScanStatus::Quarantined, | |
| 105 | 128 | Some(AppError::MalwareDetected(failed_layers.join(", "))), |
| @@ -154,7 +154,7 @@ pub(super) async fn confirm_upload( | |||
| 154 | 154 | }; | |
| 155 | 155 | ||
| 156 | 156 | // Scan + classify trust level | |
| 157 | - | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, user.id).await?; | |
| 157 | + | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, user.id, file_size_bytes).await?; | |
| 158 | 158 | db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?; | |
| 159 | 159 | if let Some(err) = malware_err { | |
| 160 | 160 | return Err(err); |
| @@ -148,7 +148,7 @@ pub(super) async fn version_confirm_upload( | |||
| 148 | 148 | }; | |
| 149 | 149 | ||
| 150 | 150 | // Scan + classify trust level | |
| 151 | - | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Download, user.id).await?; | |
| 151 | + | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Download, user.id, file_size_bytes).await?; | |
| 152 | 152 | db::scanning::update_version_scan_status(&state.db, version_id, status).await?; | |
| 153 | 153 | if let Some(err) = malware_err { | |
| 154 | 154 | return Err(err); |
| @@ -81,6 +81,15 @@ pub(super) async fn handle_invoice_payment_succeeded( | |||
| 81 | 81 | user_id = %fan_sub.user_id, error = ?e, | |
| 82 | 82 | "failed to generate Fan+ monthly credit promo code" | |
| 83 | 83 | ); | |
| 84 | + | if let Some(ref wam) = state.wam { | |
| 85 | + | let title = format!("Fan+ credit not issued: user {}", fan_sub.user_id); | |
| 86 | + | let body = format!( | |
| 87 | + | "Fan+ subscriber {} paid renewal but $5 credit promo code \ | |
| 88 | + | generation failed: {e}\n\nManually create a promo code.", | |
| 89 | + | fan_sub.user_id, | |
| 90 | + | ); | |
| 91 | + | wam.create_ticket(&title, Some(&body), "high", "fan-plus-credit-failed", Some(&fan_sub.user_id.to_string())).await; | |
| 92 | + | } | |
| 84 | 93 | } | |
| 85 | 94 | } | |
| 86 | 95 | } | |
| @@ -205,6 +214,12 @@ pub(super) async fn handle_invoice_payment_failed( | |||
| 205 | 214 | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 206 | 215 | } | |
| 207 | 216 | ||
| 217 | + | // Create WAM ticket for subscription payment failures | |
| 218 | + | if let Some(ref wam) = state.wam { | |
| 219 | + | let title = format!("Subscription payment failed: {stripe_sub_id}"); | |
| 220 | + | wam.create_ticket(&title, None, "medium", "subscription-payment-failed", Some(&stripe_sub_id)).await; | |
| 221 | + | } | |
| 222 | + | ||
| 208 | 223 | Ok(()) | |
| 209 | 224 | } | |
| 210 | 225 | ||
| @@ -269,10 +284,21 @@ pub(super) async fn handle_charge_refunded( | |||
| 269 | 284 | { | |
| 270 | 285 | tracing::info!(payment_intent_id = %payment_intent_id, "tip refund processed"); | |
| 271 | 286 | } else { | |
| 287 | + | // No matching transaction or tip — the payment webhook likely hasn't | |
| 288 | + | // arrived yet. Queue the refund for later matching rather than | |
| 289 | + | // silently dropping it. | |
| 272 | 290 | tracing::warn!( | |
| 273 | 291 | payment_intent_id = %payment_intent_id, | |
| 274 | - | "no completed transaction or tip found for payment intent" | |
| 292 | + | "no completed transaction or tip found — queuing as pending refund" | |
| 275 | 293 | ); | |
| 294 | + | db::pending_refunds::insert_pending_refund( | |
| 295 | + | &state.db, | |
| 296 | + | payment_intent_id, | |
| 297 | + | refund_data.amount, | |
| 298 | + | refund_data.amount_refunded, | |
| 299 | + | ) | |
| 300 | + | .await | |
| 301 | + | .context("insert pending refund")?; | |
| 276 | 302 | } | |
| 277 | 303 | } | |
| 278 | 304 |
| @@ -86,6 +86,10 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 86 | 86 | ).await { | |
| 87 | 87 | tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event"); | |
| 88 | 88 | } | |
| 89 | + | ||
| 90 | + | // Check for a pending refund that arrived before this payment webhook. | |
| 91 | + | // If found, process it now that the transaction is completed. | |
| 92 | + | check_pending_refund(state, &payment_intent_id).await; | |
| 89 | 93 | } | |
| 90 | 94 | Ok(None) => { | |
| 91 | 95 | tracing::info!(session_id = %session_id, "transaction already completed, ignoring duplicate webhook"); | |
| @@ -121,6 +125,14 @@ async fn maybe_generate_license_key( | |||
| 121 | 125 | } | |
| 122 | 126 | Err(e) => { | |
| 123 | 127 | tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase"); | |
| 128 | + | if let Some(ref wam) = state.wam { | |
| 129 | + | let title = format!("License key not issued: item {item_id}"); | |
| 130 | + | let body = format!( | |
| 131 | + | "Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \ | |
| 132 | + | license key generation failed: {e}\n\nManually issue a key.", | |
| 133 | + | ); | |
| 134 | + | wam.create_ticket(&title, Some(&body), "critical", "license-key-gen-failed", Some(&transaction_id.to_string())).await; | |
| 135 | + | } | |
| 124 | 136 | } | |
| 125 | 137 | } | |
| 126 | 138 | } | |
| @@ -510,6 +522,40 @@ fn send_tip_email( | |||
| 510 | 522 | }); | |
| 511 | 523 | } | |
| 512 | 524 | ||
| 525 | + | /// Check if a pending refund exists for this payment intent and process it. | |
| 526 | + | /// | |
| 527 | + | /// Called after a transaction is completed to handle out-of-order webhook | |
| 528 | + | /// delivery (refund arrived before payment confirmation). | |
| 529 | + | async fn check_pending_refund(state: &AppState, payment_intent_id: &str) { | |
| 530 | + | let pending = match db::pending_refunds::claim_pending_refund(&state.db, payment_intent_id).await { | |
| 531 | + | Ok(Some(p)) => p, | |
| 532 | + | Ok(None) => return, | |
| 533 | + | Err(e) => { | |
| 534 | + | tracing::error!(error = ?e, "failed to check pending refunds"); | |
| 535 | + | return; | |
| 536 | + | } | |
| 537 | + | }; | |
| 538 | + | ||
| 539 | + | tracing::info!( | |
| 540 | + | payment_intent_id = %payment_intent_id, | |
| 541 | + | pending_refund_id = %pending.id, | |
| 542 | + | "found pending refund — processing now" | |
| 543 | + | ); | |
| 544 | + | ||
| 545 | + | let refund_data = crate::payments::ChargeRefundData { | |
| 546 | + | payment_intent_id: pending.payment_intent_id, | |
| 547 | + | amount: pending.amount, | |
| 548 | + | amount_refunded: pending.amount_refunded, | |
| 549 | + | }; | |
| 550 | + | ||
| 551 | + | if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data).await { | |
| 552 | + | tracing::error!( | |
| 553 | + | error = ?e, pending_refund_id = %pending.id, | |
| 554 | + | "failed to process pending refund after payment completion" | |
| 555 | + | ); | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 513 | 559 | /// Record revenue splits for a completed item purchase. | |
| 514 | 560 | /// | |
| 515 | 561 | /// Looks up the item's project and its members. If the project has members |
| @@ -7,12 +7,19 @@ use axum::{ | |||
| 7 | 7 | }; | |
| 8 | 8 | ||
| 9 | 9 | use crate::{ | |
| 10 | + | auth::verify_password, | |
| 10 | 11 | db, | |
| 11 | 12 | error::{AppError, Result}, | |
| 12 | 13 | synckit_auth, | |
| 13 | 14 | AppState, | |
| 14 | 15 | }; | |
| 15 | 16 | ||
| 17 | + | /// Pre-computed dummy Argon2 hash used to equalize timing when a user is not found, | |
| 18 | + | /// preventing email enumeration via response time differences. | |
| 19 | + | static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| { | |
| 20 | + | crate::auth::hash_password("anti-timing-dummy").expect("dummy hash") | |
| 21 | + | }); | |
| 22 | + | ||
| 16 | 23 | use super::{SyncAuthRequest, SyncAuthResponse, ValidateAppQuery, ValidateAppResponse}; | |
| 17 | 24 | ||
| 18 | 25 | /// Authenticate a user and return a JWT for subsequent sync API calls. | |
| @@ -39,9 +46,14 @@ pub(super) async fn sync_auth( | |||
| 39 | 46 | .ok_or(AppError::Unauthorized)?; | |
| 40 | 47 | ||
| 41 | 48 | // Verify user credentials | |
| 42 | - | let user = db::users::get_user_by_email(&state.db, &req.email) | |
| 43 | - | .await? | |
| 44 | - | .ok_or(AppError::Unauthorized)?; | |
| 49 | + | let user = match db::users::get_user_by_email(&state.db, &req.email).await? { | |
| 50 | + | Some(u) => u, | |
| 51 | + | None => { | |
| 52 | + | // Equalize timing to prevent email enumeration | |
| 53 | + | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 54 | + | return Err(AppError::Unauthorized); | |
| 55 | + | } | |
| 56 | + | }; | |
| 45 | 57 | ||
| 46 | 58 | // Check suspension | |
| 47 | 59 | if user.is_suspended() { |
| @@ -4,9 +4,13 @@ | |||
| 4 | 4 | //! rules directory. The compiled rules are stored in ScanPipeline and reused. | |
| 5 | 5 | ||
| 6 | 6 | use std::path::Path; | |
| 7 | + | use std::time::Duration; | |
| 7 | 8 | ||
| 8 | 9 | use super::{LayerResult, LayerVerdict}; | |
| 9 | 10 | ||
| 11 | + | /// Maximum time for a single YARA scan before it is aborted. | |
| 12 | + | const YARA_SCAN_TIMEOUT: Duration = Duration::from_secs(30); | |
| 13 | + | ||
| 10 | 14 | /// Compile all YARA rules from `.yar` files in a directory. | |
| 11 | 15 | /// Returns None if the directory doesn't exist or contains no rules. | |
| 12 | 16 | pub fn compile_rules_from_dir(dir: &str) -> Result<Option<yara_x::Rules>, String> { | |
| @@ -54,6 +58,7 @@ pub fn compile_rules_from_dir(dir: &str) -> Result<Option<yara_x::Rules>, String | |||
| 54 | 58 | /// Scan file data against compiled YARA rules. | |
| 55 | 59 | pub fn scan_with_yara(rules: &yara_x::Rules, data: &[u8]) -> LayerResult { | |
| 56 | 60 | let mut scanner = yara_x::Scanner::new(rules); | |
| 61 | + | scanner.set_timeout(YARA_SCAN_TIMEOUT); | |
| 57 | 62 | ||
| 58 | 63 | let scan_results = match scanner.scan(data) { | |
| 59 | 64 | Ok(results) => results, |