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