max / makenotwork
17 files changed,
+236 insertions,
-55 deletions
| @@ -59,9 +59,21 @@ pub struct SessionUser { | |||
| 59 | 59 | pub creator_tier: Option<String>, | |
| 60 | 60 | #[serde(default)] | |
| 61 | 61 | pub deactivated: bool, | |
| 62 | + | #[serde(default)] | |
| 63 | + | pub is_sandbox: bool, | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 64 | 66 | impl SessionUser { | |
| 67 | + | /// Returns `Err(Forbidden)` if the user is a sandbox account. | |
| 68 | + | /// Call at the top of routes that sandbox users must not access (Stripe, email, etc.). | |
| 69 | + | pub fn check_not_sandbox(&self) -> Result<(), AppError> { | |
| 70 | + | if self.is_sandbox { | |
| 71 | + | Err(AppError::Forbidden) | |
| 72 | + | } else { | |
| 73 | + | Ok(()) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 65 | 77 | /// Returns `Err(Forbidden)` if the user is suspended or deactivated. | |
| 66 | 78 | /// Call at the top of write routes that suspended/deactivated users should not access. | |
| 67 | 79 | pub fn check_not_suspended(&self) -> Result<(), AppError> { | |
| @@ -270,6 +282,13 @@ pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppE | |||
| 270 | 282 | .await | |
| 271 | 283 | .map_err(|e| AppError::Internal(anyhow::anyhow!("Session cycle failed: {}", e)))?; | |
| 272 | 284 | ||
| 285 | + | // Regenerate CSRF token so pre-auth tokens can't be used post-auth | |
| 286 | + | let new_csrf = crate::csrf::generate_token(); | |
| 287 | + | session | |
| 288 | + | .insert(crate::csrf::CSRF_SESSION_KEY, &new_csrf) | |
| 289 | + | .await | |
| 290 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("CSRF token insert failed: {}", e)))?; | |
| 291 | + | ||
| 273 | 292 | session | |
| 274 | 293 | .insert(USER_SESSION_KEY, user) | |
| 275 | 294 | .await | |
| @@ -403,6 +422,7 @@ mod tests { | |||
| 403 | 422 | is_fan_plus: false, | |
| 404 | 423 | creator_tier: None, | |
| 405 | 424 | deactivated: false, | |
| 425 | + | is_sandbox: false, | |
| 406 | 426 | }; | |
| 407 | 427 | let config = Config { | |
| 408 | 428 | host: "127.0.0.1".parse().unwrap(), | |
| @@ -465,6 +485,7 @@ mod tests { | |||
| 465 | 485 | is_fan_plus: false, | |
| 466 | 486 | creator_tier: None, | |
| 467 | 487 | deactivated: false, | |
| 488 | + | is_sandbox: false, | |
| 468 | 489 | }; | |
| 469 | 490 | let config = Config { | |
| 470 | 491 | host: "127.0.0.1".parse().unwrap(), |
| @@ -160,6 +160,9 @@ pub const BUILD_TRIGGER_RATE_LIMIT_PER_SEC: u64 = 1; | |||
| 160 | 160 | pub const BUILD_TRIGGER_RATE_LIMIT_BURST: u32 = 3; | |
| 161 | 161 | pub const BUILD_WRITE_RATE_LIMIT_MS: u64 = 500; | |
| 162 | 162 | pub const BUILD_WRITE_RATE_LIMIT_BURST: u32 = 10; | |
| 163 | + | // Git browsing: burst 30, then 5/sec (blame/log can be expensive) | |
| 164 | + | pub const GIT_BROWSE_RATE_LIMIT_MS: u64 = 200; | |
| 165 | + | pub const GIT_BROWSE_RATE_LIMIT_BURST: u32 = 30; | |
| 163 | 166 | pub const BUILD_ALLOWED_TARGETS: &[&str] = &[ | |
| 164 | 167 | "linux/x86_64", | |
| 165 | 168 | "linux/aarch64", | |
| @@ -184,3 +187,18 @@ pub const CHANGELOG_PROJECT_SLUG: &str = "changelog"; | |||
| 184 | 187 | pub const USER_AGENT_MAX_LENGTH: usize = 512; | |
| 185 | 188 | pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096; | |
| 186 | 189 | pub const MAX_PRICE_CENTS: i32 = 1_000_000; // $10,000 | |
| 190 | + | ||
| 191 | + | // -- Sandbox accounts -- | |
| 192 | + | /// How long a sandbox session lasts before auto-cleanup. | |
| 193 | + | pub const SANDBOX_EXPIRY_SECS: i64 = 3600; // 1 hour | |
| 194 | + | /// How often the cleanup job runs. | |
| 195 | + | pub const SANDBOX_CLEANUP_INTERVAL_SECS: u64 = 300; // 5 minutes | |
| 196 | + | /// Max file size for sandbox uploads (bytes). | |
| 197 | + | pub const SANDBOX_MAX_FILE_BYTES: i64 = 5 * 1024 * 1024; // 5 MB | |
| 198 | + | /// Max total storage for sandbox users (bytes). | |
| 199 | + | pub const SANDBOX_MAX_STORAGE_BYTES: i64 = 50 * 1024 * 1024; // 50 MB | |
| 200 | + | /// Rate limit: sandbox creation (1 per 30 seconds, burst 2). | |
| 201 | + | pub const SANDBOX_RATE_LIMIT_MS: u64 = 30_000; | |
| 202 | + | pub const SANDBOX_RATE_LIMIT_BURST: u32 = 2; | |
| 203 | + | /// Max concurrent active sandboxes per IP. | |
| 204 | + | pub const SANDBOX_MAX_PER_IP: i64 = 3; |
| @@ -17,7 +17,7 @@ use tower_sessions::Session; | |||
| 17 | 17 | use crate::error::AppError; | |
| 18 | 18 | ||
| 19 | 19 | /// Session key for storing CSRF token | |
| 20 | - | const CSRF_SESSION_KEY: &str = "csrf_token"; | |
| 20 | + | pub const CSRF_SESSION_KEY: &str = "csrf_token"; | |
| 21 | 21 | ||
| 22 | 22 | /// CSRF token length in bytes (32 bytes = 256 bits) | |
| 23 | 23 | const CSRF_TOKEN_LENGTH: usize = 32; |
| @@ -167,32 +167,22 @@ pub async fn count_collections_by_user(pool: &PgPool, user_id: UserId) -> Result | |||
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | 169 | /// Add an item to a collection. Idempotent (ON CONFLICT DO NOTHING). | |
| 170 | - | /// Appends at max(position)+1. | |
| 170 | + | /// Appends at max(position)+1 atomically via INSERT...SELECT. | |
| 171 | 171 | #[tracing::instrument(skip_all)] | |
| 172 | 172 | pub async fn add_item_to_collection( | |
| 173 | 173 | pool: &PgPool, | |
| 174 | 174 | collection_id: CollectionId, | |
| 175 | 175 | item_id: ItemId, | |
| 176 | 176 | ) -> Result<()> { | |
| 177 | - | let max_pos: Option<i32> = sqlx::query_scalar( | |
| 178 | - | "SELECT MAX(position) FROM collection_items WHERE collection_id = $1", | |
| 179 | - | ) | |
| 180 | - | .bind(collection_id) | |
| 181 | - | .fetch_one(pool) | |
| 182 | - | .await?; | |
| 183 | - | ||
| 184 | - | let position = max_pos.unwrap_or(-1) + 1; | |
| 185 | - | ||
| 186 | 177 | sqlx::query( | |
| 187 | 178 | r#" | |
| 188 | 179 | INSERT INTO collection_items (collection_id, item_id, position) | |
| 189 | - | VALUES ($1, $2, $3) | |
| 180 | + | VALUES ($1, $2, COALESCE((SELECT MAX(position) FROM collection_items WHERE collection_id = $1), -1) + 1) | |
| 190 | 181 | ON CONFLICT (collection_id, item_id) DO NOTHING | |
| 191 | 182 | "#, | |
| 192 | 183 | ) | |
| 193 | 184 | .bind(collection_id) | |
| 194 | 185 | .bind(item_id) | |
| 195 | - | .bind(position) | |
| 196 | 186 | .execute(pool) | |
| 197 | 187 | .await?; | |
| 198 | 188 | ||
| @@ -280,12 +270,14 @@ pub async fn count_collection_items( | |||
| 280 | 270 | } | |
| 281 | 271 | ||
| 282 | 272 | /// Reorder items in a collection by assigning position from the given ID sequence. | |
| 273 | + | /// Wrapped in a transaction so a crash mid-reorder doesn't leave inconsistent state. | |
| 283 | 274 | #[tracing::instrument(skip_all)] | |
| 284 | 275 | pub async fn reorder_collection_items( | |
| 285 | 276 | pool: &PgPool, | |
| 286 | 277 | collection_id: CollectionId, | |
| 287 | 278 | item_ids: &[ItemId], | |
| 288 | 279 | ) -> Result<()> { | |
| 280 | + | let mut tx = pool.begin().await?; | |
| 289 | 281 | for (index, item_id) in item_ids.iter().enumerate() { | |
| 290 | 282 | sqlx::query( | |
| 291 | 283 | "UPDATE collection_items SET position = $1 WHERE collection_id = $2 AND item_id = $3", | |
| @@ -293,14 +285,15 @@ pub async fn reorder_collection_items( | |||
| 293 | 285 | .bind(index as i32) | |
| 294 | 286 | .bind(collection_id) | |
| 295 | 287 | .bind(item_id) | |
| 296 | - | .execute(pool) | |
| 288 | + | .execute(&mut *tx) | |
| 297 | 289 | .await?; | |
| 298 | 290 | } | |
| 299 | 291 | ||
| 300 | 292 | sqlx::query("UPDATE collections SET updated_at = NOW() WHERE id = $1") | |
| 301 | 293 | .bind(collection_id) | |
| 302 | - | .execute(pool) | |
| 294 | + | .execute(&mut *tx) | |
| 303 | 295 | .await?; | |
| 296 | + | tx.commit().await?; | |
| 304 | 297 | ||
| 305 | 298 | Ok(()) | |
| 306 | 299 | } |
| @@ -513,9 +513,14 @@ pub async fn check_upload_allowed( | |||
| 513 | 513 | file_type: FileType, | |
| 514 | 514 | file_size_bytes: i64, | |
| 515 | 515 | ) -> Result<i64> { | |
| 516 | - | // Covers and media images are always allowed (size checked separately) | |
| 516 | + | // Covers and media images bypass per-file tier checks but still respect | |
| 517 | + | // the storage cap. Look up the active tier (fallback to Basic cap). | |
| 517 | 518 | if file_type == FileType::Cover || file_type == FileType::MediaImage { | |
| 518 | - | return Ok(i64::MAX); | |
| 519 | + | let active_tier = get_active_creator_tier(pool, user_id).await?; | |
| 520 | + | let max_storage = active_tier | |
| 521 | + | .map(|t| t.max_storage_bytes()) | |
| 522 | + | .unwrap_or_else(|| CreatorTier::Basic.max_storage_bytes()); | |
| 523 | + | return Ok(max_storage); | |
| 519 | 524 | } | |
| 520 | 525 | ||
| 521 | 526 | // Resolve effective tier |
| @@ -7,7 +7,7 @@ use super::{CustomDomainId, UserId}; | |||
| 7 | 7 | use crate::error::{AppError, Result}; | |
| 8 | 8 | ||
| 9 | 9 | /// Create a custom domain entry with a verification token. | |
| 10 | - | /// Enforces a 1-domain-per-user limit. | |
| 10 | + | /// Enforces a 1-domain-per-user limit using a transaction to prevent TOCTOU races. | |
| 11 | 11 | #[tracing::instrument(skip_all)] | |
| 12 | 12 | pub async fn create_custom_domain( | |
| 13 | 13 | pool: &PgPool, | |
| @@ -15,12 +15,14 @@ pub async fn create_custom_domain( | |||
| 15 | 15 | domain: &str, | |
| 16 | 16 | verification_token: &str, | |
| 17 | 17 | ) -> Result<DbCustomDomain> { | |
| 18 | - | // Check 1-domain-per-user limit | |
| 18 | + | let mut tx = pool.begin().await?; | |
| 19 | + | ||
| 20 | + | // Lock the user row to serialize concurrent domain creation attempts | |
| 19 | 21 | let existing = sqlx::query_scalar::<_, i64>( | |
| 20 | - | "SELECT COUNT(*) FROM custom_domains WHERE user_id = $1", | |
| 22 | + | "SELECT COUNT(*) FROM custom_domains WHERE user_id = $1 FOR UPDATE", | |
| 21 | 23 | ) | |
| 22 | 24 | .bind(user_id) | |
| 23 | - | .fetch_one(pool) | |
| 25 | + | .fetch_one(&mut *tx) | |
| 24 | 26 | .await?; | |
| 25 | 27 | ||
| 26 | 28 | if existing > 0 { | |
| @@ -39,9 +41,10 @@ pub async fn create_custom_domain( | |||
| 39 | 41 | .bind(user_id) | |
| 40 | 42 | .bind(domain) | |
| 41 | 43 | .bind(verification_token) | |
| 42 | - | .fetch_one(pool) | |
| 44 | + | .fetch_one(&mut *tx) | |
| 43 | 45 | .await?; | |
| 44 | 46 | ||
| 47 | + | tx.commit().await?; | |
| 45 | 48 | Ok(row) | |
| 46 | 49 | } | |
| 47 | 50 |
| @@ -7,6 +7,7 @@ use super::{CustomLinkId, UserId}; | |||
| 7 | 7 | use crate::error::Result; | |
| 8 | 8 | ||
| 9 | 9 | /// Create a custom link for a user, appended to the end of their link list. | |
| 10 | + | /// Uses a single INSERT...SELECT to atomically compute the next sort_order. | |
| 10 | 11 | #[tracing::instrument(skip_all)] | |
| 11 | 12 | pub async fn create_custom_link( | |
| 12 | 13 | pool: &PgPool, | |
| @@ -15,19 +16,10 @@ pub async fn create_custom_link( | |||
| 15 | 16 | title: &str, | |
| 16 | 17 | description: Option<&str>, | |
| 17 | 18 | ) -> Result<DbCustomLink> { | |
| 18 | - | // Get max sort_order for user | |
| 19 | - | let max_order: Option<i32> = | |
| 20 | - | sqlx::query_scalar("SELECT MAX(sort_order) FROM custom_links WHERE user_id = $1") | |
| 21 | - | .bind(user_id) | |
| 22 | - | .fetch_one(pool) | |
| 23 | - | .await?; | |
| 24 | - | ||
| 25 | - | let sort_order = max_order.unwrap_or(0) + 1; | |
| 26 | - | ||
| 27 | 19 | let link = sqlx::query_as::<_, DbCustomLink>( | |
| 28 | 20 | r#" | |
| 29 | 21 | INSERT INTO custom_links (user_id, url, title, description, sort_order) | |
| 30 | - | VALUES ($1, $2, $3, $4, $5) | |
| 22 | + | VALUES ($1, $2, $3, $4, COALESCE((SELECT MAX(sort_order) FROM custom_links WHERE user_id = $1), 0) + 1) | |
| 31 | 23 | RETURNING * | |
| 32 | 24 | "#, | |
| 33 | 25 | ) | |
| @@ -35,7 +27,6 @@ pub async fn create_custom_link( | |||
| 35 | 27 | .bind(url) | |
| 36 | 28 | .bind(title) | |
| 37 | 29 | .bind(description) | |
| 38 | - | .bind(sort_order) | |
| 39 | 30 | .fetch_one(pool) | |
| 40 | 31 | .await?; | |
| 41 | 32 | ||
| @@ -101,16 +92,19 @@ pub async fn delete_custom_link(pool: &PgPool, id: CustomLinkId, user_id: UserId | |||
| 101 | 92 | } | |
| 102 | 93 | ||
| 103 | 94 | /// Reorder a user's custom links by assigning sort_order from the given ID sequence. | |
| 95 | + | /// Wrapped in a transaction so a crash mid-reorder doesn't leave inconsistent state. | |
| 104 | 96 | #[tracing::instrument(skip_all)] | |
| 105 | 97 | pub async fn reorder_custom_links(pool: &PgPool, user_id: UserId, link_ids: &[CustomLinkId]) -> Result<()> { | |
| 98 | + | let mut tx = pool.begin().await?; | |
| 106 | 99 | for (index, link_id) in link_ids.iter().enumerate() { | |
| 107 | 100 | sqlx::query("UPDATE custom_links SET sort_order = $1 WHERE id = $2 AND user_id = $3") | |
| 108 | 101 | .bind(index as i32) | |
| 109 | 102 | .bind(link_id) | |
| 110 | 103 | .bind(user_id) | |
| 111 | - | .execute(pool) | |
| 104 | + | .execute(&mut *tx) | |
| 112 | 105 | .await?; | |
| 113 | 106 | } | |
| 107 | + | tx.commit().await?; | |
| 114 | 108 | ||
| 115 | 109 | Ok(()) | |
| 116 | 110 | } |
| @@ -156,10 +156,27 @@ pub fn build_app( | |||
| 156 | 156 | ||
| 157 | 157 | // /metrics endpoint (Prometheus scrape target). Only available when the | |
| 158 | 158 | // recorder is installed (i.e. in the real server, not in integration tests). | |
| 159 | + | // Protected by Bearer token matching cli_service_token. | |
| 159 | 160 | if let Some(handle) = metrics_handle { | |
| 161 | + | let metrics_state = state.clone(); | |
| 160 | 162 | app = app.merge( | |
| 161 | 163 | Router::new() | |
| 162 | - | .route("/metrics", axum::routing::get(metrics::render)) | |
| 164 | + | .route("/metrics", axum::routing::get(move | | |
| 165 | + | axum::extract::State(prom_handle): axum::extract::State<metrics_exporter_prometheus::PrometheusHandle>, | |
| 166 | + | headers: axum::http::HeaderMap, | |
| 167 | + | | async move { | |
| 168 | + | use axum::response::IntoResponse; | |
| 169 | + | let token = headers | |
| 170 | + | .get("authorization") | |
| 171 | + | .and_then(|v| v.to_str().ok()) | |
| 172 | + | .and_then(|v| v.strip_prefix("Bearer ")); | |
| 173 | + | match (token, metrics_state.config.cli_service_token.as_deref()) { | |
| 174 | + | (Some(t), Some(expected)) if crate::helpers::constant_time_compare(t, expected) => { | |
| 175 | + | prom_handle.render().into_response() | |
| 176 | + | } | |
| 177 | + | _ => axum::http::StatusCode::UNAUTHORIZED.into_response(), | |
| 178 | + | } | |
| 179 | + | })) | |
| 163 | 180 | .with_state(handle), | |
| 164 | 181 | ); | |
| 165 | 182 | } | |
| @@ -196,5 +213,9 @@ async fn security_headers_middleware( | |||
| 196 | 213 | axum::http::header::HeaderName::from_static("permissions-policy"), | |
| 197 | 214 | HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), | |
| 198 | 215 | ); | |
| 216 | + | headers.insert( | |
| 217 | + | axum::http::header::HeaderName::from_static("content-security-policy"), | |
| 218 | + | 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'"), | |
| 219 | + | ); | |
| 199 | 220 | response | |
| 200 | 221 | } |
| @@ -201,18 +201,32 @@ pub async fn idempotency_middleware( | |||
| 201 | 201 | ||
| 202 | 202 | // Only cache successful responses (2xx/3xx) to avoid caching transient errors | |
| 203 | 203 | if status_code < 400 { | |
| 204 | + | // Extract the actual body bytes so we cache real content, not empty string | |
| 205 | + | let (parts, body) = response.into_parts(); | |
| 206 | + | let body_bytes = match axum::body::to_bytes(body, 1024 * 1024).await { | |
| 207 | + | Ok(b) => b, | |
| 208 | + | Err(e) => { | |
| 209 | + | tracing::warn!(error = ?e, "failed to read response body for idempotency cache"); | |
| 210 | + | return axum::response::Response::from_parts(parts, axum::body::Body::empty()); | |
| 211 | + | } | |
| 212 | + | }; | |
| 213 | + | let body_str = String::from_utf8_lossy(&body_bytes).to_string(); | |
| 214 | + | ||
| 204 | 215 | let db = state.db.clone(); | |
| 205 | 216 | let key = idem_key.clone(); | |
| 217 | + | let cache_body = body_str.clone(); | |
| 206 | 218 | tokio::spawn(async move { | |
| 207 | 219 | if let Err(e) = crate::db::idempotency::store_response( | |
| 208 | - | &db, &key, user_id, &method, &path, status_code, "", | |
| 220 | + | &db, &key, user_id, &method, &path, status_code, &cache_body, | |
| 209 | 221 | ).await { | |
| 210 | 222 | tracing::warn!(key = %key, error = ?e, "failed to store idempotency key"); | |
| 211 | 223 | } | |
| 212 | 224 | }); | |
| 213 | - | } | |
| 214 | 225 | ||
| 215 | - | response | |
| 226 | + | axum::response::Response::from_parts(parts, axum::body::Body::from(body_bytes)) | |
| 227 | + | } else { | |
| 228 | + | response | |
| 229 | + | } | |
| 216 | 230 | } | |
| 217 | 231 | ||
| 218 | 232 | /// Snapshot of current metrics for the admin dashboard. |
| @@ -426,7 +426,7 @@ async fn hook_trigger( | |||
| 426 | 426 | .strip_prefix("Bearer ") | |
| 427 | 427 | .ok_or(AppError::Unauthorized)?; | |
| 428 | 428 | ||
| 429 | - | if token != expected_token { | |
| 429 | + | if !crate::helpers::constant_time_compare(token, expected_token) { | |
| 430 | 430 | return Err(AppError::Unauthorized); | |
| 431 | 431 | } | |
| 432 | 432 |
| @@ -9,6 +9,7 @@ use axum::{ | |||
| 9 | 9 | Router, | |
| 10 | 10 | }; | |
| 11 | 11 | use git2::Repository; | |
| 12 | + | use tower_governor::GovernorLayer; | |
| 12 | 13 | ||
| 13 | 14 | use crate::{ | |
| 14 | 15 | constants, | |
| @@ -22,6 +23,11 @@ use crate::{ | |||
| 22 | 23 | ||
| 23 | 24 | /// Register all git routes. | |
| 24 | 25 | pub fn git_routes() -> Router<AppState> { | |
| 26 | + | let browse_rate_limit = crate::helpers::rate_limiter_ms( | |
| 27 | + | constants::GIT_BROWSE_RATE_LIMIT_MS, | |
| 28 | + | constants::GIT_BROWSE_RATE_LIMIT_BURST, | |
| 29 | + | ); | |
| 30 | + | ||
| 25 | 31 | Router::new() | |
| 26 | 32 | // Browsing | |
| 27 | 33 | .route("/git/{owner}/{repo}", get(browsing::repo_overview)) | |
| @@ -44,6 +50,7 @@ pub fn git_routes() -> Router<AppState> { | |||
| 44 | 50 | post(raw::smart_http_upload_pack) | |
| 45 | 51 | .layer(DefaultBodyLimit::max(constants::GIT_UPLOAD_PACK_MAX_BYTES)), | |
| 46 | 52 | ) | |
| 53 | + | .route_layer(GovernorLayer { config: browse_rate_limit }) | |
| 47 | 54 | } | |
| 48 | 55 | ||
| 49 | 56 | // ============================================================================ |
| @@ -28,6 +28,12 @@ use crate::{ | |||
| 28 | 28 | AppState, | |
| 29 | 29 | }; | |
| 30 | 30 | ||
| 31 | + | /// Anti-timing dummy hash: ensures the user-not-found path takes the same time | |
| 32 | + | /// as the wrong-password path (prevents user enumeration via response timing). | |
| 33 | + | static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| { | |
| 34 | + | crate::auth::hash_password("anti-timing-dummy").expect("dummy hash") | |
| 35 | + | }); | |
| 36 | + | ||
| 31 | 37 | // ── Request/Response types ── | |
| 32 | 38 | ||
| 33 | 39 | #[derive(Deserialize)] | |
| @@ -235,10 +241,9 @@ async fn authorize_post( | |||
| 235 | 241 | db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false } | |
| 236 | 242 | } | |
| 237 | 243 | }; | |
| 238 | - | if result.valid { | |
| 244 | + | if result.valid && !result.suspended { | |
| 239 | 245 | state.session_cache.insert(tracking_id, std::time::Instant::now()); | |
| 240 | - | // Reject suspended users | |
| 241 | - | !result.suspended | |
| 246 | + | true | |
| 242 | 247 | } else { | |
| 243 | 248 | state.session_cache.remove(&tracking_id); | |
| 244 | 249 | let _ = session.flush().await; | |
| @@ -247,7 +252,7 @@ async fn authorize_post( | |||
| 247 | 252 | } | |
| 248 | 253 | } else { | |
| 249 | 254 | // Legacy session without tracking ID — allow through | |
| 250 | - | !user.suspended | |
| 255 | + | !user.suspended && !user.deactivated | |
| 251 | 256 | }; | |
| 252 | 257 | ||
| 253 | 258 | if still_valid { Some(user) } else { None } | |
| @@ -284,6 +289,8 @@ async fn authorize_post( | |||
| 284 | 289 | let user = match user { | |
| 285 | 290 | Some(u) => u, | |
| 286 | 291 | None => { | |
| 292 | + | // Perform a dummy hash verification to prevent timing-based user enumeration | |
| 293 | + | let _ = verify_password("dummy", &DUMMY_HASH); | |
| 287 | 294 | return Ok(render_authorize_error( | |
| 288 | 295 | Some(csrf_token), | |
| 289 | 296 | session_user, | |
| @@ -308,6 +315,17 @@ async fn authorize_post( | |||
| 308 | 315 | )); | |
| 309 | 316 | } | |
| 310 | 317 | ||
| 318 | + | // Cap password length to prevent DoS via Argon2 on very long inputs | |
| 319 | + | if password.len() > 128 { | |
| 320 | + | return Ok(render_authorize_error( | |
| 321 | + | Some(csrf_token), | |
| 322 | + | session_user, | |
| 323 | + | &app.name, | |
| 324 | + | &form, | |
| 325 | + | "Invalid username/email or password", | |
| 326 | + | )); | |
| 327 | + | } | |
| 328 | + | ||
| 311 | 329 | // Verify password | |
| 312 | 330 | if !verify_password(password, &user.password_hash)? { | |
| 313 | 331 | let result = db::auth::increment_failed_login( | |
| @@ -339,6 +357,17 @@ async fn authorize_post( | |||
| 339 | 357 | // Successful auth — reset failed attempts | |
| 340 | 358 | db::auth::reset_failed_login(&state.db, user.id).await?; | |
| 341 | 359 | ||
| 360 | + | // Block suspended or deactivated users | |
| 361 | + | if user.is_suspended() || user.is_deactivated() { | |
| 362 | + | return Ok(render_authorize_error( | |
| 363 | + | Some(csrf_token), | |
| 364 | + | session_user, | |
| 365 | + | &app.name, | |
| 366 | + | &form, | |
| 367 | + | "This account is not active.", | |
| 368 | + | )); | |
| 369 | + | } | |
| 370 | + | ||
| 342 | 371 | // If user has TOTP 2FA enabled, reject — they must log in via the main site first | |
| 343 | 372 | if user.totp_enabled { | |
| 344 | 373 | return Ok(render_authorize_error( |
| @@ -166,6 +166,12 @@ pub(super) async fn project_image_confirm( | |||
| 166 | 166 | return Err(err); | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | + | // Atomically increment storage BEFORE writing the DB record | |
| 170 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 171 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 172 | + | return Err(e); | |
| 173 | + | } | |
| 174 | + | ||
| 169 | 175 | // Build permanent URL | |
| 170 | 176 | let image_url = storage::build_project_image_url( | |
| 171 | 177 | s3.as_ref(), | |
| @@ -176,9 +182,6 @@ pub(super) async fn project_image_confirm( | |||
| 176 | 182 | // Store URL in database | |
| 177 | 183 | db::projects::update_project_image_url(&state.db, req.project_id, user.id, &image_url).await?; | |
| 178 | 184 | ||
| 179 | - | // Atomically increment storage | |
| 180 | - | db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; | |
| 181 | - | ||
| 182 | 185 | // Bump cache | |
| 183 | 186 | db::projects::bump_cache_generation(&state.db, req.project_id).await?; | |
| 184 | 187 | ||
| @@ -298,6 +301,24 @@ pub(super) async fn item_image_confirm( | |||
| 298 | 301 | } | |
| 299 | 302 | }; | |
| 300 | 303 | ||
| 304 | + | // Idempotency: if cover_s3_key already matches, return success (no-op) | |
| 305 | + | if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? { | |
| 306 | + | if item.cover_s3_key.as_deref() == Some(&req.s3_key) { | |
| 307 | + | return Ok(Json(super::images::ProjectImageConfirmResponse { | |
| 308 | + | success: true, | |
| 309 | + | image_url: item.cover_image_url.unwrap_or_default(), | |
| 310 | + | })); | |
| 311 | + | } | |
| 312 | + | // If replacing a different cover, decrement old storage | |
| 313 | + | if item.cover_s3_key.is_some() { | |
| 314 | + | if let Some(old_size) = item.cover_file_size_bytes { | |
| 315 | + | if old_size > 0 { | |
| 316 | + | db::creator_tiers::decrement_storage_used(&state.db, user.id, old_size).await?; | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 301 | 322 | // Scan + classify | |
| 302 | 323 | let (status, malware_err) = scan_and_classify(&state, s3.as_ref(), &req.s3_key, FileType::Cover, user.id, file_size_bytes).await?; | |
| 303 | 324 | db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?; | |
| @@ -305,6 +326,12 @@ pub(super) async fn item_image_confirm( | |||
| 305 | 326 | return Err(err); | |
| 306 | 327 | } | |
| 307 | 328 | ||
| 329 | + | // Atomically increment storage BEFORE writing the DB record | |
| 330 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 331 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 332 | + | return Err(e); | |
| 333 | + | } | |
| 334 | + | ||
| 308 | 335 | // Build permanent URL (CDN or presigned) | |
| 309 | 336 | let image_url = storage::build_project_image_url( | |
| 310 | 337 | s3.as_ref(), | |
| @@ -317,9 +344,6 @@ pub(super) async fn item_image_confirm( | |||
| 317 | 344 | db::items::update_item_cover_s3_key(&state.db, req.item_id, &req.s3_key).await?; | |
| 318 | 345 | db::items::update_item_cover_file_size(&state.db, req.item_id, file_size_bytes).await?; | |
| 319 | 346 | ||
| 320 | - | // Atomically increment storage | |
| 321 | - | db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; | |
| 322 | - | ||
| 323 | 347 | // Bump project cache | |
| 324 | 348 | if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? | |
| 325 | 349 | && let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await |
| @@ -191,6 +191,10 @@ pub(super) async fn media_confirm( | |||
| 191 | 191 | ||
| 192 | 192 | let (media_type, file_type) = classify_media(&req.content_type)?; | |
| 193 | 193 | ||
| 194 | + | // Re-validate content type and extension at confirm time (may differ from presign) | |
| 195 | + | S3Client::validate_content_type(file_type, &req.content_type)?; | |
| 196 | + | S3Client::validate_extension(file_type, &req.file_name)?; | |
| 197 | + | ||
| 194 | 198 | // Validate S3 key belongs to this user (prevent cross-user file reference) | |
| 195 | 199 | let expected_prefix = format!("{}/media/", user.id); | |
| 196 | 200 | if !req.s3_key.starts_with(&expected_prefix) { | |
| @@ -240,8 +244,10 @@ pub(super) async fn media_confirm( | |||
| 240 | 244 | ||
| 241 | 245 | // Atomically increment storage BEFORE writing the DB record. | |
| 242 | 246 | // Avoids orphaned unbilled file references. | |
| 243 | - | db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage) | |
| 244 | - | .await?; | |
| 247 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 248 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 249 | + | return Err(e); | |
| 250 | + | } | |
| 245 | 251 | ||
| 246 | 252 | let folder = sanitize_folder(&req.folder); | |
| 247 | 253 | let safe_filename = req.file_name |
| @@ -184,10 +184,39 @@ pub(super) async fn confirm_upload( | |||
| 184 | 184 | } | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | // Idempotency: if the matching s3_key field already equals this key, return success (no-op). | |
| 188 | + | // If replacing a different file, decrement old storage first. | |
| 189 | + | if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? { | |
| 190 | + | let existing_key = match file_type { | |
| 191 | + | FileType::Audio => item.audio_s3_key.as_deref(), | |
| 192 | + | FileType::Cover => item.cover_s3_key.as_deref(), | |
| 193 | + | FileType::Video => item.video_s3_key.as_deref(), | |
| 194 | + | _ => None, | |
| 195 | + | }; | |
| 196 | + | if existing_key == Some(&req.s3_key) { | |
| 197 | + | return Ok(Json(ConfirmUploadResponse { success: true })); | |
| 198 | + | } | |
| 199 | + | // If replacing a different file, decrement old storage | |
| 200 | + | if let Some(_old_key) = existing_key { | |
| 201 | + | let old_size = match file_type { | |
| 202 | + | FileType::Audio => item.audio_file_size_bytes.unwrap_or(0), | |
| 203 | + | FileType::Cover => item.cover_file_size_bytes.unwrap_or(0), | |
| 204 | + | FileType::Video => item.video_file_size_bytes.unwrap_or(0), | |
| 205 | + | _ => 0, | |
| 206 | + | }; | |
| 207 | + | if old_size > 0 { | |
| 208 | + | db::creator_tiers::decrement_storage_used(&state.db, user.id, old_size).await?; | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 187 | 213 | // Atomically check storage cap and increment counter BEFORE writing | |
| 188 | 214 | // the item record. If this fails (quota exceeded), the item's s3_key | |
| 189 | 215 | // is never set, avoiding an orphaned unbilled file reference. | |
| 190 | - | db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; | |
| 216 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 217 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 218 | + | return Err(e); | |
| 219 | + | } | |
| 191 | 220 | ||
| 192 | 221 | // Update the database with the S3 key and file size | |
| 193 | 222 | match file_type { |
| @@ -154,9 +154,26 @@ pub(super) async fn version_confirm_upload( | |||
| 154 | 154 | return Err(err); | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | // Idempotency: if the version already has this exact s3_key, return success (no-op) | |
| 158 | + | if version.s3_key.as_deref() == Some(&req.s3_key) { | |
| 159 | + | return Ok(Json(ConfirmUploadResponse { success: true })); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // If replacing a different file, decrement old storage | |
| 163 | + | if version.s3_key.is_some() { | |
| 164 | + | if let Some(old_size) = version.file_size_bytes { | |
| 165 | + | if old_size > 0 { | |
| 166 | + | db::creator_tiers::decrement_storage_used(&state.db, user.id, old_size).await?; | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 157 | 171 | // Atomically check storage cap and increment counter BEFORE writing | |
| 158 | 172 | // the version record. Avoids orphaned unbilled file references. | |
| 159 | - | db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await?; | |
| 173 | + | if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, user.id, file_size_bytes, max_storage).await { | |
| 174 | + | s3.delete_object(&req.s3_key).await.ok(); | |
| 175 | + | return Err(e); | |
| 176 | + | } | |
| 160 | 177 | ||
| 161 | 178 | // Extract file name from the s3_key (last path segment) | |
| 162 | 179 | let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string()); |
| @@ -109,12 +109,12 @@ impl FromRequestParts<AppState> for SyncUser { | |||
| 109 | 109 | return Err(AppError::Unauthorized); | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | - | // Verify user is not suspended (JWT may outlive suspension) | |
| 112 | + | // Verify user is not suspended or deactivated (JWT may outlive suspension) | |
| 113 | 113 | let user = crate::db::users::get_user_by_id(&state.db, claims.sub) | |
| 114 | 114 | .await | |
| 115 | 115 | .map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to verify sync user")))? | |
| 116 | 116 | .ok_or(AppError::Unauthorized)?; | |
| 117 | - | if user.is_suspended() { | |
| 117 | + | if user.is_suspended() || user.is_deactivated() { | |
| 118 | 118 | return Err(AppError::Unauthorized); | |
| 119 | 119 | } | |
| 120 | 120 |