featherbit/plugins/native/
cors.rs1use async_trait::async_trait;
10use bytes::Bytes;
11use std::collections::HashMap;
12
13use crate::context::Context;
14use crate::plugins::{Plugin, PluginOutput, PluginResult};
15use crate::vars::template::Template;
16
17pub struct CorsPlugin {
28 allowed_origins: Vec<String>,
31 allowed_methods: Vec<Template>,
35 allowed_headers: Vec<Template>,
39 max_age: u64,
41 allow_credentials: bool,
43}
44
45impl CorsPlugin {
46 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
69 let allowed_origins = config
70 .get("allowed_origins")
71 .and_then(|v| v.as_array())
72 .map(|seq| {
73 seq.iter()
74 .filter_map(|v| v.as_str().map(String::from))
75 .collect()
76 })
77 .unwrap_or_else(|| vec!["*".to_string()]);
78
79 let allowed_methods: Vec<String> = config
82 .get("allowed_methods")
83 .and_then(|v| v.as_array())
84 .map(|seq| {
85 seq.iter()
86 .filter_map(|v| v.as_str().map(String::from))
87 .collect()
88 })
89 .unwrap_or_else(|| {
90 vec![
91 "GET".to_string(),
92 "POST".to_string(),
93 "PUT".to_string(),
94 "DELETE".to_string(),
95 "OPTIONS".to_string(),
96 ]
97 });
98 let allowed_methods = allowed_methods
99 .into_iter()
100 .map(|s| Template::parse(&s).0)
101 .collect();
102
103 let allowed_headers: Vec<String> = config
104 .get("allowed_headers")
105 .and_then(|v| v.as_array())
106 .map(|seq| {
107 seq.iter()
108 .filter_map(|v| v.as_str().map(String::from))
109 .collect()
110 })
111 .unwrap_or_else(|| vec!["*".to_string()]);
112 let allowed_headers = allowed_headers
113 .into_iter()
114 .map(|s| Template::parse(&s).0)
115 .collect();
116
117 let max_age = config
118 .get("max_age")
119 .and_then(|v| v.as_u64())
120 .unwrap_or(3600);
121
122 let allow_credentials = config
123 .get("allow_credentials")
124 .and_then(|v| v.as_bool())
125 .unwrap_or(false);
126
127 Ok(Self {
128 allowed_origins,
129 allowed_methods,
130 allowed_headers,
131 max_age,
132 allow_credentials,
133 })
134 }
135
136 fn origin_allowed(&self, origin: &str) -> bool {
139 self.allowed_origins.iter().any(|o| o == "*" || o == origin)
140 }
141}
142
143#[async_trait]
144impl Plugin for CorsPlugin {
145 fn plugin_type(&self) -> &str {
146 "cors"
147 }
148
149 async fn execute(&self, mut ctx: Context) -> PluginResult {
150 let origin = ctx
151 .request
152 .headers
153 .get("origin")
154 .and_then(|v| v.first())
155 .cloned()
156 .unwrap_or_default();
157
158 let is_preflight = ctx.request.method == "OPTIONS";
159
160 if self.origin_allowed(&origin) {
161 let resp_origin = if self.allowed_origins.iter().any(|o| o == "*") {
162 "*".to_string()
163 } else {
164 origin
165 };
166
167 ctx.response
168 .headers
169 .insert("access-control-allow-origin".to_string(), vec![resp_origin]);
170
171 if self.allow_credentials {
172 ctx.response.headers.insert(
173 "access-control-allow-credentials".to_string(),
174 vec!["true".to_string()],
175 );
176 }
177
178 if is_preflight {
179 let methods = self
180 .allowed_methods
181 .iter()
182 .map(|tmpl| tmpl.render(&ctx))
183 .collect::<Vec<_>>()
184 .join(", ");
185 let headers = self
186 .allowed_headers
187 .iter()
188 .map(|tmpl| tmpl.render(&ctx))
189 .collect::<Vec<_>>()
190 .join(", ");
191 ctx.response
192 .headers
193 .insert("access-control-allow-methods".to_string(), vec![methods]);
194 ctx.response
195 .headers
196 .insert("access-control-allow-headers".to_string(), vec![headers]);
197 ctx.response.headers.insert(
198 "access-control-max-age".to_string(),
199 vec![self.max_age.to_string()],
200 );
201 ctx.response.status_code = 204;
205 ctx.response.body = Bytes::new();
206 return Ok(PluginOutput::on_port(ctx, "preflight"));
207 }
208 }
209
210 Ok(PluginOutput::success(ctx))
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
221 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
222
223 fn ctx(method: &str, origin: Option<&str>) -> Context {
225 let mut headers = HashMap::new();
226 if let Some(o) = origin {
227 headers.insert("origin".to_string(), vec![o.to_string()]);
228 }
229 Context {
230 request: GatewayRequest {
231 method: method.to_string(),
232 path: "/hello".to_string(),
233 host: "h".to_string(),
234 scheme: "http".to_string(),
235 headers,
236 query_params: HashMap::new(),
237 body: Bytes::new(),
238 remote_addr: "1.2.3.4:5".to_string(),
239 protocol: Protocol::Http1,
240 },
241 response: GatewayResponse {
242 status_code: 0,
243 headers: HashMap::new(),
244 body: Bytes::new(),
245 stream: None,
246 },
247 message: HashMap::new(),
248 errors: Vec::new(),
249 }
250 }
251
252 fn plugin(config: serde_json::Value) -> CorsPlugin {
253 let map: HashMap<String, serde_json::Value> =
254 config.as_object().unwrap().clone().into_iter().collect();
255 CorsPlugin::from_config(&map).unwrap()
256 }
257
258 fn hdr<'a>(ctx: &'a Context, name: &str) -> Option<&'a str> {
260 ctx.response
261 .headers
262 .get(name)
263 .and_then(|v| v.first())
264 .map(String::as_str)
265 }
266
267 #[tokio::test]
269 async fn test_default_config_allows_any_origin() {
270 let out = plugin(serde_json::json!({}))
271 .execute(ctx("GET", Some("http://anything.example")))
272 .await
273 .unwrap();
274 assert_eq!(hdr(&out.context, "access-control-allow-origin"), Some("*"));
275 }
276
277 #[tokio::test]
279 async fn test_specific_origin_matched() {
280 let out = plugin(serde_json::json!({
281 "allowed_origins": ["http://sub.domain.com", "http://sub2.domain.com"]
282 }))
283 .execute(ctx("GET", Some("http://sub2.domain.com")))
284 .await
285 .unwrap();
286 assert_eq!(
288 hdr(&out.context, "access-control-allow-origin"),
289 Some("http://sub2.domain.com")
290 );
291 }
292
293 #[tokio::test]
295 async fn test_non_matching_origin_rejected() {
296 let out = plugin(serde_json::json!({
297 "allowed_origins": ["http://sub.domain.com"]
298 }))
299 .execute(ctx("GET", Some("http://evil.example")))
300 .await
301 .unwrap();
302 assert_eq!(hdr(&out.context, "access-control-allow-origin"), None);
303 }
304
305 #[tokio::test]
308 async fn test_no_origin_header_no_cors() {
309 let out = plugin(serde_json::json!({
310 "allowed_origins": ["http://sub.domain.com"]
311 }))
312 .execute(ctx("GET", None))
313 .await
314 .unwrap();
315 assert_eq!(hdr(&out.context, "access-control-allow-origin"), None);
316 }
317
318 #[tokio::test]
320 async fn test_allow_credentials_header() {
321 let out = plugin(serde_json::json!({
322 "allowed_origins": ["http://sub.domain.com"],
323 "allow_credentials": true
324 }))
325 .execute(ctx("GET", Some("http://sub.domain.com")))
326 .await
327 .unwrap();
328 assert_eq!(
329 hdr(&out.context, "access-control-allow-credentials"),
330 Some("true")
331 );
332 }
333
334 #[tokio::test]
338 async fn test_preflight_exits_on_preflight_port() {
339 let out = plugin(serde_json::json!({
340 "allowed_origins": ["http://sub.domain.com"],
341 "allowed_methods": ["GET", "POST"],
342 "max_age": 50
343 }))
344 .execute(ctx("OPTIONS", Some("http://sub.domain.com")))
345 .await
346 .unwrap();
347 assert_eq!(out.port, Some("preflight"));
348 assert_eq!(out.context.response.status_code, 204);
349 assert_eq!(
350 hdr(&out.context, "access-control-allow-methods"),
351 Some("GET, POST")
352 );
353 assert_eq!(hdr(&out.context, "access-control-max-age"), Some("50"));
354 assert!(out.context.response.body.is_empty());
355 }
356
357 #[tokio::test]
359 async fn test_non_preflight_stays_on_success() {
360 let out = plugin(serde_json::json!({}))
361 .execute(ctx("GET", Some("http://x.example")))
362 .await
363 .unwrap();
364 assert_eq!(out.port, None);
365 }
366
367 #[tokio::test]
370 async fn test_preflight_headers_and_methods_render_template() {
371 let out = plugin(serde_json::json!({
372 "allowed_origins": ["http://sub.domain.com"],
373 "allowed_methods": ["GET", "{{request.headers.x-extra-method}}"],
374 "allowed_headers": ["{{request.headers.x-extra-header}}"]
375 }))
376 .execute({
377 let mut c = ctx("OPTIONS", Some("http://sub.domain.com"));
378 c.request
379 .headers
380 .insert("x-extra-method".to_string(), vec!["PATCH".to_string()]);
381 c.request
382 .headers
383 .insert("x-extra-header".to_string(), vec!["x-custom".to_string()]);
384 c
385 })
386 .await
387 .unwrap();
388 assert_eq!(
389 hdr(&out.context, "access-control-allow-methods"),
390 Some("GET, PATCH")
391 );
392 assert_eq!(
393 hdr(&out.context, "access-control-allow-headers"),
394 Some("x-custom")
395 );
396 }
397
398 #[tokio::test]
401 async fn test_preflight_disallowed_origin_untouched() {
402 let out = plugin(serde_json::json!({
403 "allowed_origins": ["http://sub.domain.com"]
404 }))
405 .execute(ctx("OPTIONS", Some("http://evil.example")))
406 .await
407 .unwrap();
408 assert_eq!(out.port, None);
409 assert_ne!(out.context.response.status_code, 204);
410 assert_eq!(hdr(&out.context, "access-control-allow-origin"), None);
411 }
412}