mini-serve 0.13.12

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
//! Security headers, applied through the single response funnel.

use hyper::body::Bytes;
use hyper::{Response, StatusCode};
use mini_serve::{body, handler, RouteBuilder, ServeError};

/// A policy header present on some statuses and missing from others is worse than none:
/// the error paths are the ones an attacker is probing. Every exit path is checked
/// because each is a separate `return` in `route_inner` — a funnel is only worth having
/// if nothing routes around it.
#[tokio::test]
async fn nosniff_is_sent_on_every_response_shape() {
	let app = RouteBuilder::stateless()
		.get(
			"/exists",
			handler(|_, _| async {
				Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
			}),
		)
		.get(
			"/boom",
			handler(|_, _| async {
				Err::<Response<mini_serve::ResponseBody>, _>(ServeError::new(500, "detail"))
			}),
		)
		.seal();

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

	// 200, 500, 404 — three distinct exits.
	for path in ["/exists", "/boom", "/missing"] {
		let resp = client
			.get(format!("http://127.0.0.1:{port}{path}"))
			.send()
			.await
			.unwrap();
		assert_eq!(
			resp.headers()
				.get("x-content-type-options")
				.map(|v| v.to_str().unwrap()),
			Some("nosniff"),
			"{path} is missing nosniff"
		);
	}

	// 405 — a registered route, wrong method.
	let resp = client
		.post(format!("http://127.0.0.1:{port}/exists"))
		.send()
		.await
		.unwrap();
	assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
	assert!(resp.headers().contains_key("x-content-type-options"));

	// 400 — the oversized-path guard, which returns before routing.
	let long = "/".to_string() + &"a".repeat(9000);
	let resp = client
		.get(format!("http://127.0.0.1:{port}{long}"))
		.send()
		.await
		.unwrap();
	assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
	assert!(resp.headers().contains_key("x-content-type-options"));
}

#[tokio::test]
async fn configured_headers_are_sent_on_every_response() {
	let app = RouteBuilder::stateless()
		.with_response_header("Strict-Transport-Security", "max-age=63072000")
		.unwrap()
		.with_response_header("Referrer-Policy", "no-referrer")
		.unwrap()
		.get(
			"/exists",
			handler(|_, _| async {
				Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
			}),
		)
		.seal();

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

	for path in ["/exists", "/missing"] {
		let resp = client
			.get(format!("http://127.0.0.1:{port}{path}"))
			.send()
			.await
			.unwrap();
		assert_eq!(
			resp.headers()
				.get("strict-transport-security")
				.map(|v| v.to_str().unwrap()),
			Some("max-age=63072000"),
			"{path}"
		);
		assert_eq!(
			resp.headers()
				.get("referrer-policy")
				.map(|v| v.to_str().unwrap()),
			Some("no-referrer"),
			"{path}"
		);
	}
}

/// Apply-if-absent: a handler setting the header itself wins. Handlers here are
/// arbitrary user code that may legitimately vary a policy per route, unlike
/// `mini-static` where the server computes every header and a fixed value would be
/// fighting a computed one.
#[tokio::test]
async fn a_handler_set_header_is_not_clobbered() {
	let app = RouteBuilder::stateless()
		.with_response_header("Content-Security-Policy", "default-src 'self'")
		.unwrap()
		.get(
			"/relaxed",
			handler(|_, _| async {
				let mut resp = Response::new(body(Bytes::from("ok")));
				resp.headers_mut().insert(
					"content-security-policy",
					"default-src *".parse().unwrap(),
				);
				Ok::<_, ServeError>(resp)
			}),
		)
		.seal();

	let port = app.bind_ephemeral().await.unwrap();
	let resp = reqwest::get(format!("http://127.0.0.1:{port}/relaxed"))
		.await
		.unwrap();

	assert_eq!(
		resp.headers()
			.get("content-security-policy")
			.map(|v| v.to_str().unwrap()),
		Some("default-src *"),
		"the route's own policy must survive the app-wide default"
	);
}

#[tokio::test]
async fn nothing_extra_is_sent_without_configuration() {
	let app = RouteBuilder::stateless()
		.get(
			"/exists",
			handler(|_, _| async {
				Ok::<_, ServeError>(Response::new(body(Bytes::from("ok"))))
			}),
		)
		.seal();

	let port = app.bind_ephemeral().await.unwrap();
	let resp = reqwest::get(format!("http://127.0.0.1:{port}/exists"))
		.await
		.unwrap();

	assert!(!resp.headers().contains_key("strict-transport-security"));
}

/// Malformed input fails while the app is being built, not on a request months later.
#[test]
fn invalid_names_and_values_are_rejected_at_configuration_time() {
	assert!(RouteBuilder::stateless()
		.with_response_header("Not A Header", "x")
		.is_err());
	assert!(RouteBuilder::stateless()
		.with_response_header("X-Fine", "bad\nvalue")
		.is_err());
}

/// A fixed `Content-Length` would contradict the body hyper is about to write; the
/// other two describe framing this crate does not choose.
#[test]
fn connection_owned_headers_are_refused() {
	for name in ["Content-Length", "connection", "Transfer-Encoding"] {
		assert!(
			RouteBuilder::stateless()
				.with_response_header(name, "1")
				.is_err(),
			"{name} should be refused"
		);
	}
}