Skip to main content

mini_serve/
cors.rs

1use std::fmt;
2use hyper::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	pub fn preflight_response(&self, req_origin: Option<&str>) -> Response<ResponseBody> {
85		let headers = self.build_cors_headers(req_origin);
86		let mut resp = Response::builder()
87			.status(hyper::StatusCode::NO_CONTENT);
88
89		for (name, value) in headers {
90			if let Ok(val) = value.parse::<hyper::header::HeaderValue>() {
91				if let Ok(header_name) = name.parse::<hyper::header::HeaderName>() {
92					resp = resp.header(header_name, val);
93				}
94			}
95		}
96
97		resp
98			.body(BoxBody::new(Empty::new().map_err(|never: std::convert::Infallible| match never {})))
99			.expect("status is valid and headers are static ASCII")
100	}
101
102	/// Apply CORS headers to a response based on the request's `Origin` header.
103	///
104	/// Called after a handler completes successfully. Mutates the response to add
105	/// appropriate `Access-Control-*` headers.
106	pub fn apply_to_response(&self, resp: &mut Response<ResponseBody>, req_origin: Option<&str>) {
107		let headers = self.build_cors_headers(req_origin);
108		for (name, value) in headers {
109			if let Ok(val) = value.parse::<hyper::header::HeaderValue>() {
110				if let Ok(header_name) = name.parse::<hyper::header::HeaderName>() {
111					resp.headers_mut().insert(header_name, val);
112				}
113			}
114		}
115	}
116}
117
118/// Builder for constructing a valid `CorsConfig`.
119///
120/// Ensures that the unsafe credentialed-wildcard combination cannot be built.
121/// All origins must be explicitly provided; there is no default.
122#[derive(Default, Debug)]
123pub struct CorsConfigBuilder {
124	allow_origins: Vec<String>,
125	credentials: bool,
126}
127
128impl CorsConfigBuilder {
129	/// Add an allowed origin (e.g., `"https://example.com"` or `"*"`).
130	///
131	/// Can be called multiple times to add multiple origins.
132	pub fn allow_origin(mut self, origin: &str) -> Self {
133		self.allow_origins.push(origin.to_string());
134		self
135	}
136
137	/// Enable or disable credentialed requests (Authorization headers, cookies, etc.).
138	///
139	/// Defaults to `false`. If set to `true` and a wildcard origin is added,
140	/// `build()` will reject the configuration.
141	pub fn allow_credentials(mut self, yes: bool) -> Self {
142		self.credentials = yes;
143		self
144	}
145
146	/// Build the CORS configuration, validating that credentials and wildcard are not both enabled.
147	pub fn build(self) -> Result<CorsConfig, CorsConfigError> {
148		let allow_all_origins = self.allow_origins.len() == 1 && self.allow_origins[0] == "*";
149
150		if self.credentials && allow_all_origins {
151			return Err(CorsConfigError::CredentialedWildcard);
152		}
153
154		Ok(CorsConfig {
155			allow_origins: self.allow_origins,
156			allow_all_origins,
157			credentials: self.credentials,
158		})
159	}
160}
161
162#[cfg(test)]
163mod tests {
164	use super::*;
165
166	#[test]
167	fn credentialed_wildcard_rejected_in_debug() {
168		let result = CorsConfigBuilder::default()
169			.allow_origin("*")
170			.allow_credentials(true)
171			.build();
172
173		assert_eq!(result, Err(CorsConfigError::CredentialedWildcard));
174	}
175
176	#[test]
177	fn credentialed_wildcard_rejected_in_release() {
178		let result = CorsConfigBuilder::default()
179			.allow_origin("*")
180			.allow_credentials(true)
181			.build();
182
183		assert_eq!(result, Err(CorsConfigError::CredentialedWildcard));
184	}
185
186	#[test]
187	fn explicit_origin_with_credentials_allowed() {
188		let config = CorsConfigBuilder::default()
189			.allow_origin("https://example.com")
190			.allow_credentials(true)
191			.build();
192
193		assert!(config.is_ok());
194		let cfg = config.unwrap();
195		assert!(!cfg.allow_all_origins);
196		assert!(cfg.credentials);
197	}
198
199	#[test]
200	fn wildcard_without_credentials_allowed() {
201		let config = CorsConfigBuilder::default()
202			.allow_origin("*")
203			.allow_credentials(false)
204			.build();
205
206		assert!(config.is_ok());
207		let cfg = config.unwrap();
208		assert!(cfg.allow_all_origins);
209		assert!(!cfg.credentials);
210	}
211}