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, GatewayError};
30use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
31
32const SAFE_METHODS: [&str; 3] = ["GET", "HEAD", "OPTIONS"];
33
34pub struct CsrfPlugin {
42 key: hmac::Key,
44 expires: u64,
46 name: String,
48 phase: CsrfPhase,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54enum CsrfPhase {
55 Request,
57 Response,
59}
60
61fn 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
70fn 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
81fn 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 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 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 fn token_at(&self, ts: u64) -> String {
153 let mut random_bytes = [0u8; 16];
154 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 fn gen_token(&self) -> String {
168 self.token_at(now())
169 }
170
171 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 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 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 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 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 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 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 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 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 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 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 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}