Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions src/web/error.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -12,6 +15,14 @@ struct ErrorTemplate {
flash: Option<FlashMessage>,
}

/// 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),
Expand Down Expand Up @@ -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<BoxBody>,
) -> Result<ServiceResponse<BoxBody>, 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::<ApiErrorInfo>()
.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<anyhow::Error> for WebError {
Expand Down
6 changes: 4 additions & 2 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 41 additions & 3 deletions src/web/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -385,7 +398,7 @@ fn extract_host(url: &str) -> String {

fn rewrite_backend_url(body: &[u8], replacement: &str) -> Result<Vec<u8>, 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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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());
}
}
6 changes: 5 additions & 1 deletion src/web/raid_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ pub fn extract_session_id(req: &HttpRequest) -> Option<String> {
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<u8> {
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,
Expand Down
Loading