mini-serve 0.7.0

An HTTP server: trie router, middleware, CORS, optional TLS. Built on hyper + tokio.
Documentation
use std::fmt;
use hyper::{Method, Response};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Empty};

use crate::handler::ResponseBody;

/// Error type for CORS configuration validation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CorsConfigError {
	/// CORS misconfiguration: wildcard origin with credentials enabled.
	///
	/// The combination `allow_all_origins: true` and `credentials: true` is
	/// not allowed — it would grant credentialed access to every origin,
	/// bypassing same-origin policy. Use an explicit origin list instead.
	CredentialedWildcard,
}

impl fmt::Display for CorsConfigError {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		match self {
			CorsConfigError::CredentialedWildcard => write!(
				f,
				"CORS misconfiguration: allow_origin(\"*\") with allow_credentials(true) \
				 is not allowed — it grants credentialed access to every origin. \
				 Use an explicit origin list instead."
			),
		}
	}
}

impl std::error::Error for CorsConfigError {}

/// Cross-Origin Resource Sharing (CORS) configuration for an HTTP server.
///
/// Controls which origins are allowed to make cross-origin requests, whether
/// credentials are included in responses, and generates appropriate CORS headers.
///
/// Instantiated via `CorsConfigBuilder` to ensure the unsafe credentialed-wildcard
/// combination cannot be represented.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CorsConfig {
	/// List of origins allowed to access the server (may contain `"*"`).
	pub allow_origins: Vec<String>,
	/// Whether all origins (`"*"`) are allowed.
	pub allow_all_origins: bool,
	/// Whether credentials (`Authorization`, cookies, etc.) are included in responses.
	pub credentials: bool,
}

impl CorsConfig {
	fn build_cors_headers(&self, req_origin: Option<&str>) -> Vec<(String, String)> {
		let mut headers = Vec::new();

		let origin = if self.allow_all_origins {
			"*"
		} else if let Some(origin) = req_origin {
			if self.allow_origins.iter().any(|o| o == origin) {
				origin
			} else {
				return headers;
			}
		} else {
			return headers;
		};

		headers.push(("access-control-allow-origin".to_string(), origin.to_string()));

		if origin != "*" {
			headers.push(("vary".to_string(), "origin".to_string()));
		}

		if self.credentials {
			headers.push(("access-control-allow-credentials".to_string(), "true".to_string()));
		}

		headers
	}

	/// Build a CORS preflight response (HTTP 204).
	///
	/// Called for `OPTIONS` requests. Returns appropriate CORS headers based on the
	/// request's `Origin` header and this config's allowed origins.
	///
	/// `requested_headers` is the incoming preflight's own
	/// `Access-Control-Request-Headers` value, echoed back verbatim as
	/// `Access-Control-Allow-Headers` — a real cross-origin request is never
	/// a "simple request" once it sets a non-safelisted header (`content-type:
	/// application/json` is the common case; none of the three safelisted
	/// `Content-Type` values is JSON), so the browser always preflights it
	/// first and blocks the real request outright if the preflight doesn't
	/// confirm the header it's about to send is allowed. Echoing back exactly
	/// what was asked grants nothing broader than the caller already
	/// requested. `allowed_methods` becomes `Access-Control-Allow-Methods` —
	/// the caller passes the same per-path method list it already computes
	/// for a plain 405 response, so this never drifts from what the route
	/// actually accepts.
	pub fn preflight_response(
		&self,
		req_origin: Option<&str>,
		requested_headers: Option<&str>,
		allowed_methods: &[Method],
	) -> Response<ResponseBody> {
		let mut headers = self.build_cors_headers(req_origin);

		// Only meaningful once the origin itself was actually granted access
		// above — an empty `headers` here means `build_cors_headers` refused
		// the origin, and the response must stay exactly as bare as before
		// (still 204, so as not to leak whether the path exists to a
		// disallowed origin) rather than gain headers implying access.
		if !headers.is_empty() {
			if let Some(requested) = requested_headers {
				headers.push(("access-control-allow-headers".to_string(), requested.to_string()));
			}
			if !allowed_methods.is_empty() {
				let mut method_strs: Vec<&str> = allowed_methods.iter().map(|m| m.as_str()).collect();
				method_strs.sort();
				method_strs.dedup();
				headers.push(("access-control-allow-methods".to_string(), method_strs.join(", ")));
			}
		}

		let mut resp = Response::builder()
			.status(hyper::StatusCode::NO_CONTENT);

		for (name, value) in headers {
			if let Ok(val) = value.parse::<hyper::header::HeaderValue>() {
				if let Ok(header_name) = name.parse::<hyper::header::HeaderName>() {
					resp = resp.header(header_name, val);
				}
			}
		}

		resp
			.body(BoxBody::new(Empty::new().map_err(|never: std::convert::Infallible| match never {})))
			.expect("status is valid and headers are static ASCII")
	}

