salvo_cors/lib.rs
1#![cfg_attr(test, allow(clippy::unwrap_used))]
2//! CORS (Cross-Origin Resource Sharing) protection for Salvo web framework.
3//!
4//! # Important
5//!
6//! The CORS handler must be added to [`Service`](salvo_core::Service) via `.hoop()`,
7//! **not** to [`Router`](salvo_core::Router). This is because browsers send
8//! `OPTIONS` preflight requests that don't match any route, and only
9//! `Service`-level middleware can intercept them.
10//!
11//! ```no_run
12//! use salvo_core::http::Method;
13//! use salvo_core::prelude::*;
14//! use salvo_cors::Cors;
15//!
16//! #[handler]
17//! async fn hello() -> &'static str {
18//! "Hello World"
19//! }
20//!
21//! fn main() {
22//! let cors = Cors::new()
23//! .allow_origin("http://localhost:3000")
24//! .allow_methods([Method::GET, Method::POST, Method::DELETE])
25//! .allow_headers("authorization")
26//! .into_handler();
27//!
28//! let router = Router::new().get(hello);
29//! // CORS must be on Service, NOT on Router
30//! let _service = Service::new(router).hoop(cors);
31//! }
32//! ```
33//!
34//! [CORS]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
35#![doc(html_favicon_url = "https://salvo.rs/favicon-32x32.png")]
36#![doc(html_logo_url = "https://salvo.rs/images/logo.svg")]
37#![cfg_attr(docsrs, feature(doc_cfg))]
38
39use bytes::{BufMut, BytesMut};
40use salvo_core::http::header::{self, HeaderMap, HeaderName, HeaderValue};
41use salvo_core::http::{Method, Request, Response, StatusCode};
42use salvo_core::{Depot, FlowCtrl, Handler, async_trait};
43
44mod allow_credentials;
45mod allow_headers;
46mod allow_methods;
47mod allow_origin;
48mod allow_private_network;
49mod expose_headers;
50mod max_age;
51mod vary;
52
53pub use self::allow_credentials::AllowCredentials;
54pub use self::allow_headers::AllowHeaders;
55pub use self::allow_methods::AllowMethods;
56pub use self::allow_origin::AllowOrigin;
57pub use self::allow_private_network::AllowPrivateNetwork;
58pub use self::expose_headers::ExposeHeaders;
59pub use self::max_age::MaxAge;
60pub use self::vary::Vary;
61
62static WILDCARD: HeaderValue = HeaderValue::from_static("*");
63
64/// Represents a wildcard value (`*`) used with some CORS headers such as
65/// [`Cors::allow_methods`].
66#[derive(Debug, Clone, Copy)]
67#[must_use]
68pub struct Any;
69
70fn separated_by_commas<I>(mut iter: I) -> Option<HeaderValue>
71where
72 I: Iterator<Item = HeaderValue>,
73{
74 match iter.next() {
75 Some(fst) => {
76 let mut result = BytesMut::from(fst.as_bytes());
77 for val in iter {
78 result.reserve(val.len() + 1);
79 result.put_u8(b',');
80 result.extend_from_slice(val.as_bytes());
81 }
82
83 HeaderValue::from_maybe_shared(result.freeze()).ok()
84 }
85 None => None,
86 }
87}
88
89/// [`Cors`] middleware which adds headers for [CORS][mdn].
90///
91/// After building, call [`.into_handler()`](Cors::into_handler) and add the
92/// resulting handler to [`Service`](salvo_core::Service) via `.hoop()`.
93/// Do **not** add it to `Router` — preflight `OPTIONS` requests won't reach
94/// router-level middleware.
95///
96/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
97#[derive(Clone, Debug)]
98pub struct Cors {
99 allow_credentials: AllowCredentials,
100 allow_headers: AllowHeaders,
101 allow_methods: AllowMethods,
102 allow_origin: AllowOrigin,
103 allow_private_network: AllowPrivateNetwork,
104 expose_headers: ExposeHeaders,
105 max_age: MaxAge,
106 vary: Vary,
107}
108impl Default for Cors {
109 #[inline]
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115impl Cors {
116 /// Create new `Cors`.
117 #[inline]
118 #[must_use]
119 pub fn new() -> Self {
120 Self {
121 allow_credentials: Default::default(),
122 allow_headers: Default::default(),
123 allow_methods: Default::default(),
124 allow_origin: Default::default(),
125 allow_private_network: Default::default(),
126 expose_headers: Default::default(),
127 max_age: Default::default(),
128 vary: Default::default(),
129 }
130 }
131
132 /// A permissive configuration:
133 ///
134 /// - All request headers allowed.
135 /// - All methods allowed.
136 /// - All origins allowed.
137 /// - All headers exposed.
138 ///
139 /// # Security Warning
140 ///
141 /// **This configuration allows any website to make requests to your API.**
142 /// Only use this for:
143 /// - Public APIs that don't require authentication
144 /// - Development/testing environments
145 ///
146 /// For production APIs that require authentication, configure CORS explicitly
147 /// with specific allowed origins.
148 #[must_use]
149 pub fn permissive() -> Self {
150 Self::new()
151 .allow_headers(Any)
152 .allow_methods(Any)
153 .allow_origin(Any)
154 .expose_headers(Any)
155 }
156
157 /// A very permissive configuration:
158 ///
159 /// - **Credentials allowed.**
160 /// - The method received in `Access-Control-Request-Method` is sent back as an allowed method.
161 /// - The origin of the preflight request is sent back as an allowed origin.
162 /// - The header names received in `Access-Control-Request-Headers` are sent back as allowed
163 /// headers.
164 /// - No headers are currently exposed, but this may change in the future.
165 ///
166 /// # Security Warning
167 ///
168 /// **⚠️ DANGER: This configuration essentially disables CORS protection!**
169 ///
170 /// By enabling credentials AND mirroring the request origin, you are allowing
171 /// ANY website to:
172 /// - Make authenticated requests to your API
173 /// - Read response data including sensitive information
174 /// - Perform actions on behalf of logged-in users (CSRF attacks)
175 ///
176 /// **This should NEVER be used in production with authentication.**
177 ///
178 /// Only use this for:
179 /// - Local development where security is not a concern
180 /// - Internal tools on trusted networks
181 ///
182 /// For production, always configure explicit allowed origins:
183 /// ```ignore
184 /// Cors::new()
185 /// .allow_origin("https://your-frontend.com")
186 /// .allow_credentials(true)
187 /// ```
188 #[must_use]
189 pub fn very_permissive() -> Self {
190 tracing::warn!(
191 "Using Cors::very_permissive() - this disables CORS security and should not be used in production!"
192 );
193 Self::new()
194 .allow_credentials(true)
195 .allow_headers(AllowHeaders::mirror_request())
196 .allow_methods(AllowMethods::mirror_request())
197 .allow_origin(AllowOrigin::mirror_request())
198 }
199
200 /// Sets whether to add the `Access-Control-Allow-Credentials` header.
201 #[inline]
202 #[must_use]
203 pub fn allow_credentials(mut self, allow_credentials: impl Into<AllowCredentials>) -> Self {
204 self.allow_credentials = allow_credentials.into();
205 self
206 }
207
208 /// Adds multiple headers to the list of allowed request headers.
209 ///
210 /// **Note**: These should match the values the browser sends via
211 /// `Access-Control-Request-Headers`, e.g.`content-type`.
212 ///
213 /// # Panics
214 ///
215 /// Panics if any of the headers are not a valid `http::header::HeaderName`.
216 #[inline]
217 #[must_use]
218 pub fn allow_headers(mut self, headers: impl Into<AllowHeaders>) -> Self {
219 self.allow_headers = headers.into();
220 self
221 }
222
223 /// Sets the `Access-Control-Max-Age` header.
224 ///
225 /// # Example
226 ///
227 /// ```
228 /// use std::time::Duration;
229 ///
230 /// use salvo_core::prelude::*;
231 /// use salvo_cors::Cors;
232 ///
233 /// let cors = Cors::new().max_age(30); // 30 seconds
234 /// let cors = Cors::new().max_age(Duration::from_secs(30)); // or a Duration
235 /// ```
236 #[inline]
237 #[must_use]
238 pub fn max_age(mut self, max_age: impl Into<MaxAge>) -> Self {
239 self.max_age = max_age.into();
240 self
241 }
242
243 /// Adds multiple methods to the existing list of allowed request methods.
244 ///
245 /// # Panics
246 ///
247 /// Panics if the provided argument is not a valid `http::Method`.
248 #[inline]
249 #[must_use]
250 pub fn allow_methods<I>(mut self, methods: I) -> Self
251 where
252 I: Into<AllowMethods>,
253 {
254 self.allow_methods = methods.into();
255 self
256 }
257
258 /// Set the value of the [`Access-Control-Allow-Origin`][mdn] header.
259 /// ```
260 /// use salvo_core::http::HeaderValue;
261 /// use salvo_cors::Cors;
262 ///
263 /// let cors = Cors::new().allow_origin("http://example.com".parse::<HeaderValue>().unwrap());
264 /// ```
265 ///
266 /// Multiple origins can be allowed with
267 ///
268 /// ```
269 /// use salvo_cors::Cors;
270 ///
271 /// let origins = ["http://example.com", "http://api.example.com"];
272 ///
273 /// let cors = Cors::new().allow_origin(origins);
274 /// ```
275 ///
276 /// All origins can be allowed with
277 ///
278 /// ```
279 /// use salvo_cors::{Any, Cors};
280 ///
281 /// let cors = Cors::new().allow_origin(Any);
282 /// ```
283 ///
284 /// You can also use a closure
285 ///
286 /// ```
287 /// use salvo_core::http::HeaderValue;
288 /// use salvo_core::{Depot, Request};
289 /// use salvo_cors::{AllowOrigin, Cors};
290 ///
291 /// let cors = Cors::new().allow_origin(AllowOrigin::dynamic(
292 /// |origin: Option<&HeaderValue>, _req: &Request, _depot: &Depot| {
293 /// if origin?.as_bytes().ends_with(b".rust-lang.org") {
294 /// origin.cloned()
295 /// } else {
296 /// None
297 /// }
298 /// },
299 /// ));
300 /// ```
301 ///
302 /// You can also use an async closure, make sure all the values are owned
303 /// before passing into the future:
304 ///
305 /// ```
306 /// # #[derive(Clone)]
307 /// # struct Client;
308 /// # fn get_api_client() -> Client {
309 /// # Client
310 /// # }
311 /// # impl Client {
312 /// # async fn fetch_allowed_origins(&self) -> Vec<HeaderValue> {
313 /// # vec![HeaderValue::from_static("http://example.com")]
314 /// # }
315 /// # async fn fetch_allowed_origins_for_path(&self, _path: String) -> Vec<HeaderValue> {
316 /// # vec![HeaderValue::from_static("http://example.com")]
317 /// # }
318 /// # }
319 /// use salvo_core::http::header::HeaderValue;
320 /// use salvo_core::{Depot, Request};
321 /// use salvo_cors::{AllowOrigin, Cors};
322 ///
323 /// let cors = Cors::new().allow_origin(AllowOrigin::dynamic_async(
324 /// |origin: Option<&HeaderValue>, _req: &Request, _depot: &Depot| {
325 /// let origin = origin.cloned();
326 /// async move {
327 /// let client = get_api_client();
328 /// // fetch list of origins that are allowed
329 /// let origins = client.fetch_allowed_origins().await;
330 /// if origins.contains(origin.as_ref()?) {
331 /// origin
332 /// } else {
333 /// None
334 /// }
335 /// }
336 /// },
337 /// ));
338 /// ```
339 ///
340 /// **Note** that multiple calls to this method will override any previous
341 /// calls.
342 ///
343 /// **Note** origin must contain http or https protocol name.
344 ///
345 /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
346 #[inline]
347 #[must_use]
348 pub fn allow_origin(mut self, origin: impl Into<AllowOrigin>) -> Self {
349 self.allow_origin = origin.into();
350 self
351 }
352
353 /// Set the value of the [`Access-Control-Expose-Headers`][mdn] header.
354 ///
355 /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
356 #[inline]
357 #[must_use]
358 pub fn expose_headers(mut self, headers: impl Into<ExposeHeaders>) -> Self {
359 self.expose_headers = headers.into();
360 self
361 }
362
363 /// Set the value of the [`Access-Control-Allow-Private-Network`][wicg] header.
364 ///
365 /// ```
366 /// use salvo_cors::Cors;
367 ///
368 /// let cors = Cors::new().allow_private_network(true);
369 /// ```
370 ///
371 /// [wicg]: https://wicg.github.io/private-network-access/
372 #[must_use]
373 pub fn allow_private_network<T>(mut self, allow_private_network: T) -> Self
374 where
375 T: Into<AllowPrivateNetwork>,
376 {
377 self.allow_private_network = allow_private_network.into();
378 self
379 }
380
381 /// Set the value(s) of the [`Vary`][mdn] header.
382 ///
383 /// In contrast to the other headers, this one has a non-empty default of
384 /// [`preflight_request_headers()`].
385 ///
386 /// You only need to set this is you want to remove some of these defaults,
387 /// or if you use a closure for one of the other headers and want to add a
388 /// vary header accordingly.
389 ///
390 /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
391 #[must_use]
392 pub fn vary<T>(mut self, headers: impl Into<Vary>) -> Self {
393 self.vary = headers.into();
394 self
395 }
396
397 /// Returns a new `CorsHandler` using current cors settings.
398 pub fn into_handler(self) -> CorsHandler {
399 self.ensure_usable_cors_rules();
400 CorsHandler::new(self, CallNext::default())
401 }
402
403 fn ensure_usable_cors_rules(&self) {
404 if self.allow_credentials.is_true() {
405 assert!(
406 !self.allow_headers.is_wildcard(),
407 "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
408 with `Access-Control-Allow-Headers: *`"
409 );
410
411 assert!(
412 !self.allow_methods.is_wildcard(),
413 "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
414 with `Access-Control-Allow-Methods: *`"
415 );
416
417 assert!(
418 !self.allow_origin.is_wildcard(),
419 "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
420 with `Access-Control-Allow-Origin: *`"
421 );
422
423 assert!(
424 !self.expose_headers.is_wildcard(),
425 "Invalid CORS configuration: Cannot combine `Access-Control-Allow-Credentials: true` \
426 with `Access-Control-Expose-Headers: *`"
427 );
428 }
429 }
430}
431
432/// Enum to control when to call next handler.
433#[non_exhaustive]
434#[derive(Default, Clone, Copy, Eq, PartialEq, Debug)]
435pub enum CallNext {
436 /// Call next handlers before [`CorsHandler`] write data to response.
437 #[default]
438 Before,
439 /// Call next handlers after [`CorsHandler`] write data to response.
440 After,
441}
442
443/// CorsHandler
444#[derive(Clone, Debug)]
445pub struct CorsHandler {
446 cors: Cors,
447 call_next: CallNext,
448}
449impl CorsHandler {
450 /// Create a new `CorsHandler`.
451 pub fn new(cors: Cors, call_next: CallNext) -> Self {
452 Self { cors, call_next }
453 }
454}
455
456#[async_trait]
457impl Handler for CorsHandler {
458 async fn handle(
459 &self,
460 req: &mut Request,
461 depot: &mut Depot,
462 res: &mut Response,
463 ctrl: &mut FlowCtrl,
464 ) {
465 if self.call_next == CallNext::Before {
466 ctrl.call_next(req, depot, res).await;
467 }
468
469 let origin = req.headers().get(&header::ORIGIN);
470 let mut headers = HeaderMap::new();
471
472 // These headers are applied to both preflight and subsequent regular CORS requests:
473 // https://fetch.spec.whatwg.org/#http-responses
474 headers.extend(self.cors.allow_origin.to_header(origin, req, depot).await);
475 headers.extend(
476 self.cors
477 .allow_credentials
478 .to_header(origin, req, depot)
479 .await,
480 );
481 headers.extend(
482 self.cors
483 .allow_private_network
484 .to_header(origin, req, depot)
485 .await,
486 );
487
488 let mut vary_headers = self.cors.vary.values();
489 if let Some(first) = vary_headers.next() {
490 let mut header = match headers.entry(header::VARY) {
491 header::Entry::Occupied(_) => {
492 unreachable!("no vary header inserted up to this point")
493 }
494 header::Entry::Vacant(v) => v.insert_entry(first),
495 };
496
497 for val in vary_headers {
498 header.append(val);
499 }
500 }
501
502 // Return results immediately upon preflight request
503 if req.method() == Method::OPTIONS {
504 // These headers are applied only to preflight requests
505 headers.extend(self.cors.allow_methods.to_header(origin, req, depot).await);
506 headers.extend(self.cors.allow_headers.to_header(origin, req, depot).await);
507 headers.extend(self.cors.max_age.to_header(origin, req, depot).await);
508 res.status_code = Some(StatusCode::NO_CONTENT);
509 } else {
510 // This header is applied only to non-preflight requests
511 headers.extend(self.cors.expose_headers.to_header(origin, req, depot).await);
512 }
513 res.headers_mut().extend(headers);
514
515 if self.call_next == CallNext::After {
516 ctrl.call_next(req, depot, res).await;
517 }
518 }
519}
520
521/// Iterator over the three request headers that may be involved in a CORS preflight request.
522///
523/// This is the default set of header names returned in the `vary` header
524pub fn preflight_request_headers() -> impl Iterator<Item = HeaderName> {
525 [
526 header::ORIGIN,
527 header::ACCESS_CONTROL_REQUEST_METHOD,
528 header::ACCESS_CONTROL_REQUEST_HEADERS,
529 ]
530 .into_iter()
531}
532
533#[cfg(test)]
534mod tests {
535 use salvo_core::http::header::*;
536 use salvo_core::prelude::*;
537 use salvo_core::test::TestClient;
538
539 use super::*;
540
541 #[tokio::test]
542 async fn test_cors() {
543 let cors_handler = Cors::new()
544 .allow_origin("https://salvo.rs")
545 .allow_methods(vec![Method::GET, Method::POST, Method::OPTIONS])
546 .allow_headers(vec![
547 "CONTENT-TYPE",
548 "Access-Control-Request-Method",
549 "Access-Control-Allow-Origin",
550 "Access-Control-Allow-Headers",
551 "Access-Control-Max-Age",
552 ])
553 .into_handler();
554
555 #[handler]
556 async fn hello() -> &'static str {
557 "hello"
558 }
559
560 let router = Router::new()
561 .hoop(cors_handler)
562 .push(Router::with_path("hello").goal(hello));
563 let service = Service::new(router);
564
565 async fn options_access(service: &Service, origin: &str) -> Response {
566 TestClient::options("http://127.0.0.1:5801/hello")
567 .add_header("Origin", origin, true)
568 .add_header("Access-Control-Request-Method", "POST", true)
569 .add_header("Access-Control-Request-Headers", "Content-Type", true)
570 .send(service)
571 .await
572 }
573
574 let res = TestClient::options("https://salvo.rs").send(&service).await;
575 assert!(res.headers().get(ACCESS_CONTROL_ALLOW_METHODS).is_none());
576
577 let res = options_access(&service, "https://salvo.rs").await;
578 let headers = res.headers();
579 assert!(headers.get(ACCESS_CONTROL_ALLOW_METHODS).is_some());
580 assert!(headers.get(ACCESS_CONTROL_ALLOW_HEADERS).is_some());
581
582 let res = TestClient::options("https://google.com")
583 .send(&service)
584 .await;
585 let headers = res.headers();
586 assert!(
587 headers.get(ACCESS_CONTROL_ALLOW_METHODS).is_none(),
588 "POST, GET, DELETE, OPTIONS"
589 );
590 assert!(headers.get(ACCESS_CONTROL_ALLOW_HEADERS).is_none());
591 }
592}