fix(security): critical — repair PIN reset + enforce real prod secrets

C1: reset_user_pin wrote to a non-existent column (pin_failed_attempts);
    the real column is failed_pin_attempts, so every PIN reset 500'd. Fixed
    the column name; new e2e (pin-reset.spec.ts) proves a reset returns a
    usable PIN and the target can recover with it.

C2: config.rs::validate_secrets now rejects placeholder-ish secrets
    (change_me/dev_secret/placeholder), enforces len>=32 in prod, and
    requires a real ADMIN_PASSWORD_HASH. docker-compose.yml sets
    APP_ENV=production so the guard actually runs. Corrected the false
    "fixed" claim in SECURITY-BACKLOG.md. .env.example documents the rule.

Riders in these files (documented here since git can't split hunks):
- host.rs also carries the event-scoped ban_user fix and the
  close_event/open_event no-op broadcast guard (medium).
- docker-compose.yml also adds ORIGIN (H6), app/frontend healthchecks with
  Caddy waiting on health, and per-service memory limits (medium).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-01 21:25:39 +02:00
parent 5d21b3eefd
commit 498b4e256b
7 changed files with 227 additions and 31 deletions

View File

@@ -113,10 +113,11 @@ pub async fn ban_user(
}
sqlx::query(
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1",
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1 AND event_id = $3",
)
.bind(user_id)
.bind(body.hide_uploads)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
@@ -263,7 +264,7 @@ pub async fn reset_user_pin(
sqlx::query(
"UPDATE \"user\"
SET recovery_pin_hash = $1,
pin_failed_attempts = 0,
failed_pin_attempts = 0,
pin_locked_until = NULL
WHERE id = $2",
)
@@ -341,14 +342,18 @@ pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
sqlx::query(
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// Only broadcast when this call actually flipped the lock — closing an
// already-closed event is a no-op and shouldn't spam listeners.
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
}
Ok(StatusCode::NO_CONTENT)
}
@@ -357,14 +362,16 @@ pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1",
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1 AND uploads_locked_at IS NOT NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
if result.rows_affected() > 0 {
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}
Ok(StatusCode::NO_CONTENT)
}