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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CorsConfigError {
11 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#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct CorsConfig {
43 pub allow_origins: Vec<String>,
45 pub allow_all_origins: bool,
47 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 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 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 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#[derive(Default, Debug)]
160pub struct CorsConfigBuilder {
161 allow_origins: Vec<String>,
162 credentials: bool,
163}
164
165impl CorsConfigBuilder {
166 pub fn allow_origin(mut self, origin: &str) -> Self {
170 self.allow_origins.push(origin.to_string());
171 self
172 }
173
174 pub fn allow_credentials(mut self, yes: bool) -> Self {
179 self.credentials = yes;
180 self
181 }
182
183 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)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn credentialed_wildcard_rejected_in_debug() {
205 let result = CorsConfigBuilder::default()
206 .allow_origin("*")
207 .allow_credentials(true)
208 .build();
209
210 assert_eq!(result, Err(CorsConfigError::CredentialedWildcard));
211 }
212
213 #[test]
214 fn credentialed_wildcard_rejected_in_release() {
215 let result = CorsConfigBuilder::default()
216 .allow_origin("*")
217 .allow_credentials(true)
218 .build();
219
220 assert_eq!(result, Err(CorsConfigError::CredentialedWildcard));
221 }
222
223 #[test]
224 fn explicit_origin_with_credentials_allowed() {
225 let config = CorsConfigBuilder::default()
226 .allow_origin("https://example.com")
227 .allow_credentials(true)
228 .build();
229
230 assert!(config.is_ok());
231 let cfg = config.unwrap();
232 assert!(!cfg.allow_all_origins);
233 assert!(cfg.credentials);
234 }
235
236 #[test]
237 fn wildcard_without_credentials_allowed() {
238 let config = CorsConfigBuilder::default()
239 .allow_origin("*")
240 .allow_credentials(false)
241 .build();
242
243 assert!(config.is_ok());
244 let cfg = config.unwrap();
245 assert!(cfg.allow_all_origins);
246 assert!(!cfg.credentials);
247 }
248}