Security hardening: code fuzz round 3 fixes OAuth: anti-timing dummy hash on user-not-found, password length cap (128), block suspended/deactivated users, fix session validation logic. Builds: constant-time token comparison. CSRF: regenerate token on login (session fixation defense). CSP: add Content-Security-Policy header. Metrics: protect /metrics endpoint with Bearer token auth. Idempotency: cache actual response body instead of empty string. SyncKit auth: block deactivated users (not just suspended). Git routes: add rate limiting (burst 30, 200ms interval). Storage uploads: idempotency checks, S3 cleanup on storage quota failure, decrement old storage on file replacement, re-validate content type/extension. Cover/MediaImage: return storage cap instead of i64::MAX in check_upload_allowed. Collections/custom_links: atomic INSERT...SELECT for sort_order, wrap reorder in tx. Custom domains: SELECT FOR UPDATE to prevent TOCTOU race on 1-domain limit. Constants: add git browse rate limit, sandbox constants.
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-26 19:43 UTC
Commit:
dc8dfe91913c773c47002043350a1d6823af037bParent:
17 files changed,
+236 insertions,
-55 deletions
pub creator_tier: Option<String>, #[serde(default)] pub deactivated: bool, #[serde(default)] pub is_sandbox: bool,}impl SessionUser { /// Returns `Err(Forbidden)` if the user is a sandbox account. /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.). pub fn check_not_sandbox(&self) -> Result<(), AppError> { if self.is_sandbox { Err(AppError::Forbidden) } else { Ok(()) } } /// Returns `Err(Forbidden)` if the user is suspended or deactivated. /// Call at the top of write routes that suspended/deactivated users should not access. pub fn check_not_suspended(&self) -> Result<(), AppError> { .await .map_err(|e| AppError::Internal(anyhow::anyhow!("Session cycle failed: {}", e)))?; // Regenerate CSRF token so pre-auth tokens can't be used post-auth let new_csrf = crate::csrf::generate_token(); session .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf) .await .map_err(|e| AppError::Internal(anyhow::anyhow!("CSRF token insert failed: {}", e)))?; session .insert(USER_SESSION_KEY, user) .await is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, }; let config = Config { host: "127.0.0.1".parse().unwrap(), is_fan_plus: false, creator_tier: None, deactivated: false, is_sandbox: false, }; let config = Config { host: "127.0.0.1".parse().unwrap(),pub const BUILD_TRIGGER_RATE_LIMIT_BURST: u32 = 3;pub const BUILD_WRITE_RATE_LIMIT_MS: u64 = 500;pub const BUILD_WRITE_RATE_LIMIT_BURST: u32 = 10;// Git browsing: burst 30, then 5/sec (blame/log can be expensive)pub const GIT_BROWSE_RATE_LIMIT_MS: u64 = 200;pub const GIT_BROWSE_RATE_LIMIT_BURST: u32 = 30;pub const BUILD_ALLOWED_TARGETS: &[&str] = &[ "linux/x86_64", "linux/aarch64",pub const USER_AGENT_MAX_LENGTH: usize = 512;pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096;pub const MAX_PRICE_CENTS: i32 = 1_000_000; // $10,000// -- Sandbox accounts --/// How long a sandbox session lasts before auto-cleanup.pub const SANDBOX_EXPIRY_SECS: i64 = 3600; // 1 hour/// How often the cleanup job runs.pub const SANDBOX_CLEANUP_INTERVAL_SECS: u64 = 300; // 5 minutes/// Max file size for sandbox uploads (bytes).pub const SANDBOX_MAX_FILE_BYTES: i64 = 5 * 1024 * 1024; // 5 MB/// Max total storage for sandbox users (bytes).pub const SANDBOX_MAX_STORAGE_BYTES: i64 = 50 * 1024 * 1024; // 50 MB/// Rate limit: sandbox creation (1 per 30 seconds, burst 2).pub const SANDBOX_RATE_LIMIT_MS: u64 = 30_000;pub const SANDBOX_RATE_LIMIT_BURST: u32 = 2;/// Max concurrent active sandboxes per IP.pub const SANDBOX_MAX_PER_IP: i64 = 3;use crate::error::AppError;/// Session key for storing CSRF tokenconst CSRF_SESSION_KEY: &str = "csrf_token";pub const CSRF_SESSION_KEY: &str = "csrf_token";/// CSRF token length in bytes (32 bytes = 256 bits)const CSRF_TOKEN_LENGTH: usize = 32; // /metrics endpoint (Prometheus scrape target). Only available when the // recorder is installed (i.e. in the real server, not in integration tests). // Protected by Bearer token matching cli_service_token. if let Some(handle) = metrics_handle { let metrics_state = state.clone(); app = app.merge( Router::new() .route("/metrics", axum::routing::get(metrics::render)) .route("/metrics", axum::routing::get(move | axum::extract::State(prom_handle): axum::extract::State<metrics_exporter_prometheus::PrometheusHandle>, headers: axum::http::HeaderMap, | async move { use axum::response::IntoResponse; let token = headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")); match (token, metrics_state.config.cli_service_token.as_deref()) { (Some(t), Some(expected)) if crate::helpers::constant_time_compare(t, expected) => { prom_handle.render().into_response() } _ => axum::http::StatusCode::UNAUTHORIZED.into_response(), } })) .with_state(handle), ); } axum::http::header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), ); headers.insert( axum::http::header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self' https://cdn.makenot.work; frame-ancestors 'none'"), ); response} // Only cache successful responses (2xx/3xx) to avoid caching transient errors if status_code < 400 { // Extract the actual body bytes so we cache real content, not empty string let (parts, body) = response.into_parts(); let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await { Ok(b) => b, Err(e) => { tracing::warn!(error = ?e, "failed to read response body for idempotency cache"); return axum::response::Response::from_parts(parts, axum::body::Body::empty()); } }; let body_str = String::from_utf8_lossy(&body_bytes).to_string(); let db = state.db.clone(); let key = idem_key.clone(); let cache_body = body_str.clone(); tokio::spawn(async move { if let Err(e) = crate::db::idempotency::store_response( &db, &key, user_id, &method, &path, status_code, "", &db, &key, user_id, &method, &path, status_code, &cache_body, ).await { tracing::warn!(key = %key, error = ?e, "failed to store idempotency key"); } }); } response axum::response::Response::from_parts(parts, axum::body::Body::from(body_bytes)) } else { response }}/// Snapshot of current metrics for the admin dashboard. return Err(AppError::Unauthorized); } // Verify user is not suspended (JWT may outlive suspension) // Verify user is not suspended or deactivated (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() { if user.is_suspended() || user.is_deactivated() { return Err(AppError::Unauthorized); }}/// Add an item to a collection. Idempotent (ON CONFLICT DO NOTHING)./// Appends at max(position)+1./// Appends at max(position)+1 atomically via INSERT...SELECT.#[tracing::instrument(skip_all)]pub async fn add_item_to_collection( pool: &PgPool, collection_id: CollectionId, item_id: ItemId,) -> Result<()> { let max_pos: Option<i32> = sqlx::query_scalar( "SELECT MAX(position) FROM collection_items WHERE collection_id = $1", ) .bind(collection_id) .fetch_one(pool) .await?; let position = max_pos.unwrap_or(-1) + 1; sqlx::query( r#" INSERT INTO collection_items (collection_id, item_id, position) VALUES ($1, $2, $3) VALUES ($1, $2, COALESCE((SELECT MAX(position) FROM collection_items WHERE collection_id = $1), -1) + 1) ON CONFLICT (collection_id, item_id) DO NOTHING "#, ) .bind(collection_id) .bind(item_id) .bind(position) .execute(pool) .await?;}/// Reorder items in a collection by assigning position from the given ID sequence./// Wrapped in a transaction so a crash mid-reorder doesn't leave inconsistent state.#[tracing::instrument(skip_all)]pub async fn reorder_collection_items( pool: &PgPool, collection_id: CollectionId, item_ids: &[ItemId],) -> Result<()> { let mut tx = pool.begin().await?; for (index, item_id) in item_ids.iter().enumerate() { sqlx::query( "UPDATE collection_items SET position = $1 WHERE collection_id = $2 AND item_id = $3", .bind(index as i32) .bind(collection_id) .bind(item_id) .execute(pool) .execute(&mut *tx) .await?; } sqlx::query("UPDATE collections SET updated_at = NOW() WHERE id = $1") .bind(collection_id) .execute(pool) .execute(&mut *tx) .await?; tx.commit().await?; Ok(())} file_type: FileType, file_size_bytes: i64,) -> Result<i64> { // Covers and media images are always allowed (size checked separately) // Covers and media images bypass per-file tier checks but still respect // the storage cap. Look up the active tier (fallback to Basic cap). if file_type == FileType::Cover || file_type == FileType::MediaImage { return Ok(i64::MAX); let active_tier = get_active_creator_tier(pool, user_id).await?; let max_storage = active_tier .map(|t| t.max_storage_bytes()) .unwrap_or_else(|| CreatorTier::Basic.max_storage_bytes()); return Ok(max_storage); } // Resolve effective tieruse crate::error::{AppError, Result};/// Create a custom domain entry with a verification token./// Enforces a 1-domain-per-user limit./// Enforces a 1-domain-per-user limit using a transaction to prevent TOCTOU races.#[tracing::instrument(skip_all)]pub async fn create_custom_domain( pool: &PgPool, domain: &str, verification_token: &str,) -> Result<DbCustomDomain> { // Check 1-domain-per-user limit let mut tx = pool.begin().await?; // Lock the user row to serialize concurrent domain creation attempts let existing = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM custom_domains WHERE user_id = $1", "SELECT COUNT(*) FROM custom_domains WHERE user_id = $1 FOR UPDATE", ) .bind(user_id) .fetch_one(pool) .fetch_one(&mut *tx) .await?; if existing > 0 { .bind(user_id) .bind(domain) .bind(verification_token) .fetch_one(pool) .fetch_one(&mut *tx) .await?; tx.commit().await?; Ok(row)}use crate::error::Result;/// Create a custom link for a user, appended to the end of their link list./// Uses a single INSERT...SELECT to atomically compute the next sort_order.#[tracing::instrument(skip_all)]pub async fn create_custom_link( pool: &PgPool, title: &str, description: Option<&str>,) -> Result<DbCustomLink> { // Get max sort_order for user let max_order: Option<i32> = sqlx::query_scalar("SELECT MAX(sort_order) FROM custom_links WHERE user_id = $1") .bind(user_id) .fetch_one(pool) .await?; let sort_order = max_order.unwrap_or(0) + 1; let link = sqlx::query_as::<_, DbCustomLink>( r#" INSERT INTO custom_links (user_id, url, title, description, sort_order) VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, COALESCE((SELECT MAX(sort_order) FROM custom_links WHERE user_id = $1), 0) + 1) RETURNING * "#, ) .bind(url) .bind(title) .bind(description) .bind(sort_order) .fetch_one(pool) .await?;}/// Reorder a user's custom links by assigning sort_order from the given ID sequence./// Wrapped in a transaction so a crash mid-reorder doesn't leave inconsistent state.#[tracing::instrument(skip_all)]pub async fn reorder_custom_links(pool: &PgPool, user_id: UserId, link_ids: &[CustomLinkId]) -> Result<()> { let mut tx = pool.begin().await?; for (index, link_id) in link_ids.iter().enumerate() { sqlx::query("UPDATE custom_links SET sort_order = $1 WHERE id = $2 AND user_id = $3") .bind(index as i32) .bind(link_id) .bind(user_id) .execute(pool) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(())} .strip_prefix("Bearer ") .ok_or(AppError::Unauthorized)?; if token != expected_token { if !crate::helpers::constant_time_compare(token, expected_token) { return Err(AppError::Unauthorized); } AppState,};/// Anti-timing dummy hash: ensures the user-not-found path takes the same time/// as the wrong-password path (prevents user enumeration via response timing).static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| { crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")});// ── Request/Response types ──#[derive(Deserialize)] db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false } } }; if result.valid { if result.valid && !result.suspended { state.session_cache.insert(tracking_id, std::time::Instant::now()); // Reject suspended users !result.suspended true } else { state.session_cache.remove(&tracking_id); let _ = session.flush().await; } } else { // Legacy session without tracking ID — allow through !user.suspended !user.suspended && !user.deactivated }; if still_valid { Some(user) } else { None } let user = match user { Some(u) => u, None => { // Perform a dummy hash verification to prevent timing-based user enumeration let _ = verify_password("dummy", &DUMMY_HASH); return Ok(render_authorize_error( Some(csrf_token), session_user, )); } // Cap password length to prevent DoS via Argon2 on very long inputs if password.len() > 128 { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "Invalid username/email or password", )); } // Verify password if !verify_password(password, &user.password_hash)? { let result = db::auth::increment_failed_login( // Successful auth — reset failed attempts db::auth::reset_failed_login(&state.db, user.id).await?; // Block suspended or deactivated users if user.is_suspended() || user.is_deactivated() { return Ok(render_authorize_error( Some(csrf_token), session_user, &app.name, &form, "This account is not active.", )); } // If user has TOTP 2FA enabled, reject — they must log in via the main site first if user.totp_enabled { return Ok(render_authorize_error( Router,};use git2::Repository;use tower_governor::GovernorLayer;use crate::{ constants,/// Register all git routes.pub fn git_routes() -> Router<AppState> { let browse_rate_limit = crate::helpers::rate_limiter_ms( constants::GIT_BROWSE_RATE_LIMIT_MS, constants::GIT_BROWSE_RATE_LIMIT_BURST, ); Router::new() // Browsing .route("/git/{owner}/{repo}", get(browsing::repo_overview)) post(raw::smart_http_upload_pack) .layer(DefaultBodyLimit::max(constants::GIT_UPLOAD_PACK_MAX_BYTES)), ) .route_layer(GovernorLayer { config: browse_rate_limit })}// ============================================================================ return Err(err); } // Atomically increment storage BEFORE writing the DB record if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } // Build permanent URL let image_url = storage::build_project_image_url( s3.as_ref(), // Store URL in database db::projects::update_project_image_url(&state.db, req.project_id, user.id, &image_url).await?; // Atomically increment storage db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; // Bump cache db::projects::bump_cache_generation(&state.db, req.project_id).await?; } }; // Idempotency: if cover_s3_key already matches, return success (no-op) if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? { if item.cover_s3_key.as_deref() == Some(&req.s3_key) { return Ok(Json(super::images::ProjectImageConfirmResponse { success: true, image_url: item.cover_image_url.unwrap_or_default(), })); } // If replacing a different cover, decrement old storage if item.cover_s3_key.is_some() { if let Some(old_size) = item.cover_file_size_bytes { if old_size > 0 { db::creator_tiers::decrement_storage_used(&state.db, user.id, old_size).await?; } } } } // Scan + classify let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id, file_size_bytes).await?; db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?; return Err(err); } // Atomically increment storage BEFORE writing the DB record if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { s3.delete_object(&req.s3_key).await.ok(); return Err(e); } // Build permanent URL (CDN or presigned) let image_url = storage::build_project_image_url( s3.as_ref(), db::items::update_item_cover_s3_key(&state.db, req.item_id, &req.s3_key).await?; db::items::update_item_cover_file_size(&state.db, req.item_id, file_size_bytes).await?; // Atomically increment storage db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; // Bump project cache if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await