Skip to main content

mini_serve/
cors.rs

1use std::fmt;
2use hyper::{Method, Response};
3use http_body_util::combinators::BoxBody;
4use http_body_util::{BodyExt, Empty};
5
6use crate::handler::ResponseBody;
7
8/// Error type for CORS configuration validation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CorsConfigError {
11	/// CORS misconfiguration: wildcard origin with credentials enabled.
12	///
13	/// The combination `allow_all_origins: true` and `credentials: true` is
14	/// not allowed — it would grant credentialed access to every origin,
15	/// bypassing same-origin policy. Use an explicit origin list instead.
16	CredentialedWildcard,
17}
18
19impl fmt::Display for CorsConfigError {
20	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21		match self {
22			CorsConfigError::CredentialedWildcard => write!(
23				f,
24				"CORS misconfiguration: allow_origin(\"*\") with allow_credentials(true) \
25				 is not allowed — it grants credentialed access to every origin. \
26				 Use an explicit origin list instead."
27			),
28		}
29	}
30}
31
32impl std::error::Error for CorsConfigError {}
33
34/// Cross-Origin Resource Sharing (CORS) configuration for an HTTP server.
35///
36/// Controls which origins are allowed to make cross-origin requests, whether
37/// credentials are included in responses, and generates appropriate CORS headers.
38///
39/// Instantiated via `CorsConfigBuilder` to ensure the unsafe credentialed-wildcard
40/// combination cannot be represented.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct CorsConfig {
43	/// List of origins allowed to access the server (may contain `"*"`).
44	pub allow_origins: Vec<String>,
45	/// Whether all origins (`"*"`) are allowed.
46	pub allow_all_origins: bool,
47	/// Whether credentials (`Authorization`, cookies, etc.) are included in responses.
48	pub credentials: bool,
49}
50
51impl CorsConfig {
52	fn build_cors_headers(&self, req_origin: Option<&str>) -> Vec<(String, String)> {
53		let mut headers = Vec::new();
54
55		let origin = if self.allow_all_origins {
56			"*"
57		} else if let Some(origin) = req_origin {
58			if self.allow_origins.iter().any(|o| o == origin) {
59				origin
60			} else {
61				return headers;
62			}
63		} else {
64			return headers;
65		};
66
67		headers.push(("access-control-allow-origin".to_string(), origin.to_string()));
68
69		if origin != "*" {
70			headers.push(("vary".to_string(), "origin".to_string()));
71		}
72
73		if self.credentials {
74			headers.push(("access-control-allow-credentials".to_string(), "true".to_string()));
75		}
76
77		headers
78	}
79
80	/// Build a CORS preflight response (HTTP 204).
81	///
82	/// Called for `OPTIONS` requests. Returns appropriate CORS headers based on the
83	/// request's `Origin` header and this config's allowed origins.
84	///
85	/// `requested_headers` is the incoming preflight's own
86	/// `Access-Control-Request-Headers` value, echoed back verbatim as
87	/// `Access-Control-Allow-Headers` — a real cross-origin request is never
88	/// a "simple request" once it sets a non-safelisted header (`content-type:
89	/// application/json` is the common case; none of the three safelisted
90	/// `Content-Type` values is JSON), so the browser always preflights it
91	/// first and blocks the real request outright if the preflight doesn't
92	/// confirm the header it's about to send is allowed. Echoing back exactly
93	/// what was asked grants nothing broader than the caller already
94	/// requested. `allowed_methods` becomes `Access-Control-Allow-Methods` —
95	/// the caller passes the same per-path method list it already computes
96	/// for a plain 405 response, so this never drifts from what the route
97	/// actually accepts.
98	pub fn preflight_response(
99		&self,
100		req_origin: Option<&str>,
101		requested_headers: Option<&str>,
102		allowed_methods: &[Method],
103	) -> Response<ResponseBody> {
104		let mut headers = self.build_cors_headers(req_origin);
105
106		// Only meaningful once the origin itself was actually granted access
107		// above — an empty `headers` here means `build_cors_headers` refused
108		// the origin, and the response must stay exactly as bare as before
109		// (still 204, so as not to leak whether the path exists to a
110		// disallowed origin) rather than gain headers implying access.
111		if !headers.is_empty() {
112			if let Some(requested) = requested_headers {
113				headers.push(("access-control-allow-headers".to_string(), requested.to_string()));
114			}
115			if !allowed_methods.is_empty() {
116				let mut method_strs: Vec<&str> = allowed_methods.iter().map(|m| m.as_str()).collect();
117				method_strs.sort();
118				method_strs.dedup();
119				headers.push(("access-control-allow-methods".to_string(), method_strs.join(", ")));
120			}
121		}
122
123		let mut resp = Response::builder()
124			.status(hyper::StatusCode::NO_CONTENT);
125
126		for (name, value) in headers {
127			if let Ok(val) = value.parse::<hyper::header::HeaderValue>() {
128				if let Ok(header_name) = name.parse::<hyper::header::HeaderName>() {
129					resp = resp.header(header_name, val);
130				}
131			}
132		}
133
134		resp
135			.body(BoxBody::new(Empty::new().map_err(|never: std::convert::Infallible| match never {})))
136			.expect("status is valid and headers are static ASCII")
137	}
138
139	/// Apply CORS headers to a response based on the request's `Origin` header.
140	///
141	/// Called after a handler completes successfully. Mutates the response to add
142	/// appropriate `Access-Control-*` headers.
143	pub fn apply_to_response(&self, resp: &mut Response<ResponseBody>, req_origin: Option<&str>) {
144		let headers = self.build_cors_headers(req_origin);
145		for (name, value) in headers {
146			if let Ok(val) = value.parse::<hyper::header::HeaderValue>() {
147				if let Ok(header_name) = name.parse::<hyper::header::HeaderName>() {
148					resp.headers_mut().insert(header_name, val);
149				}
150			}
151		}
152	}
153}
154
155/// Builder for constructing a valid `CorsConfig`.
156///
157/// Ensures that the unsafe credentialed-wildcard combination cannot be built.
158/// All origins must be explicitly provided; there is no default.
159#[derive(Default, Debug)]
160pub struct CorsConfigBuilder {
161	allow_origins: Vec<String>,
162	credentials: bool,
163}
164
165impl CorsConfigBuilder {
166	/// Add an allowed origin (e.g., `"https://example.com"` or `"*"`).
167	///
168	/// Can be called multiple times to add multiple origins.
169	pub fn allow_origin(mut self, origin: &str) -> Self {
170		self.allow_origins.push(origin.to_string());
171		self
172	}
173
174	/// Enable or disable credentialed requests (Authorization headers, cookies, etc.).
175	///
176	/// Defaults to `false`. If set to `true` and a wildcard origin is added,
177	/// `build()` will reject the configuration.
178	pub fn allow_credentials(mut self, yes: bool) -> Self {
179		self.credentials = yes;
180		self
181	}
182
183	/// Build the CORS configuration, validating that credentials and wildcard are not both enabled.
184	pub fn build(self) -> Result<CorsConfig, CorsConfigError> {
185		let allow_all_origins = self.allow_origins.len() == 1 && self.allow_origins[0] == "*";
186
187		if self.credentials && allow_all_origins {
188			return Err(CorsConfigError::CredentialedWildcard);
189		}
190
191		Ok(CorsConfig {
192			allow_origins: self.allow_origins,
193			allow_all_origins,
194			credentials: self.credentials,
195		})
196	}
197}
198
199#[cfg(test)]
200#[path = "../tests/unit/cors.rs"]
201mod tests;