	/// Apply CORS headers to a response based on the request's `Origin` header.
	///
	/// Called after a handler completes successfully. Mutates the response to add
	/// appropriate `Access-Control-*` headers.
	pub fn apply_to_response(&self, resp: &mut Response<ResponseBody>, req_origin: Option<&str>) {
		let headers = self.build_cors_headers(req_origin);
		for (name, value) in headers {
			if let Ok(val) = value.parse::<hyper::header::HeaderValue>() {
				if let Ok(header_name) = name.parse::<hyper::header::HeaderName>() {
					resp.headers_mut().insert(header_name, val);
				}
			}
		}
	}
}

/// Builder for constructing a valid `CorsConfig`.
///
/// Ensures that the unsafe credentialed-wildcard combination cannot be built.
/// All origins must be explicitly provided; there is no default.
#[derive(Default, Debug)]
pub struct CorsConfigBuilder {
	allow_origins: Vec<String>,
	credentials: bool,
}

impl CorsConfigBuilder {
	/// Add an allowed origin (e.g., `"https://example.com"` or `"*"`).
	///
	/// Can be called multiple times to add multiple origins.
	pub fn allow_origin(mut self, origin: &str) -> Self {
		self.allow_origins.push(origin.to_string());
		self
	}

	/// Enable or disable credentialed requests (Authorization headers, cookies, etc.).
	///
	/// Defaults to `false`. If set to `true` and a wildcard origin is added,
	/// `build()` will reject the configuration.
	pub fn allow_credentials(mut self, yes: bool) -> Self {
		self.credentials = yes;
		self
	}

	/// Build the CORS configuration, validating that credentials and wildcard are not both enabled.
	pub fn build(self) -> Result<CorsConfig, CorsConfigError> {
		let allow_all_origins = self.allow_origins.len() == 1 && self.allow_origins[0] == "*";

		if self.credentials && allow_all_origins {
			return Err(CorsConfigError::CredentialedWildcard);
		}

		Ok(CorsConfig {
			allow_origins: self.allow_origins,
			allow_all_origins,
			credentials: self.credentials,
		})
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn credentialed_wildcard_rejected_in_debug() {
		let result = CorsConfigBuilder::default()
			.allow_origin("*")
			.allow_credentials(true)
			.build();

		assert_eq!(result, Err(CorsConfigError::CredentialedWildcard));
	}

	#[test]
	fn credentialed_wildcard_rejected_in_release() {
		let result = CorsConfigBuilder::default()
			.allow_origin("*")
			.allow_credentials(true)
			.build();

		assert_eq!(result, Err(CorsConfigError::CredentialedWildcard));
	}

	#[test]
	fn explicit_origin_with_credentials_allowed() {
		let config = CorsConfigBuilder::default()
			.allow_origin("https://example.com")
			.allow_credentials(true)
			.build();

		assert!(config.is_ok());
		let cfg = config.unwrap();
		assert!(!cfg.allow_all_origins);
		assert!(cfg.credentials);
	}

	#[test]
	fn wildcard_without_credentials_allowed() {
		let config = CorsConfigBuilder::default()
			.allow_origin("*")
			.allow_credentials(false)
			.build();

		assert!(config.is_ok());
		let cfg = config.unwrap();
		assert!(cfg.allow_all_origins);
		assert!(!cfg.credentials);
	}
}