Skip to main content

max / makenotwork

Consolidate the login gate and type-witness admin writes (ultra-fuzz Run 11 Security) Drive the Security axis to A+: - New auth::relying_party_login_gate folds wrong-password / suspended / deactivated / locked / 2FA into one accounted decision (always increment on denial, reset on success). OAuth authorize and SyncKit auth both call it instead of open-coding the ordering, closing the confirmed-password oracle where OAuth reset the counter before its 2FA/suspended gates (M1). The two RP flows can no longer drift; SyncKit also gains the missing deactivated check. Regression test asserts a correct password on a 2FA account is indistinguishable from a wrong one (generic error + counter increment). - New auth::AdminId witness, minted only by the AdminUser extractor; create_action and resolve_report take AdminId instead of a caller-supplied UserId, so a forged admin id is un-constructible at the type level (M2). - Gate the 4 path-based scan/sign variants #[cfg(test)] — they were test-only on the live path; this also moots scan_with_yara_path's uncapped fs::read (L1). - LOW: scope delete_pending_2fa_session by user_id (kills session-id enumeration delete); deterministic prune tie-break (last_active_at DESC, id DESC); lower the ZIP per-entry ratio floor 1 MiB -> 64 KiB to catch mid-size bombs earlier.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 17:43 UTC
Signed with PGP, not checked
Commit: 1f0c86a8795dba315baf3ac36486bea816724143
Parent: 803b50d
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");