fix(dos): bound uploads, offload bcrypt, cap image decode, split queues

H3: re-enable a request-body limit (550MB, sized to the largest allowed video +
multipart overhead) instead of DefaultBodyLimit::disable(), and drop the
second full-file copy by keeping the upload as Bytes.

H4: image decode now goes through ImageReader with explicit dimension
(12000x12000) and allocation caps, rejecting decompression bombs before the
expensive resize, and the whole spawn_blocking is wrapped in a 120s timeout
mirroring the ffmpeg guard.

H8: every bcrypt hash/verify (join, recover, admin_login, host PIN reset) now
runs via spawn_blocking through a new services::password helper, so concurrent
auths can't pin the async workers.

M7: images and videos get independent compression permit pools, so slow videos
can no longer starve image-preview generation.

H5: add a generous per-IP daily account-creation cap (NAT-safe at ~100 guests)
on top of the burst cap, and wire up the previously-dead upload_count_quota_*
config as an event-wide file-count cap that a re-join cannot reset.

Also bumps generated recovery PINs from 4 to 6 digits (part of M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-27 15:38:59 +02:00
parent 8faf702208
commit 9364cb624a
8 changed files with 137 additions and 39 deletions

View File

@@ -15,6 +15,7 @@ use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::services::password;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;
@@ -39,13 +40,23 @@ pub async fn join(
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
let join_rate_on = config::get_bool(&state.pool, "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,
));
if rate_limits_on && join_rate_on {
// Short burst cap (typos / double-taps) AND a generous per-IP daily cap
// on account creation. The daily cap bounds mass account-minting (which
// otherwise resets the per-user upload budget, see the event-wide count
// quota in the upload handler) while staying well above a real ~100-guest
// venue sharing one NAT'd IP.
let burst_ok =
state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60));
let daily_ok = state
.rate_limiter
.check(format!("join_day:{ip}"), 200, Duration::from_secs(86_400));
if !burst_ok || !daily_ok {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
let display_name = body.display_name.trim();
@@ -78,10 +89,10 @@ pub async fn join(
)));
}
// 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)))?;
// Generate a 6-digit PIN (≈20 bits vs the old ~13.3, so brute-forcing it
// against the 3-strike lockout is far harder).
let pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = password::hash(pin.clone(), 12).await?;
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
@@ -180,8 +191,8 @@ pub async fn recover(
User::reset_pin_attempts(&state.pool, user.id).await?;
}
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
.unwrap_or(false);
let pin_matches =
password::verify(body.pin.clone(), user.recovery_pin_hash.clone()).await;
if pin_matches {
// Reset failed attempts on success
@@ -271,8 +282,11 @@ pub async fn admin_login(
));
}
let valid = bcrypt::verify(&body.password, &state.config.admin_password_hash)
.unwrap_or(false);
let valid = password::verify(
body.password.clone(),
state.config.admin_password_hash.clone(),
)
.await;
if !valid {
tracing::warn!(ip = %ip, "admin_login: wrong password");
@@ -298,8 +312,7 @@ pub async fn admin_login(
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 dummy_hash = password::hash(dummy_pin, 4).await?;
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)

View File

@@ -282,9 +282,8 @@ pub async fn reset_user_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 pin: String = format!("{:06}", rand::rng().random_range(0..1_000_000u32));
let pin_hash = crate::services::password::hash(pin.clone(), 12).await?;
sqlx::query(
"UPDATE \"user\"

View File

@@ -90,7 +90,7 @@ pub async fn upload(
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await;
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
let mut file_data: Option<Vec<u8>> = None;
let mut file_data: Option<axum::body::Bytes> = None;
let mut caption: Option<String> = None;
let mut hashtags_csv: Option<String> = None;
@@ -100,11 +100,11 @@ pub async fn upload(
"file" => {
// The client-declared filename and Content-Type are deliberately
// ignored — the stored MIME and extension are derived from the
// file's magic bytes (see `classify`).
// file's magic bytes (see `classify`). Kept as `Bytes` (one copy
// off the wire) rather than an extra `.to_vec()`.
file_data = Some(
field.bytes().await
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?
.to_vec(),
.map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))?,
);
}
"caption" => {
@@ -172,6 +172,26 @@ pub async fn upload(
None
};
// Event-wide file-count cap. Unlike the per-user budget, this can't be reset
// by minting a fresh account via /join, so it bounds total uploads no matter
// how many accounts an abuser creates. Wires up the previously-dead
// `upload_count_quota_*` config (default off to preserve existing behavior).
if quota_on && config::get_bool(&state.pool, "upload_count_quota_enabled", false).await {
let max_count = config::get_i64(&state.pool, "upload_count_quota_max", 10_000).await;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(auth.event_id)
.fetch_one(&state.pool)
.await?;
if count >= max_count {
return Err(AppError::TooManyRequests(
"Das Upload-Limit für dieses Event ist erreicht.".into(),
None,
));
}
}
// Reserve quota and insert the row in a single transaction so the byte
// counter and the upload row are atomic (M6: no quota leak if we crash
// between them) and the reservation is a conditional UPDATE that row-locks
@@ -222,7 +242,7 @@ pub async fn upload(
return Err(AppError::Internal(e.into()));
}
}
if let Err(e) = tokio::fs::write(&absolute_path, &data).await {
if let Err(e) = tokio::fs::write(&absolute_path, data.as_ref()).await {
tx.rollback().await.ok();
return Err(AppError::Internal(e.into()));
}

View File

@@ -57,9 +57,11 @@ async fn main() -> Result<()> {
.route("/api/v1/recover", post(auth::handlers::recover))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// Upload — body limit disabled; size validation is done inside the handler
// Upload — cap the request body so a single request can't buffer the box
// into OOM. Sized to the largest allowed video (500 MB) plus multipart
// overhead; the per-category limit is still enforced inside the handler.
.route("/api/v1/upload", post(handlers::upload::upload)
.route_layer(DefaultBodyLimit::disable()))
.route_layer(DefaultBodyLimit::max(550 * 1024 * 1024)))
.route(
"/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),

View File

@@ -11,16 +11,27 @@ use crate::state::SseEvent;
#[derive(Clone)]
pub struct CompressionWorker {
semaphore: Arc<Semaphore>,
/// Separate permit pools for images and videos so a couple of slow/large
/// videos (each holding a permit across the full ffmpeg wait) can never
/// starve image-preview generation, and vice versa.
image_sem: Arc<Semaphore>,
video_sem: Arc<Semaphore>,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
}
impl CompressionWorker {
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
pub fn new(
pool: PgPool,
media_path: PathBuf,
image_concurrency: usize,
video_concurrency: usize,
sse_tx: broadcast::Sender<SseEvent>,
) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(concurrency)),
image_sem: Arc::new(Semaphore::new(image_concurrency)),
video_sem: Arc::new(Semaphore::new(video_concurrency)),
pool,
media_path,
sse_tx,
@@ -31,7 +42,9 @@ impl CompressionWorker {
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone();
tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await;
let is_video = mime_type.starts_with("video/");
let sem = if is_video { &worker.video_sem } else { &worker.image_sem };
let _permit = sem.acquire().await;
match worker.do_process(upload_id, &original_path, &mime_type).await {
Ok(_) => {
tracing::info!("compression completed for upload {upload_id}");
@@ -91,10 +104,25 @@ impl CompressionWorker {
let preview_path_clone = preview_path.clone();
let mime_owned = mime_type.to_string();
// Run blocking image operations in a spawn_blocking task
tokio::task::spawn_blocking(move || -> Result<()> {
let img = image::open(&original)
.context("failed to open image")?;
// Run blocking image operations in a spawn_blocking task, bounded by a
// hard timeout (mirrors the ffmpeg guard) so a pathological decode can't
// hold the permit forever.
let handle = tokio::task::spawn_blocking(move || -> Result<()> {
use image::ImageReader;
// Reject decompression bombs *before* fully decoding: a small file can
// otherwise expand to enormous dimensions and a very expensive resize.
// 12000×12000 covers any real phone photo; max_alloc caps memory.
let mut reader = ImageReader::open(&original)
.context("failed to open image")?
.with_guessed_format()
.context("failed to read image header")?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(12_000);
limits.max_image_height = Some(12_000);
limits.max_alloc = Some(256 * 1024 * 1024);
reader.limits(limits);
let img = reader.decode().context("failed to decode image")?;
// Resize to max 800px wide, preserving aspect ratio
let preview = img.resize(800, 800, image::imageops::FilterType::Lanczos3);
@@ -115,8 +143,12 @@ impl CompressionWorker {
}
Ok(())
})
.await??;
});
match tokio::time::timeout(std::time::Duration::from_secs(120), handle).await {
Ok(join) => join.context("image task panicked")??,
Err(_) => anyhow::bail!("image processing timeout after 120s"),
}
Ok(format!("previews/{preview_filename}"))
}

View File

@@ -5,5 +5,6 @@ pub mod jobs;
pub mod maintenance;
pub mod media_fs;
pub mod media_token;
pub mod password;
pub mod rate_limiter;
pub mod sse_tickets;

View File

@@ -0,0 +1,25 @@
//! bcrypt hashing/verification offloaded to the blocking pool.
//!
//! bcrypt cost-12 is ~250400ms of synchronous CPU. Called directly in an async
//! handler it pins a Tokio worker for that whole time, so a handful of
//! concurrent joins/recovers/admin-logins can starve every other request
//! (feed, SSE, upload). Routing the work through `spawn_blocking` keeps the
//! async reactor responsive.
use crate::error::AppError;
/// Hash a secret with the given bcrypt cost, off the async reactor.
pub async fn hash(plain: String, cost: u32) -> Result<String, AppError> {
tokio::task::spawn_blocking(move || bcrypt::hash(&plain, cost))
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}
/// Verify a secret against a bcrypt hash, off the async reactor. Returns `false`
/// on any error (mirrors the previous `unwrap_or(false)` fail-closed behavior).
pub async fn verify(plain: String, hash: String) -> bool {
tokio::task::spawn_blocking(move || bcrypt::verify(&plain, &hash).unwrap_or(false))
.await
.unwrap_or(false)
}

View File

@@ -36,8 +36,14 @@ pub struct AppState {
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256);
let compression =
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone());
// Independent image (2) and video (2) permit pools — see CompressionWorker.
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),
2,
2,
sse_tx.clone(),
);
Self {
pool,
config,