Skip to main content

max / makenotwork

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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-25 20:07 UTC
Commit: 1be62a40f7453a6589adda9d02d1836a537b24ea
Parent: 514ead9
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,