Skip to main content

rustlavel_http/
cors.rs

1//! Cross-Origin Resource Sharing.
2//!
3//! A browser will not let a page on one origin read a response from another
4//! unless the response says so. Without this middleware an API works from
5//! `curl`, from a mobile app, and from a server — and fails, silently, from
6//! every single-page application that is not served from the same host.
7//!
8//! The shape follows Laravel's `config/cors.php`, key for key, so a person who
9//! has configured CORS there already knows how to configure it here:
10//!
11//! ```ignore
12//! App::new()?.middleware(Cors::from_config(app.config()))
13//! // or, in code:
14//! App::new()?.middleware(
15//!     Cors::new()
16//!         .allow_origins(["https://app.example.com"])
17//!         .allow_credentials(),
18//! )
19//! ```
20//!
21//! Two things are worth knowing before reaching for [`Cors::permissive`]. A
22//! wildcard origin cannot be combined with credentials — the specification
23//! forbids `Access-Control-Allow-Origin: *` on a response that also allows
24//! cookies — so with credentials on, this echoes the requesting origin instead,
25//! which allows *every* origin to make credentialled requests. That is rarely
26//! what anyone means. Name the origins.
27
28use crate::handler::BoxFuture;
29use crate::method::Method;
30use crate::middleware::{Middleware, Next};
31use crate::request::Request;
32use crate::response::Response;
33use rustlavel_core::Config;
34use std::sync::Arc;
35use std::time::Duration;
36
37/// Which origins may read responses.
38#[derive(Clone)]
39enum Origins {
40    Any,
41    /// Exact origins, or patterns with one `*` (`https://*.example.com`).
42    List(Vec<String>),
43    Predicate(Arc<dyn Fn(&str) -> bool + Send + Sync>),
44}
45
46/// Which request headers a preflight may ask for.
47#[derive(Clone)]
48enum AllowedHeaders {
49    /// Echo whatever the browser asked for.
50    Any,
51    List(Vec<String>),
52}
53
54#[derive(Clone)]
55pub struct Cors {
56    /// Only requests under these paths are treated as CORS requests; the rest
57    /// pass through untouched. `*` means everything, and is the default.
58    paths: Vec<String>,
59    origins: Origins,
60    methods: Vec<Method>,
61    allowed_headers: AllowedHeaders,
62    exposed_headers: Vec<String>,
63    credentials: bool,
64    max_age: Option<Duration>,
65}
66
67impl Default for Cors {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Cors {
74    /// Deny everything until told otherwise: no origins, no credentials.
75    ///
76    /// Starting closed means a missing line of configuration fails a request
77    /// in the browser console, where somebody will see it, rather than opening
78    /// the API to the world.
79    pub fn new() -> Self {
80        Cors {
81            paths: vec!["*".to_string()],
82            origins: Origins::List(Vec::new()),
83            methods: vec![
84                Method::Get,
85                Method::Head,
86                Method::Post,
87                Method::Put,
88                Method::Patch,
89                Method::Delete,
90            ],
91            allowed_headers: AllowedHeaders::Any,
92            exposed_headers: Vec::new(),
93            credentials: false,
94            max_age: None,
95        }
96    }
97
98    /// Any origin, any method, any header, no credentials.
99    ///
100    /// Right for a genuinely public API. Wrong for anything that reads a
101    /// session cookie, because the moment `allow_credentials` is added this
102    /// becomes "every site on the internet may act as the logged-in user".
103    pub fn permissive() -> Self {
104        Cors { origins: Origins::Any, ..Cors::new() }
105    }
106
107    /// Read `config/cors.json`, with Laravel's key names.
108    ///
109    /// ```json
110    /// {
111    ///   "paths": ["api/*"],
112    ///   "allowed_origins": "${CORS_ALLOWED_ORIGINS:*}",
113    ///   "allowed_methods": ["*"],
114    ///   "allowed_headers": ["*"],
115    ///   "exposed_headers": [],
116    ///   "max_age": 0,
117    ///   "supports_credentials": false
118    /// }
119    /// ```
120    ///
121    /// Every list accepts either a JSON array or one comma-separated string,
122    /// which is what makes it settable from `.env`: a variable can only hold a
123    /// string.
124    pub fn from_config(config: &Config) -> Self {
125        let mut cors = Cors::new();
126
127        let paths = config.list("cors.paths");
128        if !paths.is_empty() {
129            cors.paths = paths;
130        }
131
132        let origins = config.list("cors.allowed_origins");
133        cors.origins =
134            if origins.iter().any(|o| o == "*") { Origins::Any } else { Origins::List(origins) };
135
136        let methods = config.list("cors.allowed_methods");
137        if !methods.is_empty() && !methods.iter().any(|m| m == "*") {
138            cors.methods = methods.iter().filter_map(|m| Method::parse(m)).collect();
139        }
140
141        let headers = config.list("cors.allowed_headers");
142        if !headers.is_empty() && !headers.iter().any(|h| h == "*") {
143            cors.allowed_headers = AllowedHeaders::List(headers);
144        }
145
146        cors.exposed_headers = config.list("cors.exposed_headers");
147        cors.credentials = config.bool("cors.supports_credentials", false);
148
149        let max_age = config.int("cors.max_age", 0);
150        if max_age > 0 {
151            cors.max_age = Some(Duration::from_secs(max_age as u64));
152        }
153
154        cors
155    }
156
157    /// Apply only under these paths, Laravel-style: `api/*`, `sanctum/csrf-cookie`.
158    pub fn paths<I, S>(mut self, paths: I) -> Self
159    where
160        I: IntoIterator<Item = S>,
161        S: Into<String>,
162    {
163        self.paths = paths.into_iter().map(Into::into).collect();
164        self
165    }
166
167    /// Allow these origins. A single `*` inside a pattern matches one label:
168    /// `https://*.example.com` allows `https://app.example.com` and not
169    /// `https://example.com.evil.net`.
170    pub fn allow_origins<I, S>(mut self, origins: I) -> Self
171    where
172        I: IntoIterator<Item = S>,
173        S: Into<String>,
174    {
175        self.origins = Origins::List(origins.into_iter().map(Into::into).collect());
176        self
177    }
178
179    pub fn allow_any_origin(mut self) -> Self {
180        self.origins = Origins::Any;
181        self
182    }
183
184    /// Decide per origin — for a list that lives in a database, say.
185    pub fn allow_origin_if(mut self, allow: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
186        self.origins = Origins::Predicate(Arc::new(allow));
187        self
188    }
189
190    pub fn allow_methods(mut self, methods: impl IntoIterator<Item = Method>) -> Self {
191        self.methods = methods.into_iter().collect();
192        self
193    }
194
195    pub fn allow_headers<I, S>(mut self, headers: I) -> Self
196    where
197        I: IntoIterator<Item = S>,
198        S: Into<String>,
199    {
200        self.allowed_headers = AllowedHeaders::List(headers.into_iter().map(Into::into).collect());
201        self
202    }
203
204    /// Let scripts read these response headers. By default a browser exposes
205    /// only the handful the specification calls "safelisted" — a custom
206    /// `X-Request-Id` or `X-RateLimit-Remaining` is invisible until listed.
207    pub fn expose_headers<I, S>(mut self, headers: I) -> Self
208    where
209        I: IntoIterator<Item = S>,
210        S: Into<String>,
211    {
212        self.exposed_headers = headers.into_iter().map(Into::into).collect();
213        self
214    }
215
216    /// Allow cookies and `Authorization` headers to travel with the request.
217    pub fn allow_credentials(mut self) -> Self {
218        self.credentials = true;
219        self
220    }
221
222    /// How long a browser may cache a preflight answer.
223    pub fn max_age(mut self, duration: Duration) -> Self {
224        self.max_age = Some(duration);
225        self
226    }
227
228    fn applies_to(&self, path: &str) -> bool {
229        let path = path.trim_start_matches('/');
230        self.paths.iter().any(|pattern| {
231            let pattern = pattern.trim_start_matches('/');
232            match pattern.strip_suffix('*') {
233                Some(prefix) => pattern == "*" || path.starts_with(prefix),
234                None => path == pattern,
235            }
236        })
237    }
238
239    fn allows_origin(&self, origin: &str) -> bool {
240        match &self.origins {
241            Origins::Any => true,
242            Origins::List(list) => list.iter().any(|pattern| origin_matches(pattern, origin)),
243            Origins::Predicate(allow) => allow(origin),
244        }
245    }
246
247    /// The `Access-Control-Allow-Origin` value, or `None` when the origin is
248    /// not allowed and the response should say nothing at all.
249    fn allow_origin_value(&self, origin: &str) -> Option<String> {
250        if !self.allows_origin(origin) {
251            return None;
252        }
253        // `*` is not permitted alongside credentials (Fetch §3.2.3), so the
254        // origin is echoed. Being a wildcard in disguise is documented on the type.
255        let wildcard = matches!(self.origins, Origins::Any) && !self.credentials;
256        Some(if wildcard { "*".to_string() } else { origin.to_string() })
257    }
258
259    fn preflight(&self, request: &Request, origin: &str) -> Response {
260        // A preflight never reaches a handler, so it always ends here: 204 and,
261        // if the origin is allowed, the headers the browser is asking about.
262        // Answering an unknown origin with a bare 204 rather than an error is
263        // deliberate — the browser blocks either way, and this reveals nothing.
264        let mut response = Response::no_content();
265        vary(&mut response, "origin");
266        vary(&mut response, "access-control-request-method");
267        vary(&mut response, "access-control-request-headers");
268
269        let Some(allow_origin) = self.allow_origin_value(origin) else { return response };
270        response.headers.set("access-control-allow-origin", allow_origin);
271
272        let methods = self.methods.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ");
273        response.headers.set("access-control-allow-methods", methods);
274
275        let allow_headers = match &self.allowed_headers {
276            AllowedHeaders::List(list) => Some(list.join(", ")),
277            // Echo what was asked for: this is what `*` means in practice, since
278            // a literal `*` is ignored by browsers when credentials are on.
279            AllowedHeaders::Any => {
280                request.header("access-control-request-headers").map(str::to_string)
281            }
282        };
283        if let Some(headers) = allow_headers.filter(|h| !h.is_empty()) {
284            response.headers.set("access-control-allow-headers", headers);
285        }
286
287        if self.credentials {
288            response.headers.set("access-control-allow-credentials", "true");
289        }
290        if let Some(max_age) = self.max_age {
291            response.headers.set("access-control-max-age", max_age.as_secs().to_string());
292        }
293        response
294    }
295
296    fn decorate(&self, mut response: Response, origin: &str) -> Response {
297        vary(&mut response, "origin");
298        let Some(allow_origin) = self.allow_origin_value(origin) else { return response };
299
300        response.headers.set("access-control-allow-origin", allow_origin);
301        if self.credentials {
302            response.headers.set("access-control-allow-credentials", "true");
303        }
304        if !self.exposed_headers.is_empty() {
305            response.headers.set("access-control-expose-headers", self.exposed_headers.join(", "));
306        }
307        response
308    }
309}
310
311impl Middleware for Cors {
312    fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
313        // Only a request carrying `Origin` is cross-origin; everything else is
314        // none of this middleware's business.
315        let origin = match request.header("origin") {
316            Some(origin) if self.applies_to(request.path()) => origin.to_string(),
317            _ => return next.run(request),
318        };
319
320        let is_preflight = request.method() == Method::Options
321            && request.header("access-control-request-method").is_some();
322        if is_preflight {
323            let response = self.preflight(&request, &origin);
324            return Box::pin(async move { response });
325        }
326
327        let cors = self.clone();
328        Box::pin(async move {
329            let response = next.run(request).await;
330            cors.decorate(response, &origin)
331        })
332    }
333}
334
335/// Add to `Vary` without clobbering what a handler already put there.
336fn vary(response: &mut Response, name: &str) {
337    let existing = response.headers.get("vary").unwrap_or("").to_string();
338    if existing.split(',').any(|v| v.trim().eq_ignore_ascii_case(name)) {
339        return;
340    }
341    let value = if existing.is_empty() { name.to_string() } else { format!("{existing}, {name}") };
342    response.headers.set("vary", value);
343}
344
345/// Exact match, or a pattern with one `*` standing for a single DNS label.
346///
347/// One label only, deliberately. `https://*.example.com` must not match
348/// `https://a.b.example.com` — that is a different origin, possibly hosted by a
349/// different team — and it certainly must not match anything that merely ends
350/// in `example.com`.
351fn origin_matches(pattern: &str, origin: &str) -> bool {
352    let Some((prefix, suffix)) = pattern.split_once('*') else {
353        return pattern.eq_ignore_ascii_case(origin);
354    };
355    let origin_lower = origin.to_ascii_lowercase();
356    let (prefix, suffix) = (prefix.to_ascii_lowercase(), suffix.to_ascii_lowercase());
357
358    let Some(rest) = origin_lower.strip_prefix(prefix.as_str()) else { return false };
359    let Some(label) = rest.strip_suffix(suffix.as_str()) else { return false };
360    !label.is_empty() && !label.contains(['.', '/', ':'])
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::router::Router;
367    use rustlavel_core::Json;
368    use crate::testing::TestClient;
369
370    fn client(cors: Cors) -> TestClient {
371        let mut router = Router::new();
372        router.middleware(cors);
373        router.get("/api/users", |_req: Request| async { Response::text("users") });
374        router.post("/api/users", |_req: Request| async { Response::text("created") });
375        router.get("/web/page", |_req: Request| async {
376            Response::text("page").with_header("vary", "Accept-Encoding")
377        });
378        TestClient::new(router)
379    }
380
381    fn preflight(path: &str, origin: &str) -> Request {
382        Request::new(Method::Options, path)
383            .with_header("origin", origin)
384            .with_header("access-control-request-method", "POST")
385            .with_header("access-control-request-headers", "content-type, x-custom")
386    }
387
388    #[tokio::test]
389    async fn a_request_without_an_origin_is_left_alone() {
390        let response = client(Cors::permissive()).get("/api/users").await;
391        let response = response.assert_ok();
392        assert_eq!(response.header("access-control-allow-origin"), None);
393    }
394
395    #[tokio::test]
396    async fn a_listed_origin_is_allowed_and_echoed() {
397        let cors = Cors::new().allow_origins(["https://app.example.com"]);
398        let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://app.example.com");
399        let response = client(cors).send(request).await;
400
401        let response = response.assert_ok();
402        assert_eq!(response.header("access-control-allow-origin"), Some("https://app.example.com"));
403        assert_eq!(response.header("vary"), Some("origin"));
404    }
405
406    #[tokio::test]
407    async fn an_unlisted_origin_gets_no_cors_headers_at_all() {
408        let cors = Cors::new().allow_origins(["https://app.example.com"]);
409        let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://evil.example");
410        let response = client(cors).send(request).await;
411
412        // The handler still runs — CORS is enforced by the browser, not here —
413        // but the browser gets nothing it could use to release the response.
414        let response = response.assert_ok();
415        assert_eq!(response.header("access-control-allow-origin"), None);
416        assert_eq!(response.header("vary"), Some("origin"), "caches must still key on origin");
417    }
418
419    #[tokio::test]
420    async fn permissive_answers_with_a_wildcard() {
421        let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://anyone.example");
422        let response = client(Cors::permissive()).send(request).await;
423        assert_eq!(response.header("access-control-allow-origin"), Some("*"));
424        assert_eq!(response.header("access-control-allow-credentials"), None);
425    }
426
427    #[tokio::test]
428    async fn credentials_forbid_the_wildcard_so_the_origin_is_echoed() {
429        let cors = Cors::permissive().allow_credentials();
430        let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://anyone.example");
431        let response = client(cors).send(request).await;
432
433        assert_eq!(response.header("access-control-allow-origin"), Some("https://anyone.example"));
434        assert_eq!(response.header("access-control-allow-credentials"), Some("true"));
435    }
436
437    #[tokio::test]
438    async fn a_preflight_is_answered_without_reaching_the_handler() {
439        let cors = Cors::new()
440            .allow_origins(["https://app.example.com"])
441            .allow_methods([Method::Get, Method::Post])
442            .max_age(Duration::from_secs(600));
443        let response = client(cors).send(preflight("/api/users", "https://app.example.com")).await;
444
445        let response = response.assert_status(204);
446        assert_eq!(response.body(), "", "a preflight has no body and never ran the handler");
447        assert_eq!(response.header("access-control-allow-origin"), Some("https://app.example.com"));
448        assert_eq!(response.header("access-control-allow-methods"), Some("GET, POST"));
449        assert_eq!(
450            response.header("access-control-allow-headers"),
451            Some("content-type, x-custom"),
452            "with no list configured, the requested headers are echoed"
453        );
454        assert_eq!(response.header("access-control-max-age"), Some("600"));
455        assert_eq!(
456            response.header("vary"),
457            Some("origin, access-control-request-method, access-control-request-headers")
458        );
459    }
460
461    #[tokio::test]
462    async fn a_preflight_for_a_path_with_no_options_route_still_succeeds() {
463        // Without this middleware the router would answer 405, and the browser
464        // would refuse to send the real request. This is the whole point.
465        client(Cors::permissive()).send(preflight("/api/users", "https://x.example")).await.assert_status(204);
466    }
467
468    #[tokio::test]
469    async fn a_preflight_from_a_forbidden_origin_is_a_bare_204() {
470        let cors = Cors::new().allow_origins(["https://app.example.com"]);
471        let response = client(cors).send(preflight("/api/users", "https://evil.example")).await;
472
473        let response = response.assert_status(204);
474        assert_eq!(response.header("access-control-allow-origin"), None);
475        assert_eq!(response.header("access-control-allow-methods"), None);
476    }
477
478    #[tokio::test]
479    async fn configured_headers_are_listed_rather_than_echoed() {
480        let cors = Cors::permissive().allow_headers(["Content-Type", "Authorization"]);
481        let response = client(cors).send(preflight("/api/users", "https://x.example")).await;
482        assert_eq!(response.header("access-control-allow-headers"), Some("Content-Type, Authorization"));
483    }
484
485    #[tokio::test]
486    async fn plain_options_without_a_request_method_is_not_a_preflight() {
487        let request = Request::new(Method::Options, "/api/users").with_header("origin", "https://x.example");
488        let response = client(Cors::permissive()).send(request).await;
489        // No handler for OPTIONS, so the router's 405 comes through — decorated.
490        let response = response.assert_status(405);
491        assert_eq!(response.header("access-control-allow-origin"), Some("*"));
492    }
493
494    #[tokio::test]
495    async fn exposed_headers_are_named_on_real_responses() {
496        let cors = Cors::permissive().expose_headers(["X-Request-Id", "X-RateLimit-Remaining"]);
497        let request = Request::new(Method::Get, "/api/users").with_header("origin", "https://x.example");
498        let response = client(cors).send(request).await;
499        assert_eq!(
500            response.header("access-control-expose-headers"),
501            Some("X-Request-Id, X-RateLimit-Remaining")
502        );
503    }
504
505    #[tokio::test]
506    async fn vary_is_appended_not_replaced() {
507        let request = Request::new(Method::Get, "/web/page").with_header("origin", "https://x.example");
508        let response = client(Cors::permissive()).send(request).await;
509        assert_eq!(response.header("vary"), Some("Accept-Encoding, origin"));
510    }
511
512    #[tokio::test]
513    async fn paths_restrict_where_cors_applies() {
514        let cors = Cors::permissive().paths(["api/*"]);
515        let client = client(cors);
516
517        let api = Request::new(Method::Get, "/api/users").with_header("origin", "https://x.example");
518        assert_eq!(client.send(api).await.header("access-control-allow-origin"), Some("*"));
519
520        let web = Request::new(Method::Get, "/web/page").with_header("origin", "https://x.example");
521        assert_eq!(client.send(web).await.header("access-control-allow-origin"), None);
522    }
523
524    #[test]
525    fn a_wildcard_matches_exactly_one_label() {
526        assert!(origin_matches("https://*.example.com", "https://app.example.com"));
527        assert!(origin_matches("https://*.example.com", "HTTPS://APP.example.com"));
528        assert!(!origin_matches("https://*.example.com", "https://example.com"));
529        assert!(!origin_matches("https://*.example.com", "https://a.b.example.com"));
530        assert!(!origin_matches("https://*.example.com", "https://example.com.evil.net"));
531        assert!(!origin_matches("https://*.example.com", "http://app.example.com"));
532        assert!(origin_matches("https://app.example.com", "https://app.example.com"));
533        assert!(!origin_matches("https://app.example.com", "https://app.example.com.evil"));
534    }
535
536    #[tokio::test]
537    async fn a_predicate_decides_per_origin() {
538        let cors = Cors::new().allow_origin_if(|origin| origin.ends_with(".trusted.example"));
539        let client = client(cors);
540
541        let yes = Request::new(Method::Get, "/api/users").with_header("origin", "https://a.trusted.example");
542        assert_eq!(
543            client.send(yes).await.header("access-control-allow-origin"),
544            Some("https://a.trusted.example")
545        );
546        let no = Request::new(Method::Get, "/api/users").with_header("origin", "https://a.other.example");
547        assert_eq!(client.send(no).await.header("access-control-allow-origin"), None);
548    }
549
550    #[test]
551    fn from_config_reads_laravels_keys_and_env_friendly_strings() {
552        let config = Config::new();
553        config.set("cors.paths", Json::from(vec!["api/*"]));
554        // A comma-separated string, as a `.env` variable would deliver it.
555        config.set("cors.allowed_origins", "https://a.example, https://b.example");
556        config.set("cors.allowed_methods", Json::from(vec!["GET", "POST"]));
557        config.set("cors.allowed_headers", Json::from(vec!["*"]));
558        config.set("cors.exposed_headers", "X-Request-Id");
559        config.set("cors.max_age", Json::from(3600_i64));
560        config.set("cors.supports_credentials", true);
561
562        let cors = Cors::from_config(&config);
563        assert!(cors.allows_origin("https://b.example"));
564        assert!(!cors.allows_origin("https://c.example"));
565        assert_eq!(cors.methods, vec![Method::Get, Method::Post]);
566        assert!(matches!(cors.allowed_headers, AllowedHeaders::Any));
567        assert_eq!(cors.exposed_headers, vec!["X-Request-Id"]);
568        assert_eq!(cors.max_age, Some(Duration::from_secs(3600)));
569        assert!(cors.credentials);
570        assert!(cors.applies_to("/api/users"));
571        assert!(!cors.applies_to("/web"));
572    }
573
574    #[test]
575    fn from_config_with_nothing_set_denies_every_origin() {
576        let cors = Cors::from_config(&Config::new());
577        assert!(!cors.allows_origin("https://anyone.example"));
578    }
579
580    #[test]
581    fn a_star_origin_in_config_means_any() {
582        let config = Config::new();
583        config.set("cors.allowed_origins", "*");
584        assert!(Cors::from_config(&config).allows_origin("https://anyone.example"));
585    }
586}