diff --git a/src/http/client.rs b/src/http/client.rs index 3e694f69..186ce7a9 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -34,6 +34,61 @@ const HTTP_TIMEOUT: Duration = Duration::from_secs(60); /// Redirect hops to follow before giving up (mirrors the Tor path). const MAX_REDIRECTS: usize = 5; +/// Maximum HTTP response body (in bytes) the wallet will buffer into memory, +/// shared by the clearnet path here and the Tor path in `crate::tor`. Every +/// legitimate response the wallet pulls is small: the relay-pool gist JSON +/// (schema-capped to 64 relays), NIP-05 `.well-known` and by-pubkey JSON, the +/// NIP-11 relay-info document, and the price/fee API JSON are all a few KiB; the +/// largest is a processed avatar PNG, itself capped to 1 MiB at +/// `nostr::nip05::fetch_avatar`. 8 MiB sits comfortably above all of them (and a +/// touch above the 4 MiB websocket frame ceiling), so no legitimate response is +/// ever clipped, while a compromised or malicious authority / gist / relay-info +/// endpoint can no longer stream a multi-GB body to OOM the wallet. +pub(crate) const MAX_HTTP_BODY: usize = 8 << 20; // 8 MiB + +/// Collect an HTTP response body while refusing to buffer more than +/// [`MAX_HTTP_BODY`] bytes. First rejects early on an oversized `Content-Length` +/// (cheap; catches an honest server without reading the body), then bounds the +/// actual streamed read frame-by-frame so a server that lies about or omits +/// `Content-Length` still cannot exceed the cap. Returns `None` (a clean error, +/// never a panic) when the limit is hit or the stream errors. Shared by both the +/// clearnet and Tor transports so every HTTP consumer is protected. +pub(crate) async fn collect_body_capped(resp: Response) -> Option> +where + B: Body + Unpin, +{ + // Early rejection: an honest oversized Content-Length is refused without + // ever reading the body. + if let Some(len) = resp + .headers() + .get(hyper::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + { + if len > MAX_HTTP_BODY as u64 { + warn!( + "http: response Content-Length {len} exceeds {MAX_HTTP_BODY}-byte cap, rejecting" + ); + return None; + } + } + // Bounded streamed read: catches a lying or absent Content-Length by + // aborting the moment the accumulated body would exceed the cap. + let mut body = resp.into_body(); + let mut out: Vec = Vec::new(); + while let Some(frame) = body.frame().await { + let frame = frame.ok()?; + if let Ok(data) = frame.into_data() { + if out.len() + data.len() > MAX_HTTP_BODY { + warn!("http: response body exceeded {MAX_HTTP_BODY}-byte cap mid-stream, aborting"); + return None; + } + out.extend_from_slice(&data); + } + } + Some(out) +} + /// Handles http requests. pub struct HttpClient {} @@ -188,6 +243,42 @@ async fn clearnet_once( } else { None }; - let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec(); + let bytes = collect_body_capped(resp).await?; Some((status, bytes, location)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A body comfortably under the cap collects intact. + #[tokio::test] + async fn under_cap_passes() { + let payload = vec![b'a'; 1024]; + let resp = Response::new(Full::new(Bytes::from(payload.clone()))); + let got = collect_body_capped(resp).await; + assert_eq!(got, Some(payload)); + } + + /// A body over the cap with no `Content-Length` is aborted mid-stream and + /// returns a clean `None` (no panic). + #[tokio::test] + async fn over_cap_streamed_rejected() { + let payload = vec![b'b'; MAX_HTTP_BODY + 1]; + let resp = Response::new(Full::new(Bytes::from(payload))); + let got = collect_body_capped(resp).await; + assert_eq!(got, None); + } + + /// An honest but oversized `Content-Length` is refused up front, before the + /// body is read. + #[tokio::test] + async fn over_cap_content_length_rejected() { + let resp = Response::builder() + .header(hyper::header::CONTENT_LENGTH, (MAX_HTTP_BODY as u64) + 1) + .body(Full::new(Bytes::from_static(b"tiny"))) + .unwrap(); + let got = collect_body_capped(resp).await; + assert_eq!(got, None); + } +} diff --git a/src/tor/mod.rs b/src/tor/mod.rs index 1f11a69e..167ada60 100644 --- a/src/tor/mod.rs +++ b/src/tor/mod.rs @@ -42,7 +42,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use bytes::Bytes; -use http_body_util::{BodyExt, Full}; +use http_body_util::Full; use hyper_util::rt::TokioIo; use log::{debug, warn}; use tokio::io::{AsyncRead, AsyncWrite}; @@ -253,7 +253,9 @@ async fn request_once( } else { None }; - let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec(); + // Bound the collected body so a hostile exit / endpoint can't OOM the wallet + // with a multi-GB stream (shared cap with the clearnet path). + let bytes = crate::http::collect_body_capped(resp).await?; Some((status, bytes, location)) }