featherbit/plugins/native/
csrf.rs1use 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
35pub struct CsrfPlugin {
43 key: hmac::Key,
45 expires: u64,
47 name: Template,
52 phase: CsrfPhase,
54}
55
56#[derive(Debug, Clone, PartialEq)]
58enum CsrfPhase {
59 Request,
61 Response,
63}
64
65fn 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
74fn 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
85fn 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 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 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 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 fn token_at(&self, ts: u64) -> String {
164 let mut random_bytes = [0u8; 16];
165 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 fn gen_token(&self) -> String {
179 self.token_at(now())
180 }
181
182 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 fn rendered_name(&self, ctx: &Context) -> String {
217 self.name.render(ctx).into_owned().to_lowercase()
218 }
219
220 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 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 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 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 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 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 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 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 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 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 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 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 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}