mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
use hyper::{Response, StatusCode};
use hyper::body::Bytes;
use mini_serve::{handler, body, CorsConfigBuilder, RouteBuilder, ServeError};

/// A real cross-origin `POST` with a JSON body is never a "simple request"
/// (RFC-fetch's safelisted request headers cover only three exact
/// `Content-Type` values, and `application/json` isn't one of them), so the
/// browser always preflights it first. If the preflight response doesn't
/// echo back that the requested header is allowed, the browser blocks the
/// real request without ever sending it — the whole reason CORS support
/// exists here is defeated for every JSON API this serves.
#[tokio::test]
async fn cors_preflight_allows_the_requested_headers_and_method() {
	let app = RouteBuilder::stateless()
		.with_cors(
			CorsConfigBuilder::default()
				.allow_origin("https://example.com")
				.build()
				.unwrap(),
		)
		.post("/api/ingest", handler(|_, _| async {
			Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
		}))
		.seal();

	let port = app.bind_ephemeral().await.unwrap();

	let resp = reqwest::Client::new()
		.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{}/api/ingest", port))
		.header("origin", "https://example.com")
		.header("access-control-request-method", "POST")
		.header("access-control-request-headers", "content-type")
		.send()
		.await
		.unwrap();

	assert_eq!(resp.status(), StatusCode::NO_CONTENT);
	assert_eq!(
		resp.headers().get("access-control-allow-headers").map(|v| v.to_str().unwrap()),
		Some("content-type"),
		"preflight must allow the header the real request will send, or the browser blocks it"
	);
	let allow_methods = resp
		.headers()
		.get("access-control-allow-methods")
		.map(|v| v.to_str().unwrap().to_string())
		.unwrap_or_default();
	assert!(
		allow_methods.contains("POST"),
		"preflight must list POST as an allowed method for this route, got: {allow_methods:?}"
	);
}

#[tokio::test]
async fn cors_preflight_only_for_registered_routes() {
	let app = RouteBuilder::stateless()
		.with_cors(
			CorsConfigBuilder::default()
				.allow_origin("https://example.com")
				.build()
				.unwrap(),
		)
		.get("/api/users", handler(|_, _| async {
			Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
		}))
		.seal();

	let port = app.bind_ephemeral().await.unwrap();

	// Preflight to existing route should return 204
	let existing_resp = reqwest::Client::new()
		.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{}/api/users", port))
		.header("origin", "https://example.com")
		.send()
		.await
		.unwrap();
	assert_eq!(
		existing_resp.status(),
		StatusCode::NO_CONTENT,
		"preflight for existing route should return 204"
	);

	// Preflight to non-existent route should return 404
	let nonexistent_resp = reqwest::Client::new()
		.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{}/api/nonexistent", port))
		.header("origin", "https://example.com")
		.send()
		.await
		.unwrap();
	assert_eq!(
		nonexistent_resp.status(),
		StatusCode::NOT_FOUND,
		"preflight for non-existent route should return 404"
	);
}

/// CORS headers were applied in exactly one of six response paths — the branch where a
/// handler returned `Ok`. So a cross-origin request that 404'd, 405'd, or errored came
/// back without them, and the browser reported an opaque CORS failure instead of the
/// real status: a client could not distinguish "your token expired" from "this API is
/// unreachable". Every response now carries them.
#[tokio::test]
async fn error_responses_carry_cors_headers_too() {
	let app = RouteBuilder::stateless()
		.with_cors(
			CorsConfigBuilder::default()
				.allow_origin("https://example.com")
				.build()
				.unwrap(),
		)
		.get("/exists", handler(|_, _| async {
			Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
		}))
		.get("/boom", handler(|_, _| async {
			Err::<Response<mini_serve::ResponseBody>, _>(ServeError::new(500, "internal detail"))
		}))
		.seal();

	let port = app.bind_ephemeral().await.unwrap();
	let client = reqwest::Client::new();

	for (path, expected) in [
		("/exists", StatusCode::OK),
		("/missing", StatusCode::NOT_FOUND),
		("/boom", StatusCode::INTERNAL_SERVER_ERROR),
	] {
		let resp = client
			.get(format!("http://127.0.0.1:{port}{path}"))
			.header("origin", "https://example.com")
			.send()
			.await
			.unwrap();

		assert_eq!(resp.status(), expected, "{path}");
		assert_eq!(
			resp.headers()
				.get("access-control-allow-origin")
				.map(|v| v.to_str().unwrap()),
			Some("https://example.com"),
			"{path} must carry CORS headers or the browser hides its status from the caller"
		);
	}

	// 405 takes a different exit path again: a registered route, wrong method.
	let resp = client
		.post(format!("http://127.0.0.1:{port}/exists"))
		.header("origin", "https://example.com")
		.send()
		.await
		.unwrap();
	assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
	assert!(
		resp.headers().contains_key("access-control-allow-origin"),
		"a 405 must carry CORS headers too"
	);
}

