Skip to main content

featherbit/plugins/native/
cors.rs

1//! CORS plugin (`cors`).
2//!
3//! Adds `Access-Control-*` response headers for allowed origins and
4//! short-circuits `OPTIONS` preflight requests with a 204 response. Never
5//! errors: disallowed origins simply pass through without CORS headers.
6
7use async_trait::async_trait;
8use bytes::Bytes;
9use std::collections::HashMap;
10
11use crate::context::Context;
12use crate::plugins::{Plugin, PluginOutput, PluginResult};
13
14/// Applies CORS response headers based on the request's `Origin` header.
15///
16/// For an allowed origin the plugin sets `access-control-allow-origin`
17/// (echoing the origin, or `*` when wildcarded) and, when enabled,
18/// `access-control-allow-credentials`. For preflight (`OPTIONS`) requests it
19/// additionally sets the allow-methods/allow-headers/max-age headers and
20/// short-circuits with a 204 empty response. Does not write to
21/// `context.message` and always succeeds.
22pub struct CorsPlugin {
23    /// Origins granted CORS access; `"*"` matches any origin.
24    allowed_origins: Vec<String>,
25    /// Methods advertised in preflight responses.
26    allowed_methods: Vec<String>,
27    /// Request headers advertised in preflight responses; may be `"*"`.
28    allowed_headers: Vec<String>,
29    /// Preflight cache lifetime in seconds (`access-control-max-age`).
30    max_age: u64,
31    /// Whether to emit `access-control-allow-credentials: true`.
32    allow_credentials: bool,
33}
34
35impl CorsPlugin {
36    /// Builds the plugin from node config. Never fails; every key has a
37    /// default.
38    ///
39    /// Accepted keys:
40    /// - `allowed_origins` (array of strings, default `["*"]`)
41    /// - `allowed_methods` (array of strings, default
42    ///   `["GET", "POST", "PUT", "DELETE", "OPTIONS"]`)
43    /// - `allowed_headers` (array of strings, default `["*"]`)
44    /// - `max_age` (integer seconds, default `3600`)
45    /// - `allow_credentials` (bool, default `false`)
46    ///
47    /// ```yaml
48    /// type: cors
49    /// config:
50    ///   allowed_origins: ["https://app.example.com"]
51    ///   allowed_methods: ["GET", "POST"]
52    ///   max_age: 600
53    ///   allow_credentials: true
54    /// ```
55    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    /// Returns true when the origin exactly matches an allowed origin or the
114    /// list contains the `"*"` wildcard.
115    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                // Short-circuit: return 204 for preflight
173                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    //! Behavioral tests translated from Apache APISIX's `t/plugin/cors.t`,
188    //! adapted to featherbit's config keys and its documented subset of the
189    //! APISIX plugin (no regex origins, no `expose_headers`, no `**` force mode).
190    //! The APISIX `=== TEST N` each scenario derives from is noted inline.
191    use super::*;
192    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
193
194    /// Builds a request context with the given method and optional Origin header.
195    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    /// First value of a response header, or None.
229    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    /// APISIX TEST 6-7: default config echoes `*` for any origin.
238    #[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    /// APISIX TEST 8-9: a specific allowed origin is echoed back.
248    #[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        // The matched origin is echoed, not `*`.
257        assert_eq!(
258            hdr(&out.context, "access-control-allow-origin"),
259            Some("http://sub2.domain.com")
260        );
261    }
262
263    /// APISIX TEST 10: an origin not in the allowlist gets no CORS headers.
264    #[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    /// APISIX TEST 37: a request with no Origin header produces no CORS headers
276    /// and no error.
277    #[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    /// APISIX TEST 8-9: `allow_credentials` emits the credentials header.
289    #[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    /// APISIX TEST 14: an OPTIONS preflight on an allowed origin is answered with
305    /// 204 and the advertised methods/headers/max-age.
306    ///
307    /// NOTE: this is the *plugin-level* contract — the plugin prepares the 204.
308    /// End-to-end the graph engine still walks the success edge into `upstream`,
309    /// so a real preflight is not short-circuited; that gap is tracked by the
310    /// (expected-failure) `E2E-DP-09` e2e test, not here.
311    #[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    /// A preflight for a *disallowed* origin is not short-circuited (no 204, no
334    /// CORS headers) — it falls through untouched.
335    #[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}