Skip to main content

rskit_http/
cors.rs

1//! CORS policy and Tower layer construction.
2
3use std::time::Duration;
4
5use http::{HeaderName, HeaderValue, Method};
6use rskit_errors::{AppError, AppResult};
7use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
8
9/// Cross-origin resource sharing policy.
10///
11/// The default is deny-by-default: no origins, methods, headers, or credentials
12/// are allowed unless explicitly configured.
13#[derive(Debug, Clone, Default, serde::Deserialize)]
14#[serde(default)]
15pub struct CorsPolicy {
16    /// Allowed Origin header values.
17    pub allowed_origins: Vec<String>,
18    /// Allowed HTTP methods.
19    pub allowed_methods: Vec<String>,
20    /// Allowed request headers.
21    pub allowed_headers: Vec<String>,
22    /// Whether to allow credentials.
23    pub allow_credentials: bool,
24    /// Cache duration for pre-flight responses.
25    pub max_age: Duration,
26}
27
28impl CorsPolicy {
29    /// Validate the CORS policy.
30    ///
31    /// # Errors
32    /// Returns an error when an origin, method, or header is invalid.
33    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    /// Build a Tower CORS layer from this policy.
58    ///
59    /// # Errors
60    /// Returns an error when an origin, method, or header is invalid.
61    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}