Skip to main content

featherbit/plugins/native/
cors.rs

1//! CORS plugin (`cors`).
2//!
3//! Adds `Access-Control-*` response headers for allowed origins and answers
4//! `OPTIONS` preflight requests with a prepared 204, exiting through the
5//! dedicated `preflight` port so the engine routes the response straight to
6//! the client instead of continuing to `upstream`. Never errors: disallowed
7//! origins simply pass through on `success` without CORS headers.
8
9use 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
17/// Applies CORS response headers based on the request's `Origin` header.
18///
19/// For an allowed origin the plugin sets `access-control-allow-origin`
20/// (echoing the origin, or `*` when wildcarded) and, when enabled,
21/// `access-control-allow-credentials`. For preflight (`OPTIONS`) requests it
22/// additionally sets the allow-methods/allow-headers/max-age headers,
23/// prepares a 204 empty response, and exits through the `preflight` port —
24/// the policy must wire that port (typically straight to `client`) or
25/// compilation rejects it. Does not write to `context.message` and always
26/// succeeds.
27pub struct CorsPlugin {
28    /// Origins granted CORS access; `"*"` matches any origin. Never
29    /// templated — semantic tokens (`*`/origin-echo) stay literal.
30    allowed_origins: Vec<String>,
31    /// Methods advertised in preflight responses. Values support
32    /// `{{namespace.path}}` references (no legacy `$var` interpolation —
33    /// these headers never supported it, so this sweep must not start).
34    allowed_methods: Vec<Template>,
35    /// Request headers advertised in preflight responses; may be `"*"`.
36    /// Values support `{{namespace.path}}` references, same as
37    /// `allowed_methods`.
38    allowed_headers: Vec<Template>,
39    /// Preflight cache lifetime in seconds (`access-control-max-age`).
40    max_age: u64,
41    /// Whether to emit `access-control-allow-credentials: true`.
42    allow_credentials: bool,
43}
44
45impl CorsPlugin {
46    /// Builds the plugin from node config. Never fails; every key has a
47    /// default.
48    ///
49    /// Accepted keys:
50    /// - `allowed_origins` (array of strings, default `["*"]`) — never
51    ///   templated; semantic tokens (`*`/origin-echo) stay literal.
52    /// - `allowed_methods` (array of strings, default
53    ///   `["GET", "POST", "PUT", "DELETE", "OPTIONS"]`); values support
54    ///   `{{namespace.path}}` references.
55    /// - `allowed_headers` (array of strings, default `["*"]`); values
56    ///   support `{{namespace.path}}` references.
57    /// - `max_age` (integer seconds, default `3600`)
58    /// - `allow_credentials` (bool, default `false`)
59    ///
60    /// ```yaml
61    /// type: cors
62    /// config:
63    ///   allowed_origins: ["https://app.example.com"]
64    ///   allowed_methods: ["GET", "POST"]
65    ///   max_age: 600
66    ///   allow_credentials: true
67    /// ```
68    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        // Discard warnings here — the compile-time walk (a later task)
80        // reports well-formed-but-unknown references; execution must not.
81        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    /// Returns true when the origin exactly matches an allowed origin or the
137    /// list contains the `"*"` wildcard.
138    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                // Short-circuit: the 204 is fully prepared, exit on the
202                // dedicated `preflight` port rather than continuing to
203                // `success` (and from there to `upstream`).
204                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    //! Behavioral tests translated from Apache APISIX's `t/plugin/cors.t`,
217    //! adapted to featherbit's config keys and its documented subset of the
218    //! APISIX plugin (no regex origins, no `expose_headers`, no `**` force mode).
219    //! The APISIX `=== TEST N` each scenario derives from is noted inline.
220    use super::*;
221    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
222
223    /// Builds a request context with the given method and optional Origin header.
224    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    /// First value of a response header, or None.
259    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    /// APISIX TEST 6-7: default config echoes `*` for any origin.
268    #[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    /// APISIX TEST 8-9: a specific allowed origin is echoed back.
278    #[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        // The matched origin is echoed, not `*`.
287        assert_eq!(
288            hdr(&out.context, "access-control-allow-origin"),
289            Some("http://sub2.domain.com")
290        );
291    }
292
293    /// APISIX TEST 10: an origin not in the allowlist gets no CORS headers.
294    #[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    /// APISIX TEST 37: a request with no Origin header produces no CORS headers
306    /// and no error.
307    #[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    /// APISIX TEST 8-9: `allow_credentials` emits the credentials header.
319    #[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    /// APISIX TEST 14: an OPTIONS preflight on an allowed origin exits on the
335    /// `preflight` port with the 204 fully prepared — the engine routes it
336    /// away from upstream (E2E-DP-09 covers the end-to-end short-circuit).
337    #[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    /// Non-preflight requests and disallowed origins stay on success.
358    #[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    /// `allowed_methods`/`allowed_headers` values render `{{namespace.path}}`
368    /// references per request.
369    #[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    /// A preflight for a *disallowed* origin is not short-circuited (no 204, no
399    /// CORS headers) — it falls through on success untouched.
400    #[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}