mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
//! The request-body ceiling, over both framings a client can use to overrun it.
//!
//! `body_bytes` is where this crate reads a request body, and the limit it enforces is
//! the only thing standing between a handler and an attacker-chosen allocation. It had no
//! test coverage at all before this file.
//!
//! Exercised through `body_bytes` rather than `json_body` on purpose: the ceiling is a
//! property of reading bytes, not of deserializing them, and testing it through the JSON
//! layer would tie this crate's most load-bearing limit to an optional feature.

use std::time::Duration;

use hyper::StatusCode;
use mini_serve::{body, body_bytes, handler, RouteBuilder};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

/// Small enough to overrun in a test without building megabytes of payload, and far
/// enough below `DEFAULT_MAX_BODY_SIZE` that a regression to the default is visible
/// rather than accidentally passing.
const LIMIT: usize = 256;

/// An app whose single route echoes back the length it managed to read.
async fn echo_length_app() -> u16 {
	let app = RouteBuilder::stateless()
		.with_max_body_size(LIMIT)
		.post(
			"/ingest",
			handler(|req, _state| async move {
				let bytes = body_bytes(req).await?;
				let mut resp = hyper::Response::new(body(
					format!("len={}", bytes.len()).into(),
				));
				*resp.status_mut() = StatusCode::OK;
				Ok(resp)
			}),
		)
		.seal();

	app.bind_ephemeral().await.unwrap()
}

#[tokio::test]
async fn a_body_within_the_limit_is_read() {
	let port = echo_length_app().await;
	let text = "a".repeat(32);

	let response = reqwest::Client::new()
		.post(format!("http://127.0.0.1:{port}/ingest"))
		.body(text)
		.send()
		.await
		.unwrap();

	assert_eq!(response.status(), StatusCode::OK);
	let body = response.text().await.unwrap();
	assert_eq!(body, "len=32", "the handler should see the whole body");
}

/// The cheap rejection: an honest `Content-Length` over the limit is refused before a
/// single body byte is read.
#[tokio::test]
async fn an_oversized_content_length_is_rejected() {
	let port = echo_length_app().await;
	let text = "a".repeat(LIMIT * 4);

	let response = reqwest::Client::new()
		.post(format!("http://127.0.0.1:{port}/ingest"))
		.json(&serde_json::json!({ "text": text }))
		.send()
		.await
		.unwrap();

	assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}

/// What the `Content-Length` pre-check actually buys, and the only way to observe it.
///
/// Both guards return 413 for an oversized declared length, so status alone cannot tell
/// them apart — deleting the pre-check leaves every other test in this file green. The
/// difference is *when*: the pre-check answers from the headers alone, so a client is
/// told to stop before uploading a byte. Without it the server sits waiting for a body
/// that is never coming.
#[tokio::test]
async fn an_oversized_content_length_is_refused_before_the_body_is_sent() {
	let port = echo_length_app().await;
	let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();

	// Headers only. The declared body is never sent.
	stream
		.write_all(
			format!(
				"POST /ingest HTTP/1.1\r\n\
				 Host: localhost\r\n\
				 Content-Type: application/json\r\n\
				 Content-Length: {}\r\n\r\n",
				LIMIT * 4
			)
			.as_bytes(),
		)
		.await
		.unwrap();

	let mut buf = vec![0u8; 1024];
	let read = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf))
		.await
		.expect("the server waited for a body instead of refusing on the header alone");

	let n = read.unwrap();
	let response = String::from_utf8_lossy(&buf[..n]);
	assert!(
		response.starts_with("HTTP/1.1 413"),
		"got: {}",
		response.lines().next().unwrap_or("<nothing>")
	);
}

