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:
@@ -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}"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user