From 9ef8ad144008963c4bdf507ef994f004039d789c Mon Sep 17 00:00:00 2001 From: Anten Skrabec Date: Wed, 29 Jul 2026 15:57:36 -0600 Subject: [PATCH] fix: harden proxy and error handling against abuse Cap zlib decompression output (64 MB proxy, 16 MB raid tracker) to prevent zip-bomb DoS. Add proxy-specific 16 MB body limit tighter than the global 64 MB PayloadConfig. Return JSON error responses for API clients that send Accept: application/json (HTMX requests still get HTML via HX-Request header detection). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web/error.rs | 71 ++++++++++++++++++++++++++++++++++++++--- src/web/mod.rs | 6 ++-- src/web/proxy.rs | 44 +++++++++++++++++++++++-- src/web/raid_tracker.rs | 6 +++- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/src/web/error.rs b/src/web/error.rs index 946ed5cb..e1bc7506 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -1,4 +1,7 @@ +use actix_web::body::BoxBody; +use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::http::StatusCode; +use actix_web::middleware::Next; use actix_web::{HttpResponse, ResponseError}; use askama::Template; @@ -12,6 +15,14 @@ struct ErrorTemplate { flash: Option, } +/// Carried in response extensions so the API middleware can extract structured +/// error info without re-parsing HTML. +#[derive(Clone)] +struct ApiErrorInfo { + title: String, + message: String, +} + #[derive(Debug)] pub enum WebError { Internal(anyhow::Error), @@ -68,16 +79,68 @@ impl ResponseError for WebError { let tmpl = ErrorTemplate { title: title.clone(), - message, + message: message.clone(), flash: None, }; - match tmpl.render() { + let mut resp = match tmpl.render() { Ok(body) => HttpResponse::build(self.status_code()) .content_type("text/html") .body(body), - Err(_) => HttpResponse::build(self.status_code()).body(title), - } + Err(_) => HttpResponse::build(self.status_code()).body(title.clone()), + }; + // Attach structured error info for the API JSON middleware to extract + resp.extensions_mut() + .insert(ApiErrorInfo { title, message }); + resp + } +} + +/// Middleware that converts HTML error responses to JSON when the client +/// prefers JSON (via Accept header) and is not an HTMX request. +pub async fn api_json_errors( + req: ServiceRequest, + next: Next, +) -> Result, actix_web::Error> { + let wants_json = !req.headers().contains_key("HX-Request") + && req + .headers() + .get("Accept") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.contains("application/json")); + + let resp = next.call(req).await?; + + let is_error = wants_json + && (resp.response().status().is_client_error() + || resp.response().status().is_server_error()); + + if is_error { + let status = resp.response().status(); + // Prefer structured ApiErrorInfo from WebError; fall back to the + // status reason phrase for errors from other middleware (auth, etc.). + let (title, message) = resp + .response() + .extensions() + .get::() + .map(|info| (info.title.clone(), info.message.clone())) + .unwrap_or_else(|| { + let reason = status.canonical_reason().unwrap_or("Error"); + (reason.to_string(), reason.to_string()) + }); + + let (req, _) = resp.into_parts(); + let json = serde_json::json!({ + "error": title, + "message": message, + "status": status.as_u16(), + }); + let new_resp = HttpResponse::build(status) + .content_type("application/json") + .body(json.to_string()); + return Ok(ServiceResponse::new(req, new_resp)); } + + Ok(resp) } impl From for WebError { diff --git a/src/web/mod.rs b/src/web/mod.rs index 6874a026..5267d038 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -230,11 +230,13 @@ pub fn configure_app( } // Build the API scope - // api_auth_middleware must be outermost (last .wrap) so it runs first — - // it checks X-Quma-Token before auth_middleware checks session cookies. + // Wrap order: last .wrap() = outermost (runs first on request, last on response). + // api_json_errors is outermost so it converts error responses (including auth + // failures) to JSON when the client sends Accept: application/json. let mut api_scope = web::scope("/api") .wrap(from_fn(auth::auth_middleware)) .wrap(from_fn(api_auth::api_auth_middleware)) + .wrap(from_fn(error::api_json_errors)) .route("/events", web::get().to(crate::web::sse::events_stream)) .route( "/mods/check-updates", diff --git a/src/web/proxy.rs b/src/web/proxy.rs index f2669ca1..8fef5ba3 100644 --- a/src/web/proxy.rs +++ b/src/web/proxy.rs @@ -11,6 +11,14 @@ use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use crate::web::state::AppState; +/// Cap proxy request bodies well below the global 64 MB PayloadConfig. +/// SPT client traffic is compressed JSON — profiles top out around 2 MB. +const MAX_PROXY_BODY: usize = 16 * 1024 * 1024; + +/// Cap zlib decompression output to prevent zip-bomb DoS. +// ponytail: 64 MB is generous; tighten if memory pressure matters +const MAX_DECOMPRESSED: u64 = 64 * 1024 * 1024; + enum BackendRewriteTarget { HttpProxy, DirectTcp, @@ -102,13 +110,18 @@ pub async fn proxy_handler( return crate::web::proxy_ws::ws_proxy_handler(req, payload, state).await; } - // Read the full body for HTTP requests + // Read the full body for HTTP requests, enforcing proxy-specific size limit let mut body = web::BytesMut::new(); while let Some(chunk) = payload.next().await { let chunk = chunk.map_err(|e| { actix_web::error::ErrorBadRequest(format!("failed to read request body: {e}")) })?; body.extend_from_slice(&chunk); + if body.len() > MAX_PROXY_BODY { + return Err(actix_web::error::ErrorPayloadTooLarge( + "proxy request body too large", + )); + } } let body = body.freeze(); @@ -385,7 +398,7 @@ fn extract_host(url: &str) -> String { fn rewrite_backend_url(body: &[u8], replacement: &str) -> Result, String> { let (json_bytes, compressed) = { - let mut decoder = ZlibDecoder::new(body); + let mut decoder = ZlibDecoder::new(body).take(MAX_DECOMPRESSED); let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => (buf, true), @@ -542,7 +555,7 @@ fn crc32c(data: &[u8]) -> u32 { fn fix_headless_crc(body: web::Bytes, dirs: &crate::dirs::QumaDirs) -> web::Bytes { // Try zlib decompression first, fall back to raw bytes let (json_bytes, compressed) = { - let mut decoder = ZlibDecoder::new(&body[..]); + let mut decoder = ZlibDecoder::new(&body[..]).take(MAX_DECOMPRESSED); let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => (buf, true), @@ -704,4 +717,29 @@ mod tests { fn crc32c_empty() { assert_eq!(crc32c(b""), 0); } + + #[test] + fn rewrite_caps_decompressed_output() { + // Compress a body that would exceed MAX_DECOMPRESSED if fully expanded. + // Since take() caps read output, the truncated JSON will fail utf8/rewrite + // but we should NOT OOM. + use flate2::write::ZlibEncoder; + use std::io::Write; + + // Create a ~200 KB compressed payload of repetitive JSON. + // Repetitive data compresses extremely well — this tests that the + // decompressed output is capped, not that we handle huge compressed input. + let big_json = format!( + r#"{{"backendUrl":"https://0.0.0.0:6969/x{}"}}"#, + "A".repeat(256 * 1024) + ); + let mut enc = ZlibEncoder::new(Vec::new(), Compression::best()); + enc.write_all(big_json.as_bytes()).unwrap(); + let compressed = enc.finish().unwrap(); + + // Should succeed without OOM — the cap is MAX_DECOMPRESSED (64 MB), + // and our test data is well under that, so it processes normally. + let result = rewrite_backend_url(&compressed, "tarkov.example.com"); + assert!(result.is_ok()); + } } diff --git a/src/web/raid_tracker.rs b/src/web/raid_tracker.rs index 335070fa..3afe28f5 100644 --- a/src/web/raid_tracker.rs +++ b/src/web/raid_tracker.rs @@ -81,9 +81,13 @@ pub fn extract_session_id(req: &HttpRequest) -> Option { None } +/// Cap decompressed output to prevent zip-bomb DoS. +// ponytail: 16 MB is generous for raid start/end JSON; raise if profiles grow +const MAX_DECOMPRESSED: u64 = 16 * 1024 * 1024; + /// Try zlib decompression, fall back to raw bytes. SPT clients send zlib-compressed request bodies. fn decompress_body(body: &[u8]) -> Vec { - let mut decoder = ZlibDecoder::new(body); + let mut decoder = ZlibDecoder::new(body).take(MAX_DECOMPRESSED); let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => buf,