/// The rejection that actually matters. A chunked request carries no `Content-Length`
/// at all, so the header check above cannot fire and the read itself has to be bounded.
/// A server that only checked the header would happily buffer this to exhaustion.
#[tokio::test]
async fn a_chunked_body_that_overruns_the_limit_is_rejected() {
	let port = echo_length_app().await;
	let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();

	stream
		.write_all(
			b"POST /ingest HTTP/1.1\r\n\
			  Host: localhost\r\n\
			  Content-Type: application/json\r\n\
			  Transfer-Encoding: chunked\r\n\r\n",
		)
		.await
		.unwrap();

	// Each chunk is well under the limit; their sum is far over it. Sent as separate
	// writes so the server is genuinely streaming, not handed one oversized buffer.
	let chunk = "a".repeat(64);
	for _ in 0..16 {
		let framed = format!("{:x}\r\n{}\r\n", chunk.len(), chunk);
		// A closed connection here means the server rejected mid-stream, which is the
		// behaviour under test — not a reason to fail on a broken pipe.
		if stream.write_all(framed.as_bytes()).await.is_err() {
			break;
		}
	}
	let _ = stream.write_all(b"0\r\n\r\n").await;

	let mut response = Vec::new();
	stream.read_to_end(&mut response).await.unwrap();
	let response = String::from_utf8_lossy(&response);

	assert!(
		response.starts_with("HTTP/1.1 413"),
		"an unbounded chunked body must be refused, got: {}",
		response.lines().next().unwrap_or("<nothing>")
	);
}

/// A `Content-Length` that lies about a chunked-sized body must not buy the sender
/// anything: the limit is enforced on bytes read, not on bytes claimed.
#[tokio::test]
async fn a_understated_content_length_does_not_bypass_the_limit() {
	let port = echo_length_app().await;
	let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();

	let body = format!("{{\"text\":\"{}\"}}", "a".repeat(LIMIT * 4));
	stream
		.write_all(
			format!(
				"POST /ingest HTTP/1.1\r\n\
				 Host: localhost\r\n\
				 Content-Type: application/json\r\n\
				 Content-Length: {}\r\n\r\n",
				LIMIT / 2
			)
			.as_bytes(),
		)
		.await
		.unwrap();
	let _ = stream.write_all(body.as_bytes()).await;

	let mut response = Vec::new();
	let _ = stream.read_to_end(&mut response).await;
	let response = String::from_utf8_lossy(&response);
	let status = response.lines().next().unwrap_or("<nothing>");

	// The guarantee is about *bytes reaching the handler*, and it must be asserted that
	// way. This previously demanded a 4xx, which it got — because hyper truncates at the
	// declared length and the truncated prefix was not valid JSON, so the JSON parser
	// failed and stood in for the limit. Move the ceiling off the JSON path, as
	// `body_bytes` does, and that assertion collapses: the request is now served a 200,
	// correctly, because the sender's lie bought it nothing.
	// Leading digits after the *first* `len=`: the sender's surplus bytes are parsed by
	// hyper as a second, malformed request, so the stream holds this response followed by
	// a 400 for the garbage. Reading from the end picks up that second response.
	let served_len: usize = response
		.split_once("len=")
		.map(|(_, rest)| rest.chars().take_while(char::is_ascii_digit).collect::<String>())
		.and_then(|digits| digits.parse().ok())
		.unwrap_or(usize::MAX);
	assert!(
		status.starts_with("HTTP/1.1 4") || served_len <= LIMIT,
		"an understated Content-Length let {served_len} bytes past a {LIMIT}-byte \
		 ceiling: {status}"
	);
}

/// The limit is per-app configuration, not a constant: a route that opts into a larger
/// ceiling gets it. Without this, a test suite passes just as well against a hardcoded
/// limit that ignores `with_max_body_size` entirely.
#[tokio::test]
async fn the_configured_limit_is_the_one_enforced() {
	let generous = LIMIT * 8;
	let app = RouteBuilder::stateless()
		.with_max_body_size(generous)
		.post(
			"/ingest",
			handler(|req, _state| async move {
				let bytes = body_bytes(req).await?;
				let mut resp = hyper::Response::new(body(
					format!("len={}", bytes.len()).into(),
				));
				*resp.status_mut() = StatusCode::OK;
				Ok(resp)
			}),
		)
		.seal();
	let port = app.bind_ephemeral().await.unwrap();

	// Over the LIMIT used by the other tests, comfortably under this app's own.
	let text = "a".repeat(LIMIT * 2);
	let response = reqwest::Client::new()
		.post(format!("http://127.0.0.1:{port}/ingest"))
		.json(&serde_json::json!({ "text": text }))
		.send()
		.await
		.unwrap();

	assert_eq!(
		response.status(),
		StatusCode::OK,
		"a body under the configured ceiling must be served"
	);
}