max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
19 files changed,
+305 insertions,
-148 deletions
| @@ -343,12 +343,43 @@ | |||
| 343 | 343 | } | |
| 344 | 344 | } | |
| 345 | 345 | ||
| 346 | + | /// Proof that an admin identity was established by the [`AdminUser`] extractor. | |
| 347 | + | /// | |
| 348 | + | /// The inner `UserId` is private to this module and the only public constructor | |
| 349 | + | /// is [`AdminUser::admin_id`], so an `AdminId` cannot exist without having passed | |
| 350 | + | /// the `require_admin` gate. DB writers that stamp an actor (`moderation_actions`, | |
| 351 | + | /// `report.resolved_by`) take `AdminId` instead of a bare `UserId`, making a | |
| 352 | + | /// forged or caller-supplied admin id un-constructible at the type level rather | |
| 353 | + | /// than relying on every route to remember the guard (ultra-fuzz Run 11 Sec M2). | |
| 354 | + | #[derive(Clone, Copy, Debug)] | |
| 355 | + | pub struct AdminId(UserId); | |
| 356 | + | ||
| 357 | + | impl AdminId { | |
| 358 | + | /// The witnessed admin user id, for binding into a query. | |
| 359 | + | pub fn get(self) -> UserId { | |
| 360 | + | self.0 | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 346 | 364 | /// Extractor for admin users - returns NotFound (hides admin routes) if not admin. | |
| 347 | 365 | /// | |
| 348 | 366 | /// Combines `AuthUser` session check with `require_admin` config check into a | |
| 349 | 367 | /// single type-safe extractor, eliminating per-handler `require_admin()` calls. | |
| 350 | 368 | pub struct AdminUser(pub SessionUser); | |
| 351 | 369 | ||
| 370 | + | impl AdminUser { | |
| 371 | + | /// Mint the [`AdminId`] witness for this verified admin. The only way to | |
| 372 | + | /// obtain an `AdminId` — its private field can't be constructed elsewhere. | |
| 373 | + | pub fn admin_id(&self) -> AdminId { | |
| 374 | + | AdminId(self.0.id) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | /// The admin's plain `UserId`, for tracing/display (not a write witness). | |
| 378 | + | pub fn id(&self) -> UserId { | |
| 379 | + | self.0.id | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 352 | 383 | impl FromRequestParts<crate::AppState> for AdminUser { | |
| 353 | 384 | type Rejection = AppError; | |
| 354 | 385 | ||
| @@ -489,6 +520,57 @@ | |||
| 489 | 520 | .map_err(|e| AppError::Internal(anyhow::anyhow!("argon2 verify task join: {e}")))? | |
| 490 | 521 | } | |
| 491 | 522 | ||
| 523 | + | /// Outcome of [`relying_party_login_gate`]. | |
| 524 | + | pub enum LoginGate { | |
| 525 | + | /// Password correct and the account may complete a relying-party login. | |
| 526 | + | Allow, | |
| 527 | + | /// Login refused. `just_locked` is true when *this* attempt tripped the | |
| 528 | + | /// lockout (so an interactive caller can show a one-time lockout notice); | |
| 529 | + | /// every other refusal reason is indistinguishable by design. | |
| 530 | + | Deny { just_locked: bool }, | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | /// Uniform password + account-status gate for relying-party logins (OAuth | |
| 534 | + | /// authorize, SyncKit auth) — the flows that reject 2FA accounts outright. | |
| 535 | + | /// | |
| 536 | + | /// Runs Argon2 first, then folds *every* refusal reason (wrong password, | |
| 537 | + | /// suspended, deactivated, locked, 2FA-enabled) into a single accounted | |
| 538 | + | /// decision: a denial always increments the failed-login counter, a success | |
| 539 | + | /// always resets it. Collapsing the blocked-account cases into the wrong-password | |
| 540 | + | /// path is what stops the counter from becoming a confirmed-password oracle — a | |
| 541 | + | /// correct guess against a 2FA/suspended account must be indistinguishable from a | |
| 542 | + | /// wrong one (ultra-fuzz Run 3 / Run 11 Sec M1). Both relying parties call this | |
| 543 | + | /// instead of open-coding the ordering, so the invariant lives in one place. | |
| 544 | + | pub async fn relying_party_login_gate( | |
| 545 | + | pool: &sqlx::PgPool, | |
| 546 | + | user: &db::DbUser, | |
| 547 | + | password: &str, | |
| 548 | + | ) -> Result<LoginGate, AppError> { | |
| 549 | + | let valid = verify_password_async(password.to_string(), user.password_hash.clone()).await?; | |
| 550 | + | ||
| 551 | + | let locked = user | |
| 552 | + | .locked_until | |
| 553 | + | .is_some_and(|locked_until| locked_until > chrono::Utc::now()); | |
| 554 | + | let denied = | |
| 555 | + | !valid || user.is_suspended() || user.is_deactivated() || locked || user.totp_enabled; | |
| 556 | + | ||
| 557 | + | if denied { | |
| 558 | + | let result = db::auth::increment_failed_login( | |
| 559 | + | pool, | |
| 560 | + | user.id, | |
| 561 | + | constants::MAX_LOGIN_ATTEMPTS, | |
| 562 | + | constants::LOCKOUT_MINUTES, | |
| 563 | + | ) | |
| 564 | + | .await?; | |
| 565 | + | return Ok(LoginGate::Deny { | |
| 566 | + | just_locked: result.just_locked, | |
| 567 | + | }); | |
| 568 | + | } | |
| 569 | + | ||
| 570 | + | db::auth::reset_failed_login(pool, user.id).await?; | |
| 571 | + | Ok(LoginGate::Allow) | |
| 572 | + | } | |
| 573 | + | ||
| 492 | 574 | /// Store user in session with session regeneration to prevent fixation attacks | |
| 493 | 575 | #[tracing::instrument(skip_all, fields(user_id = %user.id))] | |
| 494 | 576 | pub async fn login_user(session: &Session, user: SessionUser) -> Result<(), AppError> { |
| @@ -25,7 +25,7 @@ | |||
| 25 | 25 | pub async fn create_action( | |
| 26 | 26 | pool: &PgPool, | |
| 27 | 27 | user_id: UserId, | |
| 28 | - | admin_id: UserId, | |
| 28 | + | admin_id: crate::auth::AdminId, | |
| 29 | 29 | action_type: ModerationActionType, | |
| 30 | 30 | reason: &str, | |
| 31 | 31 | content_ref: Option<&str>, | |
| @@ -38,7 +38,7 @@ | |||
| 38 | 38 | "#, | |
| 39 | 39 | ) | |
| 40 | 40 | .bind(user_id) | |
| 41 | - | .bind(admin_id) | |
| 41 | + | .bind(admin_id.get()) | |
| 42 | 42 | .bind(action_type) | |
| 43 | 43 | .bind(reason) | |
| 44 | 44 | .bind(content_ref) |
| @@ -98,7 +98,7 @@ | |||
| 98 | 98 | id: ReportId, | |
| 99 | 99 | status: ReportStatus, | |
| 100 | 100 | admin_notes: &str, | |
| 101 | - | resolved_by: UserId, | |
| 101 | + | resolved_by: crate::auth::AdminId, | |
| 102 | 102 | ) -> Result<()> { | |
| 103 | 103 | let result = sqlx::query( | |
| 104 | 104 | r#" | |
| @@ -110,7 +110,7 @@ | |||
| 110 | 110 | .bind(id) | |
| 111 | 111 | .bind(status) | |
| 112 | 112 | .bind(admin_notes) | |
| 113 | - | .bind(resolved_by) | |
| 113 | + | .bind(resolved_by.get()) | |
| 114 | 114 | .execute(pool) | |
| 115 | 115 | .await?; | |
| 116 | 116 |
| @@ -32,6 +32,10 @@ | |||
| 32 | 32 | /// intermediates are never touched. Ordered by `last_active_at DESC`, so the | |
| 33 | 33 | /// freshly-created current session (newest) is always kept and the stalest are | |
| 34 | 34 | /// evicted. Returns the number of rows pruned. | |
| 35 | + | /// | |
| 36 | + | /// The keep-set is ordered `last_active_at DESC, id DESC` — the `id` tie-break | |
| 37 | + | /// makes the eviction deterministic when several sessions share a timestamp, | |
| 38 | + | /// instead of leaving the choice to Postgres' undefined row order (Run 11 Sec LOW). | |
| 35 | 39 | #[tracing::instrument(skip_all)] | |
| 36 | 40 | pub async fn prune_user_sessions_over_cap( | |
| 37 | 41 | pool: &PgPool, | |
| @@ -45,7 +49,7 @@ | |||
| 45 | 49 | AND id NOT IN ( | |
| 46 | 50 | SELECT id FROM user_sessions | |
| 47 | 51 | WHERE user_id = $1 AND kind = 'active' | |
| 48 | - | ORDER BY last_active_at DESC | |
| 52 | + | ORDER BY last_active_at DESC, id DESC | |
| 49 | 53 | LIMIT $2 | |
| 50 | 54 | ) | |
| 51 | 55 | "#, | |
| @@ -103,11 +107,19 @@ | |||
| 103 | 107 | /// Delete a pending_2fa tracking row. Called when 2FA succeeds (the caller | |
| 104 | 108 | /// then `track_session`s a fresh 'active' row) or when the pending state is | |
| 105 | 109 | /// cleared (expiry, account lockout, navigation away). | |
| 110 | + | /// | |
| 111 | + | /// Scoped by `user_id` (not id alone) so a guessed/enumerated session id can't | |
| 112 | + | /// delete another user's pending_2fa row (ultra-fuzz Run 11 Sec LOW). | |
| 106 | 113 | #[tracing::instrument(skip_all)] | |
| 107 | - | pub async fn delete_pending_2fa_session(pool: &PgPool, id: UserSessionId) -> Result<()> { | |
| 114 | + | pub async fn delete_pending_2fa_session( | |
| 115 | + | pool: &PgPool, | |
| 116 | + | id: UserSessionId, | |
| 117 | + | user_id: UserId, | |
| 118 | + | ) -> Result<()> { | |
| 108 | 119 | sqlx::query!( | |
| 109 | - | "DELETE FROM user_sessions WHERE id = $1 AND kind = 'pending_2fa'", | |
| 120 | + | "DELETE FROM user_sessions WHERE id = $1 AND user_id = $2 AND kind = 'pending_2fa'", | |
| 110 | 121 | id as UserSessionId, | |
| 122 | + | user_id as UserId, | |
| 111 | 123 | ) | |
| 112 | 124 | .execute(pool) | |
| 113 | 125 | .await?; |
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | ||
| 21 | 21 | use crate::{ | |
| 22 | 22 | auth::{verify_password_async, MaybeUserVerified}, | |
| 23 | - | constants::{self, LOCKOUT_MINUTES, MAX_LOGIN_ATTEMPTS}, | |
| 23 | + | constants::{self, LOCKOUT_MINUTES}, | |
| 24 | 24 | csrf, | |
| 25 | 25 | db::{self, CreatorTier, SyncAppId, UserId, Username}, | |
| 26 | 26 | error::{AppError, Result}, | |
| @@ -506,57 +506,33 @@ | |||
| 506 | 506 | )); | |
| 507 | 507 | } | |
| 508 | 508 | ||
| 509 | - | // Verify password | |
| 510 | - | if !verify_password_async(password.to_string(), user.password_hash.clone()).await? { | |
| 511 | - | let result = db::auth::increment_failed_login( | |
| 512 | - | &state.db, user.id, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES, | |
| 513 | - | ).await?; | |
| 514 | - | ||
| 515 | - | if result.just_locked { | |
| 509 | + | // Verify the password and account status through the shared relying-party | |
| 510 | + | // gate. It folds wrong-password / suspended / deactivated / locked / 2FA | |
| 511 | + | // into one accounted decision (always increment on denial, reset on | |
| 512 | + | // success) so a correct guess against a blocked account is NOT | |
| 513 | + | // distinguishable from a wrong one — closing the confirmed-password oracle | |
| 514 | + | // that arose from resetting before the status gates (Run 11 Sec M1). The | |
| 515 | + | // friendly "already locked" message above still short-circuits before | |
| 516 | + | // Argon2; here, only a freshly-tripped lockout earns a distinct notice. | |
| 517 | + | match crate::auth::relying_party_login_gate(&state.db, &user, password).await? { | |
| 518 | + | crate::auth::LoginGate::Deny { just_locked } => { | |
| 519 | + | let message = if just_locked { | |
| 520 | + | format!( | |
| 521 | + | "Too many failed attempts. Account locked for {} minutes.", | |
| 522 | + | LOCKOUT_MINUTES | |
| 523 | + | ) | |
| 524 | + | } else { | |
| 525 | + | "Invalid username/email or password".to_string() | |
| 526 | + | }; | |
| 516 | 527 | return Ok(render_authorize_error( | |
| 517 | 528 | Some(csrf_token), | |
| 518 | 529 | session_user, | |
| 519 | 530 | &app.name, | |
| 520 | 531 | &form, | |
| 521 | - | &format!( | |
| 522 | - | "Too many failed attempts. Account locked for {} minutes.", | |
| 523 | - | LOCKOUT_MINUTES | |
| 524 | - | ), | |
| 532 | + | &message, | |
| 525 | 533 | )); | |
| 526 | 534 | } | |
| 527 | - | ||
| 528 | - | return Ok(render_authorize_error( | |
| 529 | - | Some(csrf_token), | |
| 530 | - | session_user, | |
| 531 | - | &app.name, | |
| 532 | - | &form, | |
| 533 | - | "Invalid username/email or password", | |
| 534 | - | )); | |
| 535 | - | } | |
| 536 | - | ||
| 537 | - | // Successful auth — reset failed attempts | |
| 538 | - | db::auth::reset_failed_login(&state.db, user.id).await?; | |
| 539 | - | ||
| 540 | - | // Block suspended or deactivated users | |
| 541 | - | if user.is_suspended() || user.is_deactivated() { | |
| 542 | - | return Ok(render_authorize_error( | |
| 543 | - | Some(csrf_token), | |
| 544 | - | session_user, | |
| 545 | - | &app.name, | |
| 546 | - | &form, | |
| 547 | - | "This account is not active.", | |
| 548 | - | )); | |
| 549 | - | } | |
| 550 | - | ||
| 551 | - | // If user has TOTP 2FA enabled, reject — they must log in via the main site first | |
| 552 | - | if user.totp_enabled { | |
| 553 | - | return Ok(render_authorize_error( | |
| 554 | - | Some(csrf_token), | |
| 555 | - | session_user, | |
| 556 | - | &app.name, | |
| 557 | - | &form, | |
| 558 | - | "This account has two-factor authentication enabled. Please log in at makenot.work first, then return here to authorize the app.", | |
| 559 | - | )); | |
| 535 | + | crate::auth::LoginGate::Allow => {} | |
| 560 | 536 | } | |
| 561 | 537 | ||
| 562 | 538 | user.id |
| @@ -415,7 +415,14 @@ | |||
| 415 | 415 | } else { | |
| 416 | 416 | total_compressed += entry_compressed; | |
| 417 | 417 | total_uncompressed += actual_size; | |
| 418 | - | if entry_compressed > 0 && actual_size >= 1024 * 1024 { | |
| 418 | + | // Per-entry ratio is a fast-path signal; the accumulation vector | |
| 419 | + | // (many small ultra-compressed entries) is the total-ratio guard's | |
| 420 | + | // job below. The size floor keeps tiny, naturally-compressible | |
| 421 | + | // files (text/JSON/SVG) from tripping the 100x ratio — lowered | |
| 422 | + | // from 1 MiB to 64 KiB so mid-size bombs are caught at the entry | |
| 423 | + | // level too, where a >100x ratio is already anomalous (Run 11 Sec | |
| 424 | + | // LOW). | |
| 425 | + | if entry_compressed > 0 && actual_size >= 64 * 1024 { | |
| 419 | 426 | let entry_ratio = actual_size as f64 / entry_compressed as f64; | |
| 420 | 427 | if entry_ratio > constants::SCAN_ZIP_MAX_RATIO { | |
| 421 | 428 | archive_res = Some(LayerResult { |
| @@ -26,7 +26,9 @@ | |||
| 26 | 26 | /// the modern format and what every current toolchain emits. | |
| 27 | 27 | const APPIMAGE_MARKER: [u8; 3] = *b"AI\x02"; | |
| 28 | 28 | ||
| 29 | - | /// Path-based entry. Mmaps the spooled file and delegates. | |
| 29 | + | /// Path-based entry. Mmaps the spooled file and delegates. Test-only — the live | |
| 30 | + | /// path verifies already-resident bytes (ultra-fuzz Run 11 Sec L1). | |
| 31 | + | #[cfg(test)] | |
| 30 | 32 | pub fn verify_appimage_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 31 | 33 | if !matches!(file_type, FileType::Download) { | |
| 32 | 34 | return skip("Not a download file type"); |
| @@ -45,7 +45,9 @@ | |||
| 45 | 45 | ||
| 46 | 46 | /// Path-based entry. Mmaps the spooled file (DMG signature lookup walks | |
| 47 | 47 | /// the trailer; MachFile parses headers + load commands — both touch | |
| 48 | - | /// specific offsets, demand-paged through the mmap) and delegates. | |
| 48 | + | /// specific offsets, demand-paged through the mmap) and delegates. Test-only — | |
| 49 | + | /// the live path verifies already-resident bytes (ultra-fuzz Run 11 Sec L1). | |
| 50 | + | #[cfg(test)] | |
| 49 | 51 | pub fn verify_apple_signature_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 50 | 52 | if !matches!(file_type, FileType::Download | FileType::Insertion) { | |
| 51 | 53 | return skip("Not a download file type"); |
| @@ -35,7 +35,9 @@ | |||
| 35 | 35 | ||
| 36 | 36 | /// Path-based entry. Mmaps the spooled file and delegates. PE parsing | |
| 37 | 37 | /// walks the NT headers + attribute-cert directory at fixed offsets, so | |
| 38 | - | /// demand-paging covers the whole inspection without buffering. | |
| 38 | + | /// demand-paging covers the whole inspection without buffering. Test-only — the | |
| 39 | + | /// live path verifies already-resident bytes (ultra-fuzz Run 11 Sec L1). | |
| 40 | + | #[cfg(test)] | |
| 39 | 41 | pub fn verify_authenticode_path(path: &std::path::Path, file_type: FileType) -> LayerResult { | |
| 40 | 42 | if !matches!(file_type, FileType::Download) { | |
| 41 | 43 | return skip("Not a download file type"); |