1use 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 Exact(String),
15 List(Vec<String>),
17}
18
19#[derive(Clone)]
21pub struct Cors {
22 origin: OriginMode,
23 origin_explicit: bool,
24 methods: String,
25 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 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 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 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 pub fn headers(mut self, headers: impl Into<String>) -> Self {
69 self.headers = headers.into();
70 self
71 }
72
73 pub fn exposed(mut self, headers: impl Into<String>) -> Self {
75 self.exposed = Some(headers.into());
76 self
77 }
78
79 pub fn credentials(mut self, on: bool) -> Self {
84 self.credentials = on;
85 self.credentials_explicit = true;
86 self
87 }
88
89 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 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}