mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
//! Requests a server is expected to survive, sent over raw sockets.
//!
//! Most of these are rejected by `hyper` (1.11.0 at the time of writing) rather than by
//! this crate: HTTP/1 framing and its smuggling defences live at that layer, and
//! re-implementing them here would add risk rather than remove it. Each test names where
//! its defence actually lives, so this file informs rather than implies. What it proves
//! is the behaviour of *the stack a user deploys*, which is what a user cares about.
//!
//! **Disposition if one of these fails.** A failure that traces to hyper is reported
//! upstream and the version pinned in the test's doc comment. It is never fixed by
//! relaxing the assertion until it passes: a suite weakened until green is worse than no
//! suite, because it still looks like evidence.
//!
//! `reqwest` cannot send most of these — it normalises paths and refuses to build
//! conflicting framing headers — so everything here is written to the socket verbatim.

use std::sync::{Arc, Mutex};
use std::time::Duration;

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

/// Long enough that a server which is going to answer has answered, short enough that a
/// hung connection fails the test rather than the suite.
const REPLY_DEADLINE: Duration = Duration::from_secs(3);

async fn serve_health() -> u16 {
	let app = RouteBuilder::stateless()
		.get(
			"/health",
			handler(|_req, _state| async {
				json(StatusCode::OK, &serde_json::json!({"ok": true}))
			}),
		)
		.seal();
	app.bind_ephemeral().await.unwrap()
}

/// Send `raw` verbatim and return whatever the server says, or `""` if it just closed.
async fn send_raw(port: u16, raw: &[u8]) -> String {
	let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
	// A server that rejects mid-write is behaving correctly, so a broken pipe here is a
	// pass condition rather than a test error.
	let _ = stream.write_all(raw).await;
	let mut buf = Vec::new();
	let _ = tokio::time::timeout(REPLY_DEADLINE, stream.read_to_end(&mut buf)).await;
	String::from_utf8_lossy(&buf).into_owned()
}

fn status_line(response: &str) -> &str {
	response.lines().next().unwrap_or("<connection closed with no response>")
}

/// Assert the server did not serve this as an ordinary request.
fn assert_refused(response: &str, what: &str) {
	let line = status_line(response);
	let refused = response.is_empty()
		|| line.starts_with("HTTP/1.1 4")
		|| line.starts_with("HTTP/1.1 5");
	assert!(
		refused,
		"{what} was served rather than refused: {line}"
	);
}

/// **Request smuggling, the classic primitive.** `Content-Length` and
/// `Transfer-Encoding: chunked` together let two intermediaries disagree about where the
/// request ends, so one sees a second request the other treats as a body. RFC 9112 §6.1
/// requires the message be rejected. Rejected by hyper, not by this crate.
#[tokio::test]
async fn content_length_with_transfer_encoding_is_refused() {
	let port = serve_health().await;
	let response = send_raw(
		port,
		b"POST /health HTTP/1.1\r\n\
		  Host: localhost\r\n\
		  Content-Length: 6\r\n\
		  Transfer-Encoding: chunked\r\n\
		  \r\n\
		  0\r\n\r\n\
		  GET /smuggled HTTP/1.1\r\nHost: localhost\r\n\r\n",
	)
	.await;

	assert_refused(&response, "a request with both Content-Length and Transfer-Encoding");
	assert!(
		!response.contains("smuggled"),
		"the smuggled request was answered: {response}"
	);
}

/// Two conflicting `Content-Length` headers are the same disagreement in a simpler form:
/// whichever value a downstream picks, one of them is wrong. Rejected by hyper.
#[tokio::test]
async fn conflicting_content_lengths_are_refused() {
	let port = serve_health().await;
	let response = send_raw(
		port,
		b"POST /health HTTP/1.1\r\n\
		  Host: localhost\r\n\
		  Content-Length: 6\r\n\
		  Content-Length: 7\r\n\
		  \r\n\
		  hello!!",
	)
	.await;

	assert_refused(&response, "a request with two Content-Length values");
}

/// A chunk-size line that is not valid hex, or that would overflow, must not be treated
/// as a length. Rejected by hyper's chunked decoder.
#[tokio::test]
async fn a_malformed_chunk_size_is_refused() {
	let port = serve_health().await;
	let response = send_raw(
		port,
		b"POST /health HTTP/1.1\r\n\
		  Host: localhost\r\n\
		  Transfer-Encoding: chunked\r\n\
		  \r\n\
		  ffffffffffffffffff\r\nhello\r\n0\r\n\r\n",
	)
	.await;

	assert_refused(&response, "a request with an overflowing chunk size");
}

/// Absolute-form request targets are legal (RFC 9112 §3.2.2) and a server must route on
/// the path, not on the authority a client asserts. This pins that a request claiming
/// `http://evil.example/health` is answered as `/health` on *this* server — the authority
/// does not redirect, proxy, or otherwise leave the process.
#[tokio::test]
async fn an_absolute_form_target_routes_on_its_path() {
	let port = serve_health().await;
	let response = send_raw(
		port,
		b"GET http://evil.example/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
	)
	.await;

	let line = status_line(&response);
	assert!(
		line.starts_with("HTTP/1.1 200") || line.starts_with("HTTP/1.1 4"),
		"an absolute-form target should be served on its path or refused, got: {line}"
	);
	if line.starts_with("HTTP/1.1 200") {
		assert!(
			response.contains(r#""ok":true"#),
			"served, but not by the /health route: {response}"
		);
	}
}

/// **This one is ours.** A request path carrying encoded CR/LF must not break out of the
/// log line it is written into. The path is logged raw and undecoded precisely so a probe
/// appears verbatim, which also means `%0d%0a` stays four characters rather than becoming
/// a newline — a forged second log entry would otherwise be trivial.
#[tokio::test]
async fn an_encoded_crlf_in_the_path_cannot_forge_a_log_line() {
	#[derive(Clone, Default)]
	struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
	impl std::io::Write for SharedBuffer {
		fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
			self.0.lock().unwrap().extend_from_slice(buf);
			Ok(buf.len())
		}
		fn flush(&mut self) -> std::io::Result<()> {
			Ok(())
		}
	}

	let log = SharedBuffer::default();
	let app = RouteBuilder::stateless()
		.with_request_logging_to(Box::new(log.clone()))
		.get(
			"/health",
			handler(|_req, _state| async {
				json(StatusCode::OK, &serde_json::json!({"ok": true}))
			}),
		)
		.seal();
	let port = app.bind_ephemeral().await.unwrap();

	let _ = send_raw(
		port,
		b"GET /health%0d%0aGET%20/forged%20200%200.000ms HTTP/1.1\r\n\
		  Host: localhost\r\nConnection: close\r\n\r\n",
	)
	.await;
	tokio::time::sleep(Duration::from_millis(100)).await;

	let written = String::from_utf8(log.0.lock().unwrap().clone()).unwrap();
	assert!(
		written.contains("%0d%0a"),
		"the raw path should appear verbatim, got: {written:?}"
	);
	assert_eq!(
		written.trim_end().lines().count(),
		1,
		"the request forged extra log lines: {written:?}"
	);
}

/// A header value cannot legally contain a bare CR or LF; `http` rejects it at
/// construction, so this pins that the *stack* refuses rather than passing it through to
/// a response header. Rejected by hyper's parser.
#[tokio::test]
async fn a_bare_lf_in_a_header_value_is_refused() {
	let port = serve_health().await;
	let response = send_raw(
		port,
		b"GET /health HTTP/1.1\r\nHost: localhost\r\nX-Probe: a\nInjected: yes\r\n\r\n",
	)
	.await;

	assert!(
		!response.contains("Injected"),
		"a bare LF in a header value was reflected: {response}"
	);
}
/// HTTP/1.1 requires a `Host` header (RFC 9112 §3.2). Without it a request is ambiguous
/// for any downstream doing name-based routing. Rejected by hyper.
#[tokio::test]
async fn http_1_1_without_host_is_refused() {
	let port = serve_health().await;
	let response = send_raw(port, b"GET /health HTTP/1.1\r\n\r\n").await;

	assert_refused(&response, "an HTTP/1.1 request with no Host header");
}