featherbit/plugins/native/
cors.rs1use async_trait::async_trait;
8use bytes::Bytes;
9use std::collections::HashMap;
10
11use crate::context::Context;
12use crate::plugins::{Plugin, PluginOutput, PluginResult};
13
14pub struct CorsPlugin {
23 allowed_origins: Vec<String>,
25 allowed_methods: Vec<String>,
27 allowed_headers: Vec<String>,
29 max_age: u64,
31 allow_credentials: bool,
33}
34
35impl CorsPlugin {
36 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
56 let allowed_origins = config
57 .get("allowed_origins")
58 .and_then(|v| v.as_array())
59 .map(|seq| {
60 seq.iter()
61 .filter_map(|v| v.as_str().map(String::from))
62 .collect()
63 })
64 .unwrap_or_else(|| vec!["*".to_string()]);
65
66 let allowed_methods = config
67 .get("allowed_methods")
68 .and_then(|v| v.as_array())
69 .map(|seq| {
70 seq.iter()
71 .filter_map(|v| v.as_str().map(String::from))
72 .collect()
73 })
74 .unwrap_or_else(|| {
75 vec![
76 "GET".to_string(),
77 "POST".to_string(),
78 "PUT".to_string(),
79 "DELETE".to_string(),
80 "OPTIONS".to_string(),
81 ]
82 });
83
84 let allowed_headers = config
85 .get("allowed_headers")
86 .and_then(|v| v.as_array())
87 .map(|seq| {
88 seq.iter()
89 .filter_map(|v| v.as_str().map(String::from))
90 .collect()
91 })
92 .unwrap_or_else(|| vec!["*".to_string()]);
93
94 let max_age = config
95 .get("max_age")
96 .and_then(|v| v.as_u64())
97 .unwrap_or(3600);
98
99 let allow_credentials = config
100 .get("allow_credentials")
101 .and_then(|v| v.as_bool())
102 .unwrap_or(false);
103
104 Ok(Self {
105 allowed_origins,
106 allowed_methods,
107 allowed_headers,
108 max_age,
109 allow_credentials,
110 })
111 }
112
113 fn origin_allowed(&self, origin: &str) -> bool {
116 self.allowed_origins.iter().any(|o| o == "*" || o == origin)
117 }
118}
119
120#[async_trait]
121impl Plugin for CorsPlugin {
122 fn plugin_type(&self) -> &str {
123 "cors"
124 }
125
126 async fn execute(
127 &self,
128 mut ctx: Context,
129 _named_inputs: &HashMap<String, serde_json::Value>,
130 ) -> PluginResult {
131 let origin = ctx
132 .request
133 .headers
134 .get("origin")
135 .and_then(|v| v.first())
136 .cloned()
137 .unwrap_or_default();
138
139 let is_preflight = ctx.request.method == "OPTIONS";
140
141 if self.origin_allowed(&origin) {
142 let resp_origin = if self.allowed_origins.iter().any(|o| o == "*") {
143 "*".to_string()
144 } else {
145 origin
146 };
147
148 ctx.response
149 .headers
150 .insert("access-control-allow-origin".to_string(), vec![resp_origin]);
151
152 if self.allow_credentials {
153 ctx.response.headers.insert(
154 "access-control-allow-credentials".to_string(),
155 vec!["true".to_string()],
156 );
157 }
158
159 if is_preflight {
160 ctx.response.headers.insert(
161 "access-control-allow-methods".to_string(),
162 vec![self.allowed_methods.join(", ")],
163 );
164 ctx.response.headers.insert(
165 "access-control-allow-headers".to_string(),
166 vec![self.allowed_headers.join(", ")],
167 );
168 ctx.response.headers.insert(
169 "access-control-max-age".to_string(),
170 vec![self.max_age.to_string()],
171 );
172 ctx.response.status_code = 204;
174 ctx.response.body = Bytes::new();
175 }
176 }
177
178 Ok(PluginOutput {
179 context: ctx,
180 named_outputs: HashMap::new(),
181 })
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
192 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
193
194 fn ctx(method: &str, origin: Option<&str>) -> Context {
196 let mut headers = HashMap::new();
197 if let Some(o) = origin {
198 headers.insert("origin".to_string(), vec![o.to_string()]);
199 }
200 Context {
201 request: GatewayRequest {
202 method: method.to_string(),
203 path: "/hello".to_string(),
204 host: "h".to_string(),
205 scheme: "http".to_string(),
206 headers,
207 query_params: HashMap::new(),
208 body: Bytes::new(),
209 remote_addr: "1.2.3.4:5".to_string(),
210 protocol: Protocol::Http1,
211 },
212 response: GatewayResponse {
213 status_code: 0,
214 headers: HashMap::new(),
215 body: Bytes::new(),
216 },
217 message: HashMap::new(),
218 errors: Vec::new(),
219 }
220 }
221
222 fn plugin(config: serde_json::Value) -> CorsPlugin {
223 let map: HashMap<String, serde_json::Value> =
224 config.as_object().unwrap().clone().into_iter().collect();
225 CorsPlugin::from_config(&map).unwrap()
226 }
227
228 fn hdr<'a>(ctx: &'a Context, name: &str) -> Option<&'a str> {
230 ctx.response
231 .headers
232 .get(name)
233 .and_then(|v| v.first())
234 .map(String::as_str)
235 }
236
237 #[tokio::test]
239 async fn test_default_config_allows_any_origin() {
240 let out = plugin(serde_json::json!({}))
241 .execute(ctx("GET", Some("http://anything.example")), &HashMap::new())
242 .await
243 .unwrap();
244 assert_eq!(hdr(&out.context, "access-control-allow-origin"), Some("*"));
245 }
246
247 #[tokio::test]
249 async fn test_specific_origin_matched() {
250 let out = plugin(serde_json::json!({
251 "allowed_origins": ["http://sub.domain.com", "http://sub2.domain.com"]
252 }))
253 .execute(ctx("GET", Some("http://sub2.domain.com")), &HashMap::new())
254 .await
255 .unwrap();
256 assert_eq!(
258 hdr(&out.context, "access-control-allow-origin"),
259 Some("http://sub2.domain.com")
260 );
261 }
262
263 #[tokio::test]
265 async fn test_non_matching_origin_rejected() {
266 let out = plugin(serde_json::json!({
267 "allowed_origins": ["http://sub.domain.com"]
268 }))
269 .execute(ctx("GET", Some("http://evil.example")), &HashMap::new())
270 .await
271 .unwrap();
272 assert_eq!(hdr(&out.context, "access-control-allow-origin"), None);
273 }
274
275 #[tokio::test]
278 async fn test_no_origin_header_no_cors() {
279 let out = plugin(serde_json::json!({
280 "allowed_origins": ["http://sub.domain.com"]
281 }))
282 .execute(ctx("GET", None), &HashMap::new())
283 .await
284 .unwrap();
285 assert_eq!(hdr(&out.context, "access-control-allow-origin"), None);
286 }
287
288 #[tokio::test]
290 async fn test_allow_credentials_header() {
291 let out = plugin(serde_json::json!({
292 "allowed_origins": ["http://sub.domain.com"],
293 "allow_credentials": true
294 }))
295 .execute(ctx("GET", Some("http://sub.domain.com")), &HashMap::new())
296 .await
297 .unwrap();
298 assert_eq!(
299 hdr(&out.context, "access-control-allow-credentials"),
300 Some("true")
301 );
302 }
303
304 #[tokio::test]
312 async fn test_preflight_prepares_204() {
313 let out = plugin(serde_json::json!({
314 "allowed_origins": ["http://sub.domain.com"],
315 "allowed_methods": ["GET", "POST"],
316 "max_age": 50
317 }))
318 .execute(
319 ctx("OPTIONS", Some("http://sub.domain.com")),
320 &HashMap::new(),
321 )
322 .await
323 .unwrap();
324 assert_eq!(out.context.response.status_code, 204);
325 assert_eq!(
326 hdr(&out.context, "access-control-allow-methods"),
327 Some("GET, POST")
328 );
329 assert_eq!(hdr(&out.context, "access-control-max-age"), Some("50"));
330 assert!(out.context.response.body.is_empty());
331 }
332
333 #[tokio::test]
336 async fn test_preflight_disallowed_origin_untouched() {
337 let out = plugin(serde_json::json!({
338 "allowed_origins": ["http://sub.domain.com"]
339 }))
340 .execute(ctx("OPTIONS", Some("http://evil.example")), &HashMap::new())
341 .await
342 .unwrap();
343 assert_ne!(out.context.response.status_code, 204);
344 assert_eq!(hdr(&out.context, "access-control-allow-origin"), None);
345 }
346}