/// A `PATCH` route must appear in the preflight's `Access-Control-Allow-Methods` without
/// CORS being taught about `PATCH` at all.
///
/// The allow-list is built from `allowed_methods_with_head(&path)` — the router's own
/// method map — so this is the end-to-end proof that nothing between the builder and the
/// preflight response carries a fixed set of verbs. It is the test that would have caught
/// `PATCH` being bolted on at the edge rather than added to the router.
#[tokio::test]
async fn a_patch_route_is_advertised_by_the_cors_preflight() {
	let app = RouteBuilder::stateless()
		.with_cors(
			CorsConfigBuilder::default()
				.allow_origin("https://example.com")
				.build()
				.unwrap(),
		)
		.patch("/api/resource", handler(|_, _| async {
			Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
		}))
		.seal();

	let port = app.bind_ephemeral().await.unwrap();
	let resp = reqwest::Client::new()
		.request(reqwest::Method::OPTIONS, format!("http://127.0.0.1:{port}/api/resource"))
		.header("origin", "https://example.com")
		.header("access-control-request-method", "PATCH")
		.send()
		.await
		.unwrap();

	let allowed = resp
		.headers()
		.get("access-control-allow-methods")
		.expect("a preflight must advertise the methods it allows")
		.to_str()
		.unwrap()
		.to_string();
	assert!(
		allowed.contains("PATCH"),
		"the preflight did not advertise PATCH: {allowed}"
	);
}

/// **A request may carry more than one `Origin` header.** The CORS origin used to be read
/// twice — once in `route_with` for `finalize`, once again in `route_inner` for the
/// preflight branch. Two independent reads of an attacker-controlled header are two
/// chances to disagree, and a preflight that authorises one origin while the response
/// reflects another is a header-smuggling primitive: the browser's decision and the
/// server's answer stop describing the same request.
///
/// `HeaderMap::get` returns the *first* value, which both reads happened to agree on.
/// This pins that, so the single capture cannot silently become a `get_all().last()` and
/// so the two exit paths cannot drift apart again. Sent over a raw socket because
/// `reqwest` gives no guarantee about the order it writes repeated headers in.
#[tokio::test]
async fn a_second_origin_header_is_ignored_on_every_exit_path() {
	use tokio::io::{AsyncReadExt, AsyncWriteExt};

	let app = RouteBuilder::stateless()
		.with_cors(
			CorsConfigBuilder::default()
				.allow_origin("https://first.example")
				.allow_origin("https://second.example")
				.build()
				.unwrap(),
		)
		.get("/exists", handler(|_, _| async {
			Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
		}))
		.seal();

	let port = app.bind_ephemeral().await.unwrap();

	// Both origins are allowed by config, so whichever the server picks it will reflect
	// one of them — the test is about *which*, not about the request being refused.
	for (what, request) in [
		(
			"a normal response",
			"GET /exists HTTP/1.1\r\nHost: x\r\nOrigin: https://first.example\r\n\
			 Origin: https://second.example\r\nConnection: close\r\n\r\n",
		),
		(
			"a preflight",
			"OPTIONS /exists HTTP/1.1\r\nHost: x\r\nOrigin: https://first.example\r\n\
			 Origin: https://second.example\r\nAccess-Control-Request-Method: GET\r\n\
			 Connection: close\r\n\r\n",
		),
	] {
		let mut stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
			.await
			.unwrap();
		stream.write_all(request.as_bytes()).await.unwrap();
		let mut buf = Vec::new();
		let _ = tokio::time::timeout(
			std::time::Duration::from_secs(3),
			stream.read_to_end(&mut buf),
		)
		.await;
		let response = String::from_utf8_lossy(&buf).to_lowercase();

		assert!(
			response.contains("access-control-allow-origin: https://first.example"),
			"{what} must reflect the first Origin; got:\n{response}"
		);
		assert!(
			!response.contains("access-control-allow-origin: https://second.example"),
			"{what} reflected the second Origin — the two reads have drifted apart:\n{response}"
		);
	}
}