From 05c60db6052bc0caa35b489bda06ce94ca78a892 Mon Sep 17 00:00:00 2001 From: M1 Date: Wed, 18 Mar 2026 12:17:55 +0400 Subject: [PATCH] fix: wrap full request+body read in timeout to catch slow response bodies --- apps/monitor/src/runner.rs | 54 +++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index 7a1eba8..8319cc8 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -97,9 +97,38 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op req = req.body(body.clone()); } - let result = req.send().await; + // Wrap the entire request + body read in a single timeout so slow response + // bodies don't slip through after headers arrive within the window. + let timed = tokio::time::timeout(timeout, async { + let resp = req.send().await?; + let status = resp.status(); + let headers: HashMap = resp.headers().iter() + .filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string()))) + .collect(); + + const MAX_BODY_BYTES: usize = 10 * 1024 * 1024; + let body = { + let content_len = resp.content_length().unwrap_or(0) as usize; + if content_len > MAX_BODY_BYTES { + format!("[body truncated: Content-Length {} exceeds 10MB limit]", content_len) + } else { + let bytes = resp.bytes().await?; + let truncated = &bytes[..bytes.len().min(MAX_BODY_BYTES)]; + String::from_utf8_lossy(truncated).into_owned() + } + }; + Ok::<_, reqwest::Error>((status, headers, body)) + }).await; + let latency_ms = start.elapsed().as_millis() as u64; + // Flatten timeout + reqwest errors into a single result + let result = match timed { + Err(_) => Err(format!("timed out after {}ms", timeout.as_millis())), + Ok(Err(e)) => Err(e.to_string()), + Ok(Ok(v)) => Ok(v), + }; + match result { Err(e) => PingResult { monitor_id: monitor.id.clone(), @@ -108,29 +137,12 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op status_code: None, latency_ms: Some(latency_ms), up: false, - error: Some(e.to_string()), + error: Some(e), cert_expiry_days, meta: None, }, - Ok(resp) => { - let status = resp.status().as_u16(); - let headers: HashMap = resp.headers().iter() - .filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string()))) - .collect(); - - // Limit response body to 10MB to prevent OOM from malicious targets - const MAX_BODY_BYTES: usize = 10 * 1024 * 1024; - let body = { - let content_len = resp.content_length().unwrap_or(0) as usize; - if content_len > MAX_BODY_BYTES { - // Skip reading body entirely if Content-Length exceeds limit - format!("[body truncated: Content-Length {} exceeds 10MB limit]", content_len) - } else { - let bytes = resp.bytes().await.unwrap_or_default(); - let truncated = &bytes[..bytes.len().min(MAX_BODY_BYTES)]; - String::from_utf8_lossy(truncated).into_owned() - } - }; + Ok((status_raw, headers, body)) => { + let status = status_raw.as_u16(); // Evaluate query if present let (up, query_error) = if let Some(q) = &monitor.query {