1
0
forked from GRIN/grim

fix(http): cap collected response body size to prevent OOM DoS

HTTP response bodies were collected with no size limit on both the Tor
path (tor::request_once) and the clearnet path (http::clearnet_once),
each doing into_body().collect().to_vec() unbounded. A compromised or
malicious name-authority, relay-pool gist, NIP-11 relay-info, or price
endpoint could stream a multi-GB body and OOM the wallet. The websocket
path already caps frames at 4 MiB; HTTP did not.

Add a shared collect_body_capped() that rejects an oversized
Content-Length up front and bounds the actual streamed read frame by
frame (catching a lying/absent Content-Length), returning a clean Err
instead of panicking. Cap is 8 MiB: comfortably above every legitimate
response (relay-pool gist, NIP-05/.well-known/by-pubkey JSON, NIP-11
info, price/fee JSON are all a few KiB; the 1 MiB avatar PNG is the
largest) and a touch above the 4 MiB websocket ceiling, so nothing real
is clipped.
This commit is contained in:
2ro
2026-07-10 15:06:57 -04:00
parent 528d088e54
commit d47a71a03b
2 changed files with 96 additions and 3 deletions
+92 -1
View File
@@ -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<B>(resp: Response<B>) -> Option<Vec<u8>>
where
B: Body<Data = Bytes> + 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::<u64>().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<u8> = 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);
}
}
+4 -2
View File
@@ -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))
}