Skip to main content

max / makenotwork

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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:43 UTC
Commit: dc8dfe91913c773c47002043350a1d6823af037b
Parent: e7052f4
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