1use std::time::Duration;
4
5use http::{HeaderName, HeaderValue, Method};
6use rskit_errors::{AppError, AppResult};
7use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
8
9#[derive(Debug, Clone, Default, serde::Deserialize)]
14#[serde(default)]
15pub struct CorsPolicy {
16 pub allowed_origins: Vec<String>,
18 pub allowed_methods: Vec<String>,
20 pub allowed_headers: Vec<String>,
22 pub allow_credentials: bool,
24 pub max_age: Duration,
26}
27
28impl CorsPolicy {
29 pub fn validate(&self) -> AppResult<()> {
34 for origin in &self.allowed_origins {
35 validate_allowed_origin(origin)?;
36 }
37 if self.allow_credentials && self.allowed_origins.is_empty() {
38 return Err(AppError::invalid_input(
39 "allowed_origins",
40 "credentials require an explicit origin allow-list",
41 ));
42 }
43
44 for method in &self.allowed_methods {
45 method.parse::<Method>().map_err(|error| {
46 AppError::invalid_input("allowed_methods", format!("invalid method: {error}"))
47 })?;
48 }
49 for header in &self.allowed_headers {
50 HeaderName::from_bytes(header.as_bytes()).map_err(|error| {
51 AppError::invalid_input("allowed_headers", format!("invalid header: {error}"))
52 })?;
53 }
54 Ok(())
55 }
56
57 pub fn layer(&self) -> AppResult<CorsLayer> {
62 self.validate()?;
63
64 let origins = self
65 .allowed_origins
66 .iter()
67 .map(|origin| {
68 HeaderValue::from_str(origin).map_err(|error| {
69 AppError::invalid_input("allowed_origins", format!("invalid origin: {error}"))
70 })
71 })
72 .collect::<AppResult<Vec<_>>>()?;
73 let methods = self
74 .allowed_methods
75 .iter()
76 .map(|method| {
77 method.parse::<Method>().map_err(|error| {
78 AppError::invalid_input("allowed_methods", format!("invalid method: {error}"))
79 })
80 })
81 .collect::<AppResult<Vec<_>>>()?;
82 let headers = self
83 .allowed_headers
84 .iter()
85 .map(|header| {
86 HeaderName::from_bytes(header.as_bytes()).map_err(|error| {
87 AppError::invalid_input("allowed_headers", format!("invalid header: {error}"))
88 })
89 })
90 .collect::<AppResult<Vec<_>>>()?;
91
92 let mut layer = CorsLayer::new()
93 .allow_origin(AllowOrigin::list(origins))
94 .allow_methods(AllowMethods::list(methods))
95 .allow_headers(AllowHeaders::list(headers))
96 .allow_credentials(self.allow_credentials);
97
98 if !self.max_age.is_zero() {
99 layer = layer.max_age(self.max_age);
100 }
101
102 Ok(layer)
103 }
104}
105
106fn validate_allowed_origin(origin: &str) -> AppResult<()> {
107 if origin == "*" {
108 return Err(AppError::invalid_input(
109 "allowed_origins",
110 "wildcard origins are not allowed",
111 ));
112 }
113
114 let parsed = origin.parse::<http::Uri>().map_err(|error| {
115 AppError::invalid_input("allowed_origins", format!("invalid origin: {error}"))
116 })?;
117
118 match parsed.scheme_str() {
119 Some("http" | "https") => {}
120 scheme => {
121 return Err(AppError::invalid_input(
122 "allowed_origins",
123 format!("origin scheme must be http or https, got {scheme:?}"),
124 ));
125 }
126 }
127
128 let Some(authority) = parsed.authority() else {
129 return Err(AppError::invalid_input(
130 "allowed_origins",
131 "origin must include a host",
132 ));
133 };
134 if authority.as_str().contains('@') {
135 return Err(AppError::invalid_input(
136 "allowed_origins",
137 "origin must not contain credentials",
138 ));
139 }
140
141 let path = parsed.path();
142 if path != "/" && !path.is_empty() {
143 return Err(AppError::invalid_input(
144 "allowed_origins",
145 "origin must not contain a path",
146 ));
147 }
148 if parsed.query().is_some() {
149 return Err(AppError::invalid_input(
150 "allowed_origins",
151 "origin must not contain a query",
152 ));
153 }
154 if origin.contains('#') {
155 return Err(AppError::invalid_input(
156 "allowed_origins",
157 "origin must not contain a fragment",
158 ));
159 }
160 Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn defaults_are_deny_by_default() {
169 let policy = CorsPolicy::default();
170 assert!(policy.allowed_origins.is_empty());
171 assert!(policy.allowed_methods.is_empty());
172 assert!(policy.allowed_headers.is_empty());
173 assert!(policy.validate().is_ok());
174 }
175
176 #[test]
177 fn omitted_fields_deserialize_to_deny_by_default_values() {
178 let policy: CorsPolicy =
179 serde_json::from_str(r#"{"allowed_origins":["https://example.com"]}"#).unwrap();
180 assert_eq!(policy.allowed_origins, vec!["https://example.com"]);
181 assert!(policy.allowed_methods.is_empty());
182 assert!(policy.allowed_headers.is_empty());
183 assert!(!policy.allow_credentials);
184 assert!(policy.max_age.is_zero());
185 }
186
187 #[test]
188 fn rejects_wildcard_origin_before_url_validation() {
189 let policy = CorsPolicy {
190 allowed_origins: vec!["*".to_string()],
191 allowed_methods: vec!["GET".to_string()],
192 allowed_headers: vec!["authorization".to_string()],
193 allow_credentials: false,
194 max_age: Duration::from_mins(1),
195 };
196 let err = policy.validate().unwrap_err();
197 assert!(err.to_string().contains("wildcard origins are not allowed"));
198 }
199
200 #[test]
201 fn rejects_invalid_methods_and_headers() {
202 let policy = CorsPolicy {
203 allowed_origins: vec!["https://example.com".to_string()],
204 allowed_methods: vec!["bad method".to_string()],
205 allowed_headers: vec!["authorization".to_string()],
206 allow_credentials: false,
207 max_age: Duration::from_mins(1),
208 };
209 assert!(policy.validate().is_err());
210
211 let policy = CorsPolicy {
212 allowed_origins: vec!["https://example.com".to_string()],
213 allowed_methods: vec!["GET".to_string()],
214 allowed_headers: vec!["bad header".to_string()],
215 allow_credentials: false,
216 max_age: Duration::from_mins(1),
217 };
218 assert!(policy.validate().is_err());
219 }
220}