max / makenotwork
7 files changed,
+109 insertions,
-10 deletions
| @@ -499,6 +499,17 @@ pub async fn track_session( | |||
| 499 | 499 | let tracking_id = | |
| 500 | 500 | db::sessions::create_user_session(pool, user_id, user_agent.as_deref(), ip.as_deref()).await?; | |
| 501 | 501 | ||
| 502 | + | // Cap the user's active sessions so repeated logins can't grow the table | |
| 503 | + | // unboundedly. Best-effort: a prune failure must not break the login (the row | |
| 504 | + | // we just created is the newest and is never the one evicted). | |
| 505 | + | match db::sessions::prune_user_sessions_over_cap(pool, user_id, constants::MAX_SESSIONS_PER_USER).await { | |
| 506 | + | Ok(pruned) if pruned > 0 => { | |
| 507 | + | tracing::info!(pruned, "pruned oldest sessions over the per-user cap"); | |
| 508 | + | } | |
| 509 | + | Ok(_) => {} | |
| 510 | + | Err(e) => tracing::warn!(error = ?e, "failed to prune sessions over the per-user cap"), | |
| 511 | + | } | |
| 512 | + | ||
| 502 | 513 | session | |
| 503 | 514 | .insert(SESSION_TRACKING_KEY, tracking_id) | |
| 504 | 515 | .await |
| @@ -19,6 +19,13 @@ pub const SESSION_EXPIRY_DAYS: i64 = 7; | |||
| 19 | 19 | /// session-revocation lag (admin suspend, logout-everywhere, password change) — | |
| 20 | 20 | /// shorter = tighter revocation, slightly more DB load on the auth hot path. | |
| 21 | 21 | pub const SESSION_TOUCH_CACHE_SECS: u64 = 5; | |
| 22 | + | /// Cap on tracked sessions per user. Each login mints a `user_sessions` row; | |
| 23 | + | /// without a bound, repeated logins (or an automated loop) grow the table | |
| 24 | + | /// unboundedly. After a new session is recorded, the oldest beyond this many are | |
| 25 | + | /// pruned (a sliding window) — the current session is always the newest, so it | |
| 26 | + | /// is never evicted. Matches the 100-row session-listing cap, so anything pruned | |
| 27 | + | /// was already invisible bloat, never a session a user could see or manage. | |
| 28 | + | pub const MAX_SESSIONS_PER_USER: i64 = 100; | |
| 22 | 29 | ||
| 23 | 30 | // -- Login security -- | |
| 24 | 31 | pub const MAX_LOGIN_ATTEMPTS: i32 = 5; | |
| @@ -360,6 +367,7 @@ const _: () = assert!(DB_MAX_LIFETIME_SECS > DB_IDLE_TIMEOUT_SECS); | |||
| 360 | 367 | // Session constants | |
| 361 | 368 | const _: () = assert!(SESSION_EXPIRY_DAYS > 0 && SESSION_EXPIRY_DAYS <= 365); | |
| 362 | 369 | const _: () = assert!(SESSION_TOUCH_CACHE_SECS > 0 && SESSION_TOUCH_CACHE_SECS < 86400); | |
| 370 | + | const _: () = assert!(MAX_SESSIONS_PER_USER > 0); | |
| 363 | 371 | ||
| 364 | 372 | // Login security | |
| 365 | 373 | const _: () = assert!(MAX_LOGIN_ATTEMPTS > 0); |
| @@ -25,6 +25,39 @@ pub async fn create_user_session( | |||
| 25 | 25 | Ok(row) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | /// Cap a user's active sessions to the newest `max`, deleting older ones. | |
| 29 | + | /// | |
| 30 | + | /// Bounds unbounded `user_sessions` growth from repeated logins (or an automated | |
| 31 | + | /// loop). Only `kind='active'` rows are counted and pruned — `pending_2fa` | |
| 32 | + | /// intermediates are never touched. Ordered by `last_active_at DESC`, so the | |
| 33 | + | /// freshly-created current session (newest) is always kept and the stalest are | |
| 34 | + | /// evicted. Returns the number of rows pruned. | |
| 35 | + | #[tracing::instrument(skip_all)] | |
| 36 | + | pub async fn prune_user_sessions_over_cap( | |
| 37 | + | pool: &PgPool, | |
| 38 | + | user_id: UserId, | |
| 39 | + | max: i64, | |
| 40 | + | ) -> Result<u64> { | |
| 41 | + | let result = sqlx::query( | |
| 42 | + | r#" | |
| 43 | + | DELETE FROM user_sessions | |
| 44 | + | WHERE user_id = $1 AND kind = 'active' | |
| 45 | + | AND id NOT IN ( | |
| 46 | + | SELECT id FROM user_sessions | |
| 47 | + | WHERE user_id = $1 AND kind = 'active' | |
| 48 | + | ORDER BY last_active_at DESC | |
| 49 | + | LIMIT $2 | |
| 50 | + | ) | |
| 51 | + | "#, | |
| 52 | + | ) | |
| 53 | + | .bind(user_id) | |
| 54 | + | .bind(max) | |
| 55 | + | .execute(pool) | |
| 56 | + | .await?; | |
| 57 | + | ||
| 58 | + | Ok(result.rows_affected()) | |
| 59 | + | } | |
| 60 | + | ||
| 28 | 61 | /// Insert a `kind='pending_2fa'` row for an intermediate session held between | |
| 29 | 62 | /// the password step and the TOTP/backup-code step. Exposed to | |
| 30 | 63 | /// `delete_all_sessions_for_user` so "log out everywhere" sweeps a phisher |
| @@ -150,10 +150,14 @@ pub(super) async fn media_presign( | |||
| 150 | 150 | // Early quota check — images bypass tier, video requires BigFiles+ | |
| 151 | 151 | db::creator_tiers::check_presign_allowed(&state.db, user.id, file_type).await?; | |
| 152 | 152 | ||
| 153 | - | // Validate the declared size against the static per-type cap before signing | |
| 154 | - | // it into Content-Length (media video is the largest upload class, up to | |
| 155 | - | // 20 GB — it had no protocol-level size guard before). | |
| 156 | - | super::validate_declared_upload_size(req.file_size_bytes, file_type, None)?; | |
| 153 | + | // Validate the declared size against the static per-type cap AND the user's | |
| 154 | + | // tier per-file cap before signing it into Content-Length, so an oversize | |
| 155 | + | // media upload is rejected at presign instead of after the bytes are spent | |
| 156 | + | // and only caught at confirm. `get_effective_max_file_bytes` returns None for | |
| 157 | + | // images (they bypass the tier cap) and the tier limit for video. | |
| 158 | + | let max_file_bytes = | |
| 159 | + | db::creator_tiers::get_effective_max_file_bytes(&state.db, user.id, file_type).await?; | |
| 160 | + | super::validate_declared_upload_size(req.file_size_bytes, file_type, max_file_bytes)?; | |
| 157 | 161 | ||
| 158 | 162 | // Filename uniqueness is enforced at confirm time by the | |
| 159 | 163 | // `idx_media_files_user_folder_name` unique index — see `media_confirm`, |
| @@ -131,7 +131,7 @@ pub fn spawn_scheduler( | |||
| 131 | 131 | ); | |
| 132 | 132 | let state_for_announce = state.clone(); | |
| 133 | 133 | let item_for_announce = item.clone(); | |
| 134 | - | tokio::spawn(async move { | |
| 134 | + | state.bg.spawn("release-announcements", async move { | |
| 135 | 135 | announcements::send_release_announcements( | |
| 136 | 136 | &state_for_announce, &item_for_announce, | |
| 137 | 137 | ).await; | |
| @@ -163,7 +163,7 @@ pub fn spawn_scheduler( | |||
| 163 | 163 | ); | |
| 164 | 164 | let state_for_announce = state.clone(); | |
| 165 | 165 | let post_for_announce = post.clone(); | |
| 166 | - | tokio::spawn(async move { | |
| 166 | + | state.bg.spawn("blog-post-announcements", async move { | |
| 167 | 167 | announcements::send_blog_post_announcements( | |
| 168 | 168 | &state_for_announce, &post_for_announce, | |
| 169 | 169 | ).await; |
| @@ -24,7 +24,7 @@ pub fn spawn_mt_thread_for_item( | |||
| 24 | 24 | let username = user.username.to_string(); | |
| 25 | 25 | let display_name = user.display_name.clone(); | |
| 26 | 26 | ||
| 27 | - | tokio::spawn(async move { | |
| 27 | + | state.bg.spawn("mt-thread-item", async move { | |
| 28 | 28 | create_mt_thread_for_item( | |
| 29 | 29 | &mt, | |
| 30 | 30 | &db_pool, | |
| @@ -53,7 +53,7 @@ pub(super) fn spawn_mt_thread_for_item_by_lookup(state: &AppState, item: &DbItem | |||
| 53 | 53 | let item_title = item.title.clone(); | |
| 54 | 54 | let project_id = item.project_id; | |
| 55 | 55 | ||
| 56 | - | tokio::spawn(async move { | |
| 56 | + | state.bg.spawn("mt-thread-item-lookup", async move { | |
| 57 | 57 | let Ok(Some(project)) = db::projects::get_project_by_id(&db_pool, project_id).await | |
| 58 | 58 | else { | |
| 59 | 59 | return; | |
| @@ -139,7 +139,7 @@ pub fn spawn_mt_thread_for_blog_post( | |||
| 139 | 139 | let username = user.username.to_string(); | |
| 140 | 140 | let display_name = user.display_name.clone(); | |
| 141 | 141 | ||
| 142 | - | tokio::spawn(async move { | |
| 142 | + | state.bg.spawn("mt-thread-blog", async move { | |
| 143 | 143 | create_mt_thread_for_blog_post( | |
| 144 | 144 | &mt, | |
| 145 | 145 | &db_pool, | |
| @@ -170,7 +170,7 @@ pub(super) fn spawn_mt_thread_for_blog_post_by_lookup(state: &AppState, post: &D | |||
| 170 | 170 | let post_slug = post.slug.to_string(); | |
| 171 | 171 | let project_id = post.project_id; | |
| 172 | 172 | ||
| 173 | - | tokio::spawn(async move { | |
| 173 | + | state.bg.spawn("mt-thread-blog-lookup", async move { | |
| 174 | 174 | let Ok(Some(project)) = db::projects::get_project_by_id(&db_pool, project_id).await | |
| 175 | 175 | else { | |
| 176 | 176 | return; |
| @@ -211,3 +211,46 @@ async fn touch_session_and_revoke_primitive_is_idempotent() { | |||
| 211 | 211 | // once the auth touch cache lapses. | |
| 212 | 212 | assert!(!touch_session(&h.db, sid).await.unwrap().valid, "a revoked session must touch as invalid"); | |
| 213 | 213 | } | |
| 214 | + | ||
| 215 | + | // Per-user session cap: prune_user_sessions_over_cap bounds active-session growth, | |
| 216 | + | // keeping the newest `max` active sessions and never touching pending_2fa rows. | |
| 217 | + | #[tokio::test] | |
| 218 | + | async fn prune_user_sessions_over_cap_keeps_newest_and_spares_pending_2fa() { | |
| 219 | + | use makenotwork::db::sessions::prune_user_sessions_over_cap; | |
| 220 | + | ||
| 221 | + | let mut h = TestHarness::new().await; | |
| 222 | + | let user_id = h.signup("captest", "captest@test.com", "password123").await; | |
| 223 | + | ||
| 224 | + | // signup created one active session (last_active_at = now, the newest). Add | |
| 225 | + | // three ancient active sessions (oldest first) + one pending_2fa intermediate. | |
| 226 | + | let mut ancient = Vec::new(); | |
| 227 | + | for ts in [1i64, 2, 3] { | |
| 228 | + | let id: uuid::Uuid = sqlx::query_scalar( | |
| 229 | + | "INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'active', to_timestamp($2)) RETURNING id", | |
| 230 | + | ).bind(user_id).bind(ts).fetch_one(&h.db).await.unwrap(); | |
| 231 | + | ancient.push(id); | |
| 232 | + | } | |
| 233 | + | sqlx::query("INSERT INTO user_sessions (user_id, kind, last_active_at) VALUES ($1, 'pending_2fa', to_timestamp(5))") | |
| 234 | + | .bind(user_id).execute(&h.db).await.unwrap(); | |
| 235 | + | ||
| 236 | + | // 4 active + 1 pending_2fa. Cap to 2 active → the two oldest active are pruned. | |
| 237 | + | let pruned = prune_user_sessions_over_cap(&h.db, user_id, 2).await.unwrap(); | |
| 238 | + | assert_eq!(pruned, 2, "the two oldest active sessions must be pruned"); | |
| 239 | + | ||
| 240 | + | let active: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'active'") | |
| 241 | + | .bind(user_id).fetch_one(&h.db).await.unwrap(); | |
| 242 | + | assert_eq!(active, 2, "active sessions capped to the newest two"); | |
| 243 | + | ||
| 244 | + | let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE user_id = $1 AND kind = 'pending_2fa'") | |
| 245 | + | .bind(user_id).fetch_one(&h.db).await.unwrap(); | |
| 246 | + | assert_eq!(pending, 1, "a pending_2fa session must never be pruned by the cap"); | |
| 247 | + | ||
| 248 | + | // The two oldest (ts=1, ts=2) were evicted; the newer ancient (ts=3) and the | |
| 249 | + | // signup session (now) are kept. | |
| 250 | + | assert!(!session_exists(&h, ancient[0]).await, "oldest active session must be pruned"); | |
| 251 | + | assert!(!session_exists(&h, ancient[1]).await, "second-oldest active session must be pruned"); | |
| 252 | + | assert!(session_exists(&h, ancient[2]).await, "the newer active session must survive"); | |
| 253 | + | ||
| 254 | + | // Re-pruning at the same cap is a no-op. | |
| 255 | + | assert_eq!(prune_user_sessions_over_cap(&h.db, user_id, 2).await.unwrap(), 0, "re-prune is idempotent"); | |
| 256 | + | } |