Skip to main content

featherbit/plugins/native/
csrf.rs

1//! CSRF protection plugin (`csrf`).
2//!
3//! Port of APISIX's `csrf` plugin using the double-submit-cookie pattern:
4//! safe methods (`GET`/`HEAD`/`OPTIONS`) pass through and receive a signed
5//! token cookie; unsafe methods must send the same token in both the cookie
6//! and a request header, with a valid HMAC signature and unexpired timestamp.
7//! Failures are routed through the error port as 401 with code `CSRF_INVALID`.
8//!
9//! Token layout mirrors APISIX (`base64(json{random, expires, sign})`) but the
10//! signature is `hex(HMAC-SHA256(key, random || expires))` via `ring` instead
11//! of APISIX's plain SHA-256 over a Lua-formatted string, so featherbit tokens
12//! only round-trip against featherbit — they are not APISIX-compatible.
13//!
14//! APISIX sets the cookie in its `header_filter` phase (after the upstream
15//! response). featherbit's `upstream` node replaces `context.response.headers`
16//! wholesale, so a cookie set before proxying would be lost. The `phase`
17//! option maps APISIX's two phases onto the node graph: place a
18//! `phase: request` node before the upstream (validation) and a
19//! `phase: response` node after it (cookie issuance) sharing the same `key`.
20
21use async_trait::async_trait;
22use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
23use bytes::Bytes;
24use ring::hmac;
25use ring::rand::{SecureRandom, SystemRandom};
26use std::collections::HashMap;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use crate::context::{Context, GatewayError};
30use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
31
32const SAFE_METHODS: [&str; 3] = ["GET", "HEAD", "OPTIONS"];
33
34/// Double-submit-cookie CSRF protection keyed by an HMAC secret.
35///
36/// In `phase: request` (default), unsafe methods must present matching
37/// header and cookie tokens carrying a valid signature; safe methods pass
38/// through (and get a token cookie, though a downstream `upstream` node will
39/// replace it — see the module docs). In `phase: response` the node only
40/// issues the token cookie, mirroring APISIX's `header_filter`.
41pub struct CsrfPlugin {
42    /// HMAC-SHA256 key derived from the configured secret.
43    key: hmac::Key,
44    /// Token lifetime in seconds; `0` disables the expiry check.
45    expires: u64,
46    /// Cookie and header name carrying the token (lowercased for lookup).
47    name: String,
48    /// Which side of the exchange this node handles.
49    phase: CsrfPhase,
50}
51
52/// Which APISIX phase this node instance emulates.
53#[derive(Debug, Clone, PartialEq)]
54enum CsrfPhase {
55    /// Validate unsafe methods (APISIX `access`).
56    Request,
57    /// Issue the token cookie on the response (APISIX `header_filter`).
58    Response,
59}
60
61/// Hex-encodes a byte slice (lowercase).
62fn hex_encode(bytes: &[u8]) -> String {
63    let mut out = String::with_capacity(bytes.len() * 2);
64    for b in bytes {
65        out.push_str(&format!("{:02x}", b));
66    }
67    out
68}
69
70/// Decodes a lowercase/uppercase hex string; `None` on invalid input.
71fn hex_decode(s: &str) -> Option<Vec<u8>> {
72    if !s.len().is_multiple_of(2) {
73        return None;
74    }
75    (0..s.len())
76        .step_by(2)
77        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
78        .collect()
79}
80
81/// Current unix timestamp in seconds.
82fn now() -> u64 {
83    SystemTime::now()
84        .duration_since(UNIX_EPOCH)
85        .map(|d| d.as_secs())
86        .unwrap_or(0)
87}
88
89impl CsrfPlugin {
90    /// Builds the plugin from node config.
91    ///
92    /// Accepted keys:
93    /// - `key` (string, **required**, non-empty): HMAC secret used to sign
94    ///   tokens.
95    /// - `expires` (integer seconds, default `7200`): token lifetime; `0`
96    ///   disables the expiry check and makes the cookie a session cookie.
97    /// - `name` (string, default `"featherbit-csrf-token"`): cookie **and**
98    ///   request-header name carrying the token.
99    /// - `phase` (string, default `request`): `request` validates unsafe
100    ///   methods; `response` only issues the token cookie (place it after the
101    ///   upstream node).
102    ///
103    /// ```yaml
104    /// type: csrf
105    /// config:
106    ///   key: edd1c9f034335f136f87ad84b625c8f1
107    ///   expires: 3600
108    ///   name: featherbit-csrf-token
109    /// ```
110    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
111        let secret = config
112            .get("key")
113            .and_then(|v| v.as_str())
114            .filter(|s| !s.is_empty())
115            .ok_or_else(|| "csrf: key is required and must be a non-empty string".to_string())?;
116
117        let expires = match config.get("expires") {
118            None => 7200,
119            Some(v) => v
120                .as_u64()
121                .ok_or_else(|| "csrf: expires must be a non-negative integer".to_string())?,
122        };
123
124        let name = config
125            .get("name")
126            .and_then(|v| v.as_str())
127            .filter(|s| !s.is_empty())
128            .unwrap_or("featherbit-csrf-token")
129            .to_lowercase();
130
131        let phase = match config.get("phase").and_then(|v| v.as_str()) {
132            Some("response") => CsrfPhase::Response,
133            _ => CsrfPhase::Request,
134        };
135
136        Ok(Self {
137            key: hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes()),
138            expires,
139            name,
140            phase,
141        })
142    }
143
144    /// Computes the token signature: `hex(HMAC-SHA256(key, random || expires))`.
145    fn sign(&self, random: &str, expires: u64) -> String {
146        let tag = hmac::sign(&self.key, format!("{}{}", random, expires).as_bytes());
147        hex_encode(tag.as_ref())
148    }
149
150    /// Builds a token issued at timestamp `ts`:
151    /// `base64(json{random, expires: ts, sign})`.
152    fn token_at(&self, ts: u64) -> String {
153        let mut random_bytes = [0u8; 16];
154        // On the vanishingly unlikely RNG failure the buffer stays zeroed;
155        // the token remains valid, just not random.
156        let _ = SystemRandom::new().fill(&mut random_bytes);
157        let random = hex_encode(&random_bytes);
158        let token = serde_json::json!({
159            "random": random,
160            "expires": ts,
161            "sign": self.sign(&random, ts),
162        });
163        BASE64.encode(token.to_string())
164    }
165
166    /// Generates a fresh token for the current time.
167    fn gen_token(&self) -> String {
168        self.token_at(now())
169    }
170
171    /// Verifies a token's structure, expiry, and signature.
172    fn check_token(&self, token: &str) -> bool {
173        let Ok(decoded) = BASE64.decode(token) else {
174            return false;
175        };
176        let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
177            return false;
178        };
179        let Some(random) = parsed.get("random").and_then(|v| v.as_str()) else {
180            return false;
181        };
182        let Some(expires) = parsed.get("expires").and_then(|v| v.as_u64()) else {
183            return false;
184        };
185        if self.expires > 0 && now().saturating_sub(expires) > self.expires {
186            return false;
187        }
188        let Some(sign) = parsed.get("sign").and_then(|v| v.as_str()) else {
189            return false;
190        };
191        let Some(sign_bytes) = hex_decode(sign) else {
192            return false;
193        };
194        hmac::verify(
195            &self.key,
196            format!("{}{}", random, expires).as_bytes(),
197            &sign_bytes,
198        )
199        .is_ok()
200    }
201
202    /// Appends the token `Set-Cookie` header to the response.
203    ///
204    /// Deviation: uses `Max-Age` instead of APISIX's `Expires` date (omitted
205    /// when `expires` is `0`, yielding a session cookie).
206    fn set_cookie(&self, ctx: &mut Context) {
207        let max_age = if self.expires > 0 {
208            format!(";Max-Age={}", self.expires)
209        } else {
210            String::new()
211        };
212        let cookie = format!(
213            "{}={};path=/;SameSite=Lax{}",
214            self.name,
215            self.gen_token(),
216            max_age
217        );
218        ctx.response
219            .headers
220            .entry("set-cookie".to_string())
221            .or_default()
222            .push(cookie);
223    }
224
225    /// Builds the 401 rejection routed through the error port with code
226    /// `CSRF_INVALID`.
227    fn reject(&self, mut ctx: Context, msg: &str) -> PluginResult {
228        ctx.response.status_code = 401;
229        ctx.response.body = Bytes::from(serde_json::json!({ "error_msg": msg }).to_string());
230        ctx.response.headers.insert(
231            "content-type".to_string(),
232            vec!["application/json".to_string()],
233        );
234        Err(PluginExecutionError {
235            context: ctx,
236            error: GatewayError {
237                node_id: String::new(),
238                code: "CSRF_INVALID".to_string(),
239                message: msg.to_string(),
240                metadata: HashMap::new(),
241            },
242        })
243    }
244}
245
246#[async_trait]
247impl Plugin for CsrfPlugin {
248    fn plugin_type(&self) -> &str {
249        "csrf"
250    }
251
252    async fn execute(
253        &self,
254        mut ctx: Context,
255        _named_inputs: &HashMap<String, serde_json::Value>,
256    ) -> PluginResult {
257        if self.phase == CsrfPhase::Response {
258            // Cookie issuance only (APISIX header_filter).
259            self.set_cookie(&mut ctx);
260            return Ok(PluginOutput {
261                context: ctx,
262                named_outputs: HashMap::new(),
263            });
264        }
265
266        if SAFE_METHODS.contains(&ctx.request.method.as_str()) {
267            self.set_cookie(&mut ctx);
268            return Ok(PluginOutput {
269                context: ctx,
270                named_outputs: HashMap::new(),
271            });
272        }
273
274        let header_token = ctx
275            .request
276            .headers
277            .get(&self.name)
278            .and_then(|v| v.first())
279            .cloned()
280            .unwrap_or_default();
281        if header_token.is_empty() {
282            return self.reject(ctx, "no csrf token in headers");
283        }
284
285        let cookie_token =
286            crate::vars::resolve(&ctx, &format!("cookie_{}", self.name)).map(|v| v.into_owned());
287        let Some(cookie_token) = cookie_token else {
288            return self.reject(ctx, "no csrf cookie");
289        };
290
291        if header_token != cookie_token {
292            return self.reject(ctx, "csrf token mismatch");
293        }
294
295        if !self.check_token(&cookie_token) {
296            return self.reject(ctx, "Failed to verify the csrf token signature");
297        }
298
299        self.set_cookie(&mut ctx);
300        Ok(PluginOutput {
301            context: ctx,
302            named_outputs: HashMap::new(),
303        })
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
311
312    const NAME: &str = "featherbit-csrf-token";
313
314    fn test_context(method: &str) -> Context {
315        Context {
316            request: GatewayRequest {
317                method: method.to_string(),
318                path: "/test".to_string(),
319                host: "localhost".to_string(),
320                scheme: "http".to_string(),
321                headers: HashMap::new(),
322                query_params: HashMap::new(),
323                body: Bytes::new(),
324                remote_addr: "127.0.0.1:12345".to_string(),
325                protocol: Protocol::Http1,
326            },
327            response: GatewayResponse {
328                status_code: 0,
329                headers: HashMap::new(),
330                body: Bytes::new(),
331            },
332            message: HashMap::new(),
333            errors: Vec::new(),
334        }
335    }
336
337    fn with_tokens(method: &str, header_token: &str, cookie_token: &str) -> Context {
338        let mut ctx = test_context(method);
339        ctx.request
340            .headers
341            .insert(NAME.to_string(), vec![header_token.to_string()]);
342        ctx.request.headers.insert(
343            "cookie".to_string(),
344            vec![format!("{}={}", NAME, cookie_token)],
345        );
346        ctx
347    }
348
349    fn plugin(json: serde_json::Value) -> CsrfPlugin {
350        CsrfPlugin::from_config(&serde_json::from_value(json).unwrap()).unwrap()
351    }
352
353    #[test]
354    fn test_config_requires_key() {
355        let empty: HashMap<String, serde_json::Value> = HashMap::new();
356        assert!(CsrfPlugin::from_config(&empty).is_err());
357        assert!(CsrfPlugin::from_config(
358            &serde_json::from_value(serde_json::json!({"key": ""})).unwrap()
359        )
360        .is_err());
361        assert!(CsrfPlugin::from_config(
362            &serde_json::from_value(serde_json::json!({"key": "secret", "expires": -1})).unwrap()
363        )
364        .is_err());
365        assert!(CsrfPlugin::from_config(
366            &serde_json::from_value(serde_json::json!({"key": "secret"})).unwrap()
367        )
368        .is_ok());
369    }
370
371    #[tokio::test]
372    async fn test_safe_method_passes_and_sets_cookie() {
373        let p = plugin(serde_json::json!({"key": "secret"}));
374        let out = p
375            .execute(test_context("GET"), &HashMap::new())
376            .await
377            .unwrap();
378        let cookies = out.context.response.headers.get("set-cookie").unwrap();
379        assert_eq!(cookies.len(), 1);
380        assert!(cookies[0].starts_with(&format!("{}=", NAME)));
381        assert!(cookies[0].contains("SameSite=Lax"));
382        assert!(cookies[0].contains("Max-Age=7200"));
383    }
384
385    #[tokio::test]
386    async fn test_response_phase_only_sets_cookie() {
387        let p = plugin(serde_json::json!({"key": "secret", "phase": "response"}));
388        // even for an unsafe method, response phase never validates
389        let out = p
390            .execute(test_context("POST"), &HashMap::new())
391            .await
392            .unwrap();
393        assert!(out.context.response.headers.contains_key("set-cookie"));
394    }
395
396    #[tokio::test]
397    async fn test_unsafe_method_without_tokens_rejected() {
398        let p = plugin(serde_json::json!({"key": "secret"}));
399
400        let err = p
401            .execute(test_context("POST"), &HashMap::new())
402            .await
403            .unwrap_err();
404        assert_eq!(err.error.code, "CSRF_INVALID");
405        assert_eq!(err.context.response.status_code, 401);
406        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
407        assert_eq!(body["error_msg"], "no csrf token in headers");
408
409        // header token present but no cookie
410        let mut ctx = test_context("POST");
411        ctx.request
412            .headers
413            .insert(NAME.to_string(), vec!["sometoken".to_string()]);
414        let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
415        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
416        assert_eq!(body["error_msg"], "no csrf cookie");
417    }
418
419    #[tokio::test]
420    async fn test_valid_token_round_trip() {
421        let p = plugin(serde_json::json!({"key": "secret"}));
422        let token = p.gen_token();
423        let out = p
424            .execute(with_tokens("POST", &token, &token), &HashMap::new())
425            .await
426            .unwrap();
427        // a fresh cookie is issued after successful validation
428        assert!(out.context.response.headers.contains_key("set-cookie"));
429    }
430
431    #[tokio::test]
432    async fn test_token_mismatch_and_tampering_rejected() {
433        let p = plugin(serde_json::json!({"key": "secret"}));
434        let token = p.gen_token();
435        let other = p.gen_token();
436
437        // header != cookie
438        let err = p
439            .execute(with_tokens("POST", &token, &other), &HashMap::new())
440            .await
441            .unwrap_err();
442        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
443        assert_eq!(body["error_msg"], "csrf token mismatch");
444
445        // token signed with a different key
446        let wrong_key = plugin(serde_json::json!({"key": "other-secret"}));
447        let forged = wrong_key.gen_token();
448        let err = p
449            .execute(with_tokens("POST", &forged, &forged), &HashMap::new())
450            .await
451            .unwrap_err();
452        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
453        assert_eq!(
454            body["error_msg"],
455            "Failed to verify the csrf token signature"
456        );
457
458        // garbage token
459        assert!(p
460            .execute(with_tokens("POST", "nonsense", "nonsense"), &HashMap::new())
461            .await
462            .is_err());
463    }
464
465    #[tokio::test]
466    async fn test_expiry() {
467        let p = plugin(serde_json::json!({"key": "secret", "expires": 10}));
468        let stale = p.token_at(now() - 60);
469        assert!(p
470            .execute(with_tokens("POST", &stale, &stale), &HashMap::new())
471            .await
472            .is_err());
473
474        // expires = 0 disables the expiry check (APISIX parity)
475        let no_expiry = plugin(serde_json::json!({"key": "secret", "expires": 0}));
476        let ancient = no_expiry.token_at(now() - 1_000_000);
477        assert!(no_expiry
478            .execute(with_tokens("POST", &ancient, &ancient), &HashMap::new())
479            .await
480            .is_ok());
481        // session cookie: no Max-Age
482        let out = no_expiry
483            .execute(test_context("GET"), &HashMap::new())
484            .await
485            .unwrap();
486        assert!(!out.context.response.headers.get("set-cookie").unwrap()[0].contains("Max-Age"));
487    }
488
489    #[test]
490    fn test_hex_round_trip() {
491        assert_eq!(hex_encode(&[0x00, 0xff, 0x2a]), "00ff2a");
492        assert_eq!(hex_decode("00ff2a"), Some(vec![0x00, 0xff, 0x2a]));
493        assert_eq!(hex_decode("0"), None);
494        assert_eq!(hex_decode("zz"), None);
495    }
496}