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:
+92
-1
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user