Security audit: fix 23 flaws from adversarial code fuzz, harden test suite Round 1 fixes (from prior audit, unstaged): - S3 key prefix validation on all confirm endpoints - Unicode homograph prevention (is_ascii_alphanumeric) - Bundle refund child transaction revocation (migration 061) - Tip amount overflow guard ($1-$10K bounds) - Project member split TOCTOU (SELECT FOR UPDATE) - SSE connection limit with drop guard - Backup code transaction wrapping - Storage increment ordering (before DB writes) - Constant-time compare via SHA-256 pre-hash - Login timing equalization (dummy Argon2 hash) - Atomic lockout increment (single SQL UPDATE) - Tip refund webhook handling - OTA semver ordering - SUM ::BIGINT casts in analytics - N+1 query batching (UNNEST, ANY) - Admin query LIMIT caps - DB ownership checks on mutation functions Round 2 fixes (fuzz audit, this session): - Partial refund handling: extract amount_refunded from Charge, skip revocation on partial refunds (previously any refund revoked all access) - Propagate complete_transaction errors so Stripe retries webhooks - Unique public project slug index (migration 062) + deterministic ORDER BY on get_public_project_by_slug - Move file type rejection before try_increment_storage to prevent storage counter leak on rejected Download/Insertion/Media types - Reject upload confirm when S3 object_size returns None instead of defaulting to 0 bytes (6 confirm handlers) - PWYW $10K max cap (matching tip ceiling) - Revenue split rounding with remainder distribution - Fee display clamped to 0 for sub-31-cent items - Password 128-char cap on login and SyncKit auth paths - Session cycle before storing pending_2fa_user_id - slugify() restricted to ASCII alphanumeric - SyncKit app is_active check in JWT extractor - SSE sync_notify and sse_connections pruning on disconnect - Image content-type: reject unrecognized magic bytes for Cover/MediaImage Test suite: - Template database for integration tests (CREATE DATABASE ... TEMPLATE) - SSE streaming tests marked #[ignore] to prevent binary hang - New integration tests for auth, blog, content, license keys, payments
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-25 07:14 UTC
Commit:
514ead900b1068762c70e1f978c5507c70cf854eParent:
66 files changed,
+2558 insertions,
-658 deletions
pub const SYNC_LOG_RETAIN_DAYS: i64 = 90;pub const SYNCKIT_MAX_BLOB_SIZE_BYTES: i64 = 500 * 1024 * 1024; // 500 MBpub const SYNCKIT_BLOB_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hourpub const SYNCKIT_MAX_SSE_CONNECTIONS_PER_USER: usize = 10;// -- Subscriptions --pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum}/// Constant-time string comparison to prevent timing attacks.////// Hashes both inputs with SHA-256 before comparing to avoid leaking/// the length of the expected value via early return.pub fn constant_time_compare(a: &str, b: &str) -> bool { if a.len() != b.len() { return false; } use sha2::{Sha256, Digest}; let hash_a = Sha256::digest(a.as_bytes()); let hash_b = Sha256::digest(b.as_bytes()); let mut result = 0u8; for (x, y) in a.bytes().zip(b.bytes()) { for (x, y) in hash_a.iter().zip(hash_b.iter()) { result |= x ^ y; } result == 0 let slug: String = title .to_lowercase() .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); // Collapse multiple hyphens, trim from ends let mut result = String::new(); } let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE + crate::constants::STRIPE_FEE_FIXED_CENTS) as i32; (fee, price_cents - fee) let creator_receives = (price_cents - fee).max(0); (fee.min(price_cents), creator_receives)}/// Sanitize a string for use as a CSV cell value. /// SSE push notification channels for SyncKit subscribers. /// Key: (app_id, user_id), Value: broadcast sender that SSE connections subscribe to. pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<()>>>, /// Concurrent SSE connection count per user (for rate limiting). pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>, /// Prometheus metrics handle for rendering the admin dashboard. `None` in tests. pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,} domain_cache, restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), sync_notify: std::sync::Arc::new(dashmap::DashMap::new()), sse_connections: std::sync::Arc::new(dashmap::DashMap::new()), metrics_handle: Some(makenotwork::metrics::init()), }; fn validate_amount(&self, amount_cents: i32) -> Result<(), String> { let min = self.min_cents.unwrap_or(0); if amount_cents < min { Err(format!( return Err(format!( "Amount must be at least ${:.2}", min as f64 / 100.0 )) } else { Ok(()) )); } // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges if amount_cents > 1_000_000 { return Err("Amount cannot exceed $10,000".to_string()); } Ok(()) } fn kind(&self) -> db::PricingKind { let claims = decode_sync_token(secret, token)?; // Verify the app is still active (JWT may outlive app deactivation) let app = crate::db::synckit::get_sync_app_by_id(&state.db, claims.app) .await .map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to verify sync app")))? .ok_or(AppError::Unauthorized)?; if !app.is_active { return Err(AppError::Unauthorized); } Ok(SyncUser { user_id: claims.sub, app_id: claims.app,