Skip to main content

sova_cors/
lib.rs

1//! CORS plugin for Sova (Express [`cors`](https://expressjs.com/en/resources/middleware/cors.html)-style).
2
3use http::header::{
4    ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
5    ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE, VARY,
6};
7use http::Method;
8use sova_core::extend::{named, with_leaked};
9use sova_core::{App, Plugin, Request, Response};
10
11#[derive(Clone, Debug)]
12enum OriginMode {
13    /// Literal `Access-Control-Allow-Origin` value (`*` or a single origin).
14    Exact(String),
15    /// Mirror request `Origin` when it is in the list.
16    List(Vec<String>),
17}
18
19/// CORS plugin.
20#[derive(Clone)]
21pub struct Cors {
22    origin: OriginMode,
23    origin_explicit: bool,
24    methods: String,
25    /// Empty string → reflect `Access-Control-Request-Headers` on preflight.
26    headers: String,
27    exposed: Option<String>,
28    credentials: bool,
29    credentials_explicit: bool,
30    max_age: Option<u64>,
31}
32
33impl Cors {
34    pub fn new() -> Self {
35        Self {
36            origin: OriginMode::Exact("*".into()),
37            origin_explicit: false,
38            methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS".into(),
39            // CSRF headers for cookie-session SPAs (Laravel / axios XSRF).
40            headers: "Content-Type, Authorization, X-CSRF-Token, X-XSRF-TOKEN".into(),
41            exposed: None,
42            credentials: false,
43            credentials_explicit: false,
44            max_age: Some(86400),
45        }
46    }
47
48    /// Single allowed origin, or `"*"` (Express `origin` string).
49    pub fn origin(mut self, origin: impl Into<String>) -> Self {
50        self.origin = OriginMode::Exact(origin.into());
51        self.origin_explicit = true;
52        self
53    }
54
55    /// Allow any of these origins (mirror matching request `Origin`).
56    pub fn origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
57        self.origin = OriginMode::List(origins.into_iter().map(Into::into).collect());
58        self.origin_explicit = true;
59        self
60    }
61
62    pub fn methods(mut self, methods: impl Into<String>) -> Self {
63        self.methods = methods.into();
64        self
65    }
66
67    /// Allowed request headers. Empty → reflect `Access-Control-Request-Headers`.
68    pub fn headers(mut self, headers: impl Into<String>) -> Self {
69        self.headers = headers.into();
70        self
71    }
72
73    /// `Access-Control-Expose-Headers`.
74    pub fn exposed(mut self, headers: impl Into<String>) -> Self {
75        self.exposed = Some(headers.into());
76        self
77    }
78
79    /// `Access-Control-Allow-Credentials`.
80    ///
81    /// When combined with [`Self::origin`]`("*")`, browsers reject `*` + credentials.
82    /// This plugin mirrors the request `Origin` instead of emitting `*`.
83    pub fn credentials(mut self, on: bool) -> Self {
84        self.credentials = on;
85        self.credentials_explicit = true;
86        self
87    }
88
89    /// `Access-Control-Max-Age` for preflight (seconds). `None` omits the header.
90    pub fn max_age(mut self, secs: impl Into<Option<u64>>) -> Self {
91        self.max_age = secs.into();
92        self
93    }
94
95    fn apply_config(&mut self, app: &App) {
96        let Some(doc) = app.config_doc() else {
97            return;
98        };
99        let Some(section) = doc.section("cors") else {
100            return;
101        };
102        if !self.origin_explicit {
103            if let Some(toml::Value::Array(arr)) = section.get("origins") {
104                let list: Vec<String> = arr
105                    .iter()
106                    .filter_map(|v| v.as_str().map(str::to_string))
107                    .collect();
108                if !list.is_empty() {
109                    self.origin = OriginMode::List(list);
110                }
111            } else if let Some(o) = section.get("origin").and_then(|v| v.as_str()) {
112                self.origin = OriginMode::Exact(o.to_string());
113            }
114        }
115        if !self.credentials_explicit {
116            if let Some(v) = section.get("credentials").and_then(|v| v.as_bool()) {
117                self.credentials = v;
118            }
119        }
120        if let Some(v) = section.get("max_age").and_then(|v| v.as_integer()) {
121            self.max_age = Some(v as u64);
122        }
123    }
124}
125
126impl Default for Cors {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl Plugin for Cors {
133    fn id(&self) -> &'static str {
134        "cors"
135    }
136
137    fn meta(&self) -> sova_core::PluginMeta {
138        sova_core::PluginMeta::new("CORS")
139            .description("Cross-Origin Resource Sharing headers")
140            .version(env!("CARGO_PKG_VERSION"))
141    }
142
143    fn install(mut self, app: &mut App) {
144        self.apply_config(app);
145        app.use_middleware(named(
146            "cors",
147            with_leaked(self, |cors, req, next| async move {
148                if req.method == Method::OPTIONS {
149                    return preflight(cors, &req);
150                }
151                let origin = req.header("origin").map(str::to_owned);
152                let mut res = next(req).await;
153                apply_cors(cors, origin.as_deref(), None, &mut res, false);
154                res
155            }),
156        ));
157    }
158}
159
160fn preflight(cors: &Cors, req: &Request) -> Response {
161    let mut res = Response::empty().status(204);
162    let origin = req.header("origin");
163    let acrh = req.header("access-control-request-headers");
164    apply_cors(cors, origin, acrh, &mut res, true);
165    res
166}
167
168fn apply_cors(
169    cors: &Cors,
170    req_origin: Option<&str>,
171    acrh: Option<&str>,
172    res: &mut Response,
173    preflight: bool,
174) {
175    let Some(allow_origin) = resolve_origin(&cors.origin, req_origin, cors.credentials) else {
176        return;
177    };
178
179    let mirrored_star = cors.credentials
180        && matches!(&cors.origin, OriginMode::Exact(o) if o == "*")
181        && allow_origin != "*";
182    if !matches!(&cors.origin, OriginMode::Exact(o) if o == "*") || mirrored_star {
183        append_vary(res, "Origin");
184    }
185
186    set_header(res, ACCESS_CONTROL_ALLOW_ORIGIN, &allow_origin);
187
188    if cors.credentials {
189        set_header(res, ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
190    }
191
192    if let Some(ref exposed) = cors.exposed {
193        if !exposed.is_empty() {
194            set_header(res, ACCESS_CONTROL_EXPOSE_HEADERS, exposed);
195        }
196    }
197
198    if preflight {
199        set_header(res, ACCESS_CONTROL_ALLOW_METHODS, &cors.methods);
200        let allow_headers = if cors.headers.is_empty() {
201            acrh.unwrap_or("").to_string()
202        } else {
203            cors.headers.clone()
204        };
205        if !allow_headers.is_empty() {
206            set_header(res, ACCESS_CONTROL_ALLOW_HEADERS, &allow_headers);
207        }
208        if let Some(age) = cors.max_age {
209            set_header(res, ACCESS_CONTROL_MAX_AGE, &age.to_string());
210        }
211    }
212}
213
214fn resolve_origin(mode: &OriginMode, req_origin: Option<&str>, credentials: bool) -> Option<String> {
215    match mode {
216        OriginMode::Exact(o) if o == "*" => {
217            if credentials {
218                // Browsers reject `*` + credentials — mirror request Origin instead.
219                req_origin.map(str::to_string)
220            } else {
221                Some("*".into())
222            }
223        }
224        OriginMode::Exact(o) => Some(o.clone()),
225        OriginMode::List(list) => {
226            let origin = req_origin?;
227            if list.iter().any(|o| o == origin) {
228                Some(origin.to_string())
229            } else {
230                None
231            }
232        }
233    }
234}
235
236fn set_header(res: &mut Response, name: http::HeaderName, value: &str) {
237    if let Ok(v) = value.parse() {
238        res.headers_mut().insert(name, v);
239    }
240}
241
242fn append_vary(res: &mut Response, token: &str) {
243    let existing = res
244        .headers()
245        .get(VARY)
246        .and_then(|v| v.to_str().ok())
247        .unwrap_or("")
248        .to_string();
249    if existing
250        .split(',')
251        .any(|t| t.trim().eq_ignore_ascii_case(token))
252    {
253        return;
254    }
255    let value = if existing.is_empty() {
256        token.to_string()
257    } else {
258        format!("{existing}, {token}")
259    };
260    if let Ok(v) = value.parse() {
261        res.headers_mut().insert(VARY, v);
262    }
263}