Skip to main content

lino_rest_api/
cors.rs

1//! Cross-origin resource sharing (specification section 8).
2
3use crate::headers::Headers;
4
5/// Headers a browser must be allowed to send to negotiate Links Notation.
6pub const DEFAULT_ALLOWED_HEADERS: [&str; 5] = [
7    "Content-Type",
8    "Accept",
9    "Authorization",
10    "If-Match",
11    "If-None-Match",
12];
13
14/// Headers a browser must be allowed to read to follow the protocol.
15pub const DEFAULT_EXPOSED_HEADERS: [&str; 4] = ["ETag", "Link", "Location", "Allow"];
16
17/// Methods a browser may use unless the policy narrows them.
18pub const DEFAULT_METHODS: [&str; 7] = ["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"];
19
20/// A cross-origin policy.
21///
22/// [`CorsPolicy::permissive`] is the `cors: true` of the JavaScript and Python
23/// packages: every origin, every default method and header.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct CorsPolicy {
26    /// Allowed origins; a single `*` allows every origin.
27    pub origins: Vec<String>,
28    /// Allowed methods.
29    pub methods: Vec<String>,
30    /// Headers a request may carry.
31    pub allowed_headers: Vec<String>,
32    /// Headers a browser may read from the response.
33    pub exposed_headers: Vec<String>,
34    /// Whether credentials may be sent.
35    pub credentials: bool,
36    /// How long a preflight result may be cached, in seconds.
37    pub max_age: u32,
38}
39
40impl Default for CorsPolicy {
41    fn default() -> Self {
42        Self::permissive()
43    }
44}
45
46impl CorsPolicy {
47    /// The permissive default policy.
48    pub fn permissive() -> Self {
49        Self {
50            origins: vec!["*".to_string()],
51            methods: DEFAULT_METHODS.iter().map(|m| m.to_string()).collect(),
52            allowed_headers: DEFAULT_ALLOWED_HEADERS
53                .iter()
54                .map(|h| h.to_string())
55                .collect(),
56            exposed_headers: DEFAULT_EXPOSED_HEADERS
57                .iter()
58                .map(|h| h.to_string())
59                .collect(),
60            credentials: false,
61            max_age: 600,
62        }
63    }
64
65    /// The same policy restricted to the given origins.
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use lino_rest_api::cors::{cors_headers, CorsPolicy};
71    ///
72    /// let policy = CorsPolicy::permissive().with_origins(["https://app.example"]);
73    /// assert!(cors_headers(&policy, Some("https://other.example")).is_empty());
74    /// ```
75    pub fn with_origins<I, S>(mut self, origins: I) -> Self
76    where
77        I: IntoIterator<Item = S>,
78        S: Into<String>,
79    {
80        self.origins = origins.into_iter().map(Into::into).collect();
81        self
82    }
83
84    /// The same policy with credentials allowed.
85    pub fn with_credentials(mut self, credentials: bool) -> Self {
86        self.credentials = credentials;
87        self
88    }
89
90    /// The same policy with another preflight cache lifetime.
91    pub fn with_max_age(mut self, max_age: u32) -> Self {
92        self.max_age = max_age;
93        self
94    }
95}
96
97/// Build the CORS response headers for a request.
98///
99/// The map is empty when the origin is not allowed, which leaves the response
100/// without any cross-origin permission — exactly what a browser needs to refuse
101/// it.
102pub fn cors_headers(policy: &CorsPolicy, request_origin: Option<&str>) -> Headers {
103    let wildcard = policy.origins.iter().any(|origin| origin == "*");
104    let allow_origin = if wildcard {
105        match (policy.credentials, request_origin) {
106            (true, Some(origin)) => Some(origin.to_string()),
107            _ => Some("*".to_string()),
108        }
109    } else {
110        request_origin
111            .filter(|origin| policy.origins.iter().any(|allowed| allowed == origin))
112            .map(str::to_string)
113    };
114
115    let Some(allow_origin) = allow_origin else {
116        return Headers::new();
117    };
118
119    let mut headers = Headers::from_pairs([
120        ("Access-Control-Allow-Origin", allow_origin),
121        ("Access-Control-Allow-Methods", policy.methods.join(", ")),
122        (
123            "Access-Control-Allow-Headers",
124            policy.allowed_headers.join(", "),
125        ),
126        (
127            "Access-Control-Expose-Headers",
128            policy.exposed_headers.join(", "),
129        ),
130        ("Access-Control-Max-Age", policy.max_age.to_string()),
131    ]);
132    if policy.credentials {
133        headers.insert("Access-Control-Allow-Credentials", "true");
134    }
135    headers
136}