Security audit follow-up. No Critical/High existed; these close the
Medium/Low findings.
F1 [Med] Host privilege boundary: a host could demote a peer host to
guest and then ban / PIN-reset (→ account takeover via /recover) them,
because the ban/pin-reset peer guards key off the target's *current*
role. set_role now blocks a non-admin from demoting a host.
F2 [Med] Moderation now revokes preview/thumbnail access: they are served
through visibility-checked aliases (/api/v1/upload/{id}/{preview,
thumbnail}) that filter soft-deleted / ban-hidden uploads, and direct
/media/previews|thumbnails is 404-blocked. Feed emits the gated URLs;
Caddy keeps them privately cacheable (max-age=300, short so moderation
revokes promptly — the live feed already evicts cards via SSE). Uses a
lean find_visible_media() (paths+mime only) on the media hot path.
F3 [Med/Low] npm audit fix: svelte 5.55.1→5.56.4 (SSR-XSS advisories),
devalue 5.6.4→5.8.1 (DoS). Prod deps: 0 vulnerabilities.
F4 [Low] /recover no longer leaks account existence: unknown display name
returns the same 401 as a wrong PIN, and runs a dummy bcrypt verify so
timing matches — closing the enumeration + timing oracle.
F5 [Low] SSE streams re-validate the session every ~60s and close once it
is logged out / expired, instead of running until client disconnect.
F6 [Info] X-Content-Type-Options: nosniff set at the app layer on all
media responses (defense-in-depth alongside the edge).
Tests: new e2e specs for F1 (host cannot demote peer host), F2 (preview
gated + 404 after delete + direct /media blocked), F4 (uniform 401).
40 backend unit tests + 182 e2e passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
362 lines
13 KiB
Rust
362 lines
13 KiB
Rust
use std::time::Duration;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::Json;
|
|
use chrono::Utc;
|
|
use rand::Rng;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::jwt;
|
|
use crate::auth::middleware::AuthUser;
|
|
use crate::error::AppError;
|
|
use crate::models::event::Event;
|
|
use crate::models::session::Session;
|
|
use crate::models::user::{User, UserRole};
|
|
use crate::services::config;
|
|
use crate::services::rate_limiter::client_ip;
|
|
use crate::state::AppState;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct JoinRequest {
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct JoinResponse {
|
|
pub jwt: String,
|
|
pub pin: String,
|
|
pub user_id: Uuid,
|
|
pub is_new: bool,
|
|
}
|
|
|
|
pub async fn join(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<JoinRequest>,
|
|
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
|
let ip = client_ip(&headers, "unknown");
|
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
|
let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;
|
|
if rate_limits_on && join_rate_on
|
|
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
|
{
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let display_name = body.display_name.trim();
|
|
let name_chars = display_name.chars().count();
|
|
if name_chars == 0 || name_chars > 50 {
|
|
return Err(AppError::BadRequest(
|
|
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
|
|
));
|
|
}
|
|
// Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers
|
|
// see a clean 400 instead of an internal error.
|
|
if display_name.contains('\0') {
|
|
return Err(AppError::BadRequest(
|
|
"Name enthält ungültige Zeichen.".into(),
|
|
));
|
|
}
|
|
|
|
let event = Event::find_or_create(
|
|
&state.pool,
|
|
&state.config.event_slug,
|
|
&state.config.event_name,
|
|
)
|
|
.await?;
|
|
|
|
// Reject if a user with this name (case-insensitive) already exists
|
|
if User::name_taken(&state.pool, event.id, display_name).await? {
|
|
return Err(AppError::Conflict(format!(
|
|
"Der Name \"{}\" ist bereits vergeben.",
|
|
display_name
|
|
)));
|
|
}
|
|
|
|
// Generate a 4-digit PIN
|
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
|
let pin_hash =
|
|
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
|
|
|
|
let token = jwt::create_token(
|
|
user.id,
|
|
event.id,
|
|
user.role.clone(),
|
|
&state.config.jwt_secret,
|
|
state.config.session_expiry_days,
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
|
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
|
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(JoinResponse {
|
|
jwt: token,
|
|
pin,
|
|
user_id: user.id,
|
|
is_new: true,
|
|
}),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RecoverRequest {
|
|
pub display_name: String,
|
|
pub pin: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct RecoverResponse {
|
|
pub jwt: String,
|
|
pub user_id: Uuid,
|
|
}
|
|
|
|
/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used
|
|
/// to run a constant-time-ish verify on the "unknown display name" branch of
|
|
/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't
|
|
/// be told apart by timing.
|
|
fn dummy_pin_hash() -> &'static str {
|
|
static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
HASH.get_or_init(|| {
|
|
bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12)
|
|
.expect("bcrypt hashing of a static dummy value cannot fail")
|
|
})
|
|
}
|
|
|
|
pub async fn recover(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<RecoverRequest>,
|
|
) -> Result<Json<RecoverResponse>, AppError> {
|
|
let display_name = body.display_name.trim();
|
|
|
|
// Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this
|
|
// an attacker who knows a display name (they're visible on the feed) can
|
|
// burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
|
|
// every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
|
|
// softens that into a real cost.
|
|
let ip = client_ip(&headers, "unknown");
|
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
|
let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
|
|
if rate_limits_on && recover_rate_on {
|
|
let name_key = display_name.to_lowercase();
|
|
if !state.rate_limiter.check(
|
|
format!("recover:{ip}:{name_key}"),
|
|
5,
|
|
Duration::from_secs(15 * 60),
|
|
) {
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
|
|
None,
|
|
));
|
|
}
|
|
}
|
|
|
|
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
|
|
|
let users =
|
|
User::find_by_event_and_name(&state.pool, event.id, display_name).await?;
|
|
|
|
if users.is_empty() {
|
|
// No user with this name. Run a throwaway bcrypt verify so this branch takes
|
|
// the same time as the user-exists path, and return the SAME error as a wrong
|
|
// PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
|
|
// timing. Display names are already public on the feed, but this still closes
|
|
// the /recover enumeration + timing oracle.
|
|
let _ = bcrypt::verify(&body.pin, dummy_pin_hash());
|
|
return Err(AppError::Unauthorized("PIN ist falsch.".into()));
|
|
}
|
|
|
|
for user in &users {
|
|
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt
|
|
// counter so the user gets a fresh 3-strike window — otherwise the counter
|
|
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
|
|
// after waiting out the cooldown. Without this reset, a once-locked account
|
|
// is effectively permanently fragile.
|
|
if let Some(locked_until) = user.pin_locked_until {
|
|
if Utc::now() < locked_until {
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
|
None,
|
|
));
|
|
}
|
|
// Lockout window expired — wipe the counter and the timestamp.
|
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
|
}
|
|
|
|
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
|
.unwrap_or(false);
|
|
|
|
if pin_matches {
|
|
// Reset failed attempts on success
|
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
|
|
|
let token = jwt::create_token(
|
|
user.id,
|
|
event.id,
|
|
user.role.clone(),
|
|
&state.config.jwt_secret,
|
|
state.config.session_expiry_days,
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
|
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
|
|
|
return Ok(Json(RecoverResponse {
|
|
jwt: token,
|
|
user_id: user.id,
|
|
}));
|
|
}
|
|
|
|
// Wrong PIN — increment failure count
|
|
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
|
tracing::warn!(
|
|
user_id = %user.id,
|
|
event_id = %event.id,
|
|
ip = %ip,
|
|
attempts,
|
|
"recover: wrong PIN"
|
|
);
|
|
if attempts >= 3 {
|
|
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
|
User::lock_pin(&state.pool, user.id, lockout).await?;
|
|
tracing::warn!(
|
|
user_id = %user.id,
|
|
event_id = %event.id,
|
|
ip = %ip,
|
|
"recover: account locked for 15 minutes"
|
|
);
|
|
}
|
|
}
|
|
|
|
Err(AppError::Unauthorized("PIN ist falsch.".into()))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct AdminLoginRequest {
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct AdminLoginResponse {
|
|
pub jwt: String,
|
|
/// The admin's user id + display name, so the client can populate a real identity
|
|
/// (own-post affordances, a name on the Account page) instead of a blank session.
|
|
pub user_id: Uuid,
|
|
pub display_name: String,
|
|
}
|
|
|
|
pub async fn admin_login(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<AdminLoginRequest>,
|
|
) -> Result<Json<AdminLoginResponse>, AppError> {
|
|
if state.config.admin_password_hash.is_empty() {
|
|
return Err(AppError::Forbidden(
|
|
"Admin-Login ist nicht konfiguriert.".into(),
|
|
));
|
|
}
|
|
|
|
// Throttle password attempts. The admin password is bcrypt-hashed (slow to
|
|
// verify) but with no IP-level limit a determined attacker can still mount
|
|
// a long-running guess campaign. 5 attempts / minute / IP is plenty for
|
|
// honest typos.
|
|
let ip = client_ip(&headers, "unknown");
|
|
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
|
|
let admin_rate_on = config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;
|
|
if rate_limits_on && admin_rate_on
|
|
&& !state.rate_limiter.check(
|
|
format!("admin_login:{ip}"),
|
|
5,
|
|
Duration::from_secs(60),
|
|
)
|
|
{
|
|
return Err(AppError::TooManyRequests(
|
|
"Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
|
|
.unwrap_or(false);
|
|
|
|
if !valid {
|
|
tracing::warn!(ip = %ip, "admin_login: wrong password");
|
|
return Err(AppError::Unauthorized("Falsches Passwort.".into()));
|
|
}
|
|
|
|
let event = Event::find_or_create(
|
|
&state.pool,
|
|
&state.config.event_slug,
|
|
&state.config.event_name,
|
|
)
|
|
.await?;
|
|
|
|
// Find or create the admin user for this event
|
|
let admin_name = "Admin";
|
|
let users = User::find_by_event_and_name(&state.pool, event.id, admin_name).await?;
|
|
let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
|
|
u
|
|
} else {
|
|
// Admin authenticates via password, but the schema still requires a PIN
|
|
// hash. Generate a random unguessable PIN so the recovery path remains
|
|
// unusable as an escalation route even if the role flag ever got cleared.
|
|
let dummy_pin: String = (0..32)
|
|
.map(|_| rand::rng().random_range(b'a'..=b'z') as char)
|
|
.collect();
|
|
let dummy_hash = bcrypt::hash(&dummy_pin, 4)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
|
|
sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
|
|
.bind(user.id)
|
|
.execute(&state.pool)
|
|
.await?;
|
|
User::find_by_id(&state.pool, user.id)
|
|
.await?
|
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
|
|
};
|
|
|
|
tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success");
|
|
|
|
let token = jwt::create_token(
|
|
admin_user.id,
|
|
event.id,
|
|
UserRole::Admin,
|
|
&state.config.jwt_secret,
|
|
1, // Admin sessions expire after 1 day
|
|
)
|
|
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
|
|
|
let token_hash = jwt::hash_token(&token);
|
|
let expires_at = Utc::now() + chrono::Duration::days(1);
|
|
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
|
|
|
|
Ok(Json(AdminLoginResponse {
|
|
jwt: token,
|
|
user_id: admin_user.id,
|
|
display_name: admin_user.display_name,
|
|
}))
|
|
}
|
|
|
|
pub async fn logout(
|
|
State(state): State<AppState>,
|
|
auth: AuthUser,
|
|
) -> Result<StatusCode, AppError> {
|
|
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|