Skip to main content

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-ByClaude 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

OldNewLine
@@ -59,9 +59,21 @@
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 @@
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 @@
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 @@
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(),
OldNewLine
@@ -160,6 +160,9 @@
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 @@
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;
OldNewLine
@@ -17,7 +17,7 @@
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;
OldNewLine
@@ -156,10 +156,27 @@
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 @@
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
}
OldNewLine
@@ -201,18 +201,32 @@
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.
OldNewLine
@@ -109,12 +109,12 @@
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
OldNewLine
@@ -167,32 +167,22 @@
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 @@
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 @@
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
}
OldNewLine
@@ -513,9 +513,14 @@
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
OldNewLine
@@ -7,7 +7,7 @@
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 @@
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 @@
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
OldNewLine
@@ -426,7 +426,7 @@
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
OldNewLine
@@ -28,6 +28,12 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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(
OldNewLine
@@ -9,6 +9,7 @@
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 @@
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 @@
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
// ============================================================================
OldNewLine
@@ -166,6 +166,12 @@
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 @@
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 @@
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 @@
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 @@
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