1use async_trait::async_trait;
17use bytes::Bytes;
18use regex::Regex;
19use std::collections::HashMap;
20
21use crate::context::Context;
22use crate::plugins::{Plugin, PluginOutput, PluginResult};
23use crate::vars::template::Template;
24
25pub struct DataMaskPlugin {
31 rules: Vec<MaskRule>,
32 max_body_size: usize,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq)]
37enum Target {
38 Query,
39 Header,
40 Body,
41}
42
43enum Action {
52 Remove,
54 Replace(Template),
56 Regex { regex: Regex, value: Template },
61}
62
63enum ResolvedAction<'a> {
66 Remove,
67 Replace(String),
68 Regex { regex: &'a Regex, value: String },
69}
70
71impl Action {
72 fn resolve(&self, ctx: &Context) -> ResolvedAction<'_> {
75 match self {
76 Action::Remove => ResolvedAction::Remove,
77 Action::Replace(tpl) => ResolvedAction::Replace(tpl.render(ctx).into_owned()),
78 Action::Regex { regex, value } => ResolvedAction::Regex {
79 regex,
80 value: value.render(ctx).into_owned(),
81 },
82 }
83 }
84}
85
86struct MaskRule {
88 target: Target,
89 name: String,
91 path: Vec<PathSeg>,
93 action: Action,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98enum PathSeg {
99 Key(String),
100 Index(usize),
101}
102
103fn parse_dotted_path(raw: &str) -> Result<Vec<PathSeg>, String> {
107 let trimmed = raw
108 .strip_prefix("$.")
109 .or_else(|| raw.strip_prefix('$'))
110 .unwrap_or(raw);
111 if trimmed.is_empty() {
112 return Err(format!("body rule path '{}' is empty", raw));
113 }
114 trimmed
115 .split('.')
116 .map(|seg| {
117 if seg.is_empty() {
118 Err(format!("body rule path '{}' has an empty segment", raw))
119 } else if let Ok(idx) = seg.parse::<usize>() {
120 Ok(PathSeg::Index(idx))
121 } else {
122 Ok(PathSeg::Key(seg.to_string()))
123 }
124 })
125 .collect()
126}
127
128fn apply_to_json(root: &mut serde_json::Value, path: &[PathSeg], action: &ResolvedAction) -> bool {
134 let (last, parents) = match path.split_last() {
135 Some(pair) => pair,
136 None => return false,
137 };
138
139 let mut current = root;
141 for seg in parents {
142 current = match seg {
143 PathSeg::Key(k) => match current.get_mut(k.as_str()) {
144 Some(v) => v,
145 None => return false,
146 },
147 PathSeg::Index(i) => match current.get_mut(*i) {
148 Some(v) => v,
149 None => return false,
150 },
151 };
152 }
153
154 match (last, current) {
155 (PathSeg::Key(k), serde_json::Value::Object(map)) => {
156 if !map.contains_key(k.as_str()) {
157 return false;
158 }
159 match action {
160 ResolvedAction::Remove => map.remove(k.as_str()).is_some(),
161 ResolvedAction::Replace(value) => {
162 map.insert(k.clone(), serde_json::Value::String(value.clone()));
163 true
164 }
165 ResolvedAction::Regex { regex, value } => {
166 if let Some(serde_json::Value::String(s)) = map.get_mut(k.as_str()) {
167 regex_mask(s, regex, value)
168 } else {
169 false
170 }
171 }
172 }
173 }
174 (PathSeg::Index(i), serde_json::Value::Array(arr)) => {
175 if *i >= arr.len() {
176 return false;
177 }
178 match action {
179 ResolvedAction::Remove => {
180 arr.remove(*i);
181 true
182 }
183 ResolvedAction::Replace(value) => {
184 arr[*i] = serde_json::Value::String(value.clone());
185 true
186 }
187 ResolvedAction::Regex { regex, value } => {
188 if let serde_json::Value::String(s) = &mut arr[*i] {
189 regex_mask(s, regex, value)
190 } else {
191 false
192 }
193 }
194 }
195 }
196 _ => false,
197 }
198}
199
200fn regex_mask(s: &mut String, regex: &Regex, replacement: &str) -> bool {
203 if !regex.is_match(s) {
204 return false;
205 }
206 *s = regex.replace(s, replacement).into_owned();
207 true
208}
209
210fn apply_to_multimap(
214 map: &mut HashMap<String, Vec<String>>,
215 name: &str,
216 action: &ResolvedAction,
217) -> bool {
218 if !map.contains_key(name) {
219 return false;
220 }
221 match action {
222 ResolvedAction::Remove => map.remove(name).is_some(),
223 ResolvedAction::Replace(value) => {
224 map.insert(name.to_string(), vec![value.clone()]);
225 true
226 }
227 ResolvedAction::Regex { regex, value } => {
228 let mut masked = false;
229 if let Some(values) = map.get_mut(name) {
230 for v in values.iter_mut() {
231 if regex_mask(v, regex, value) {
232 masked = true;
233 }
234 }
235 }
236 masked
237 }
238 }
239}
240
241impl DataMaskPlugin {
242 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
274 let mut rules = Vec::new();
275
276 if let Some(raw) = config.get("request") {
277 let items = raw
278 .as_array()
279 .ok_or("data-mask: 'request' must be an array of rule objects")?;
280 for (idx, item) in items.iter().enumerate() {
281 rules.push(parse_rule(item).map_err(|e| format!("data-mask rule {}: {}", idx, e))?);
282 }
283 }
284
285 let max_body_size = match config.get("max_body_size") {
286 None => 1024 * 1024,
287 Some(v) => {
288 let n = v
289 .as_u64()
290 .filter(|n| *n > 0)
291 .ok_or("data-mask: 'max_body_size' must be a positive integer")?;
292 n as usize
293 }
294 };
295
296 Ok(Self {
297 rules,
298 max_body_size,
299 })
300 }
301}
302
303fn parse_rule(item: &serde_json::Value) -> Result<MaskRule, String> {
305 let obj = item.as_object().ok_or("must be an object")?;
306
307 let get_str = |key: &str| obj.get(key).and_then(|v| v.as_str());
308
309 let target = match get_str("type") {
310 Some("query") => Target::Query,
311 Some("header") => Target::Header,
312 Some("body") => Target::Body,
313 Some(other) => return Err(format!("unknown type '{}' (query|header|body)", other)),
314 None => return Err("'type' is required".to_string()),
315 };
316
317 let name = get_str("name")
318 .filter(|s| !s.is_empty())
319 .ok_or("'name' is required")?
320 .to_string();
321
322 if target == Target::Body {
323 match get_str("body_format") {
324 Some("json") => {}
325 Some(other) => {
326 return Err(format!(
327 "body_format '{}' is not supported — featherbit implements 'json' only",
328 other
329 ));
330 }
331 None => return Err("'body_format' is required for body rules".to_string()),
332 }
333 }
334
335 let action = match get_str("action") {
338 Some("remove") => Action::Remove,
339 Some("replace") => {
340 let value = get_str("value").ok_or("'value' is required for action 'replace'")?;
341 Action::Replace(Template::parse(value).0)
342 }
343 Some("regex") => {
344 let pattern = get_str("regex").ok_or("'regex' is required for action 'regex'")?;
345 let value = get_str("value").ok_or("'value' is required for action 'regex'")?;
346 let regex =
347 Regex::new(pattern).map_err(|e| format!("invalid regex '{}': {}", pattern, e))?;
348 Action::Regex {
349 regex,
350 value: Template::parse(value).0,
351 }
352 }
353 Some(other) => return Err(format!("unknown action '{}' (remove|replace|regex)", other)),
354 None => return Err("'action' is required".to_string()),
355 };
356
357 let path = if target == Target::Body {
358 parse_dotted_path(&name)?
359 } else {
360 Vec::new()
361 };
362
363 Ok(MaskRule {
364 target,
365 name,
366 path,
367 action,
368 })
369}
370
371#[async_trait]
372impl Plugin for DataMaskPlugin {
373 fn plugin_type(&self) -> &str {
374 "data-mask"
375 }
376
377 async fn execute(&self, mut ctx: Context) -> PluginResult {
378 let mut json_body: Option<serde_json::Value> = None;
380 let mut body_masked = false;
381
382 for rule in &self.rules {
383 let action = rule.action.resolve(&ctx);
384 match rule.target {
385 Target::Query => {
386 apply_to_multimap(&mut ctx.request.query_params, &rule.name, &action);
387 }
388 Target::Header => {
389 apply_to_multimap(&mut ctx.request.headers, &rule.name.to_lowercase(), &action);
390 }
391 Target::Body => {
392 if ctx.request.body.is_empty() || ctx.request.body.len() > self.max_body_size {
393 continue; }
395 if json_body.is_none() {
396 match serde_json::from_slice(&ctx.request.body) {
397 Ok(parsed) => json_body = Some(parsed),
398 Err(_) => continue, }
400 }
401 if let Some(doc) = json_body.as_mut() {
402 if apply_to_json(doc, &rule.path, &action) {
403 body_masked = true;
404 }
405 }
406 }
407 }
408 }
409
410 if body_masked {
411 if let Some(doc) = &json_body {
412 ctx.request.body = Bytes::from(serde_json::to_vec(doc).unwrap_or_default());
415 ctx.request.headers.remove("content-length");
416 }
417 }
418
419 Ok(PluginOutput::success(ctx))
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
427
428 fn test_context(body: &str) -> Context {
429 let mut headers = HashMap::new();
430 headers.insert(
431 "authorization".to_string(),
432 vec!["Bearer secret-token".to_string()],
433 );
434 headers.insert("content-length".to_string(), vec![body.len().to_string()]);
435 let mut query = HashMap::new();
436 query.insert("token".to_string(), vec!["abc123".to_string()]);
437 query.insert("name".to_string(), vec!["jack".to_string()]);
438
439 Context {
440 request: GatewayRequest {
441 method: "POST".to_string(),
442 path: "/api".to_string(),
443 host: "localhost".to_string(),
444 scheme: "http".to_string(),
445 headers,
446 query_params: query,
447 body: Bytes::from(body.to_string()),
448 remote_addr: "127.0.0.1:12345".to_string(),
449 protocol: Protocol::Http1,
450 },
451 response: GatewayResponse {
452 status_code: 0,
453 headers: HashMap::new(),
454 body: Bytes::new(),
455 stream: None,
456 },
457 message: HashMap::new(),
458 errors: Vec::new(),
459 }
460 }
461
462 fn plugin(rules: serde_json::Value) -> DataMaskPlugin {
463 let mut config = HashMap::new();
464 config.insert("request".to_string(), rules);
465 DataMaskPlugin::from_config(&config).unwrap()
466 }
467
468 #[tokio::test]
469 async fn test_data_mask_query_remove_and_replace() {
470 let p = plugin(serde_json::json!([
471 { "type": "query", "name": "token", "action": "remove" },
472 { "type": "query", "name": "name", "action": "replace", "value": "***" }
473 ]));
474 let out = p.execute(test_context("")).await.unwrap();
475 assert!(!out.context.request.query_params.contains_key("token"));
476 assert_eq!(
477 out.context.request.query_params.get("name"),
478 Some(&vec!["***".to_string()])
479 );
480 }
481
482 #[tokio::test]
483 async fn test_data_mask_header_regex() {
484 let p = plugin(serde_json::json!([
485 { "type": "header", "name": "Authorization", "action": "regex",
486 "regex": "Bearer .*", "value": "Bearer ***" }
487 ]));
488 let out = p.execute(test_context("")).await.unwrap();
489 assert_eq!(
490 out.context.request.headers.get("authorization"),
491 Some(&vec!["Bearer ***".to_string()])
492 );
493 }
494
495 #[tokio::test]
496 async fn test_data_mask_body_nested_and_array_paths() {
497 let body = r#"{"user":{"name":"jack","cards":[{"number":"4111111111111111"},{"number":"5500000000000004"}]},"password":"hunter2"}"#;
498 let p = plugin(serde_json::json!([
499 { "type": "body", "body_format": "json", "name": "password", "action": "remove" },
500 { "type": "body", "body_format": "json", "name": "user.cards.0.number",
501 "action": "regex", "regex": r"^(\d{4})\d+(\d{4})$", "value": "$1********$2" },
502 { "type": "body", "body_format": "json", "name": "user.cards.1.number",
503 "action": "replace", "value": "MASKED" },
504 { "type": "body", "body_format": "json", "name": "user.missing", "action": "remove" }
505 ]));
506 let out = p.execute(test_context(body)).await.unwrap();
507 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
508 assert!(parsed.get("password").is_none());
509 assert_eq!(parsed["user"]["cards"][0]["number"], "4111********1111");
510 assert_eq!(parsed["user"]["cards"][1]["number"], "MASKED");
511 assert!(!out.context.request.headers.contains_key("content-length"));
513 }
514
515 #[tokio::test]
516 async fn test_data_mask_body_array_element_remove() {
517 let body = r#"{"items":["a","b","c"]}"#;
518 let p = plugin(serde_json::json!([
519 { "type": "body", "body_format": "json", "name": "items.1", "action": "remove" }
520 ]));
521 let out = p.execute(test_context(body)).await.unwrap();
522 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
523 assert_eq!(parsed["items"], serde_json::json!(["a", "c"]));
524 }
525
526 #[tokio::test]
527 async fn test_data_mask_non_json_body_skipped() {
528 let p = plugin(serde_json::json!([
529 { "type": "body", "body_format": "json", "name": "password", "action": "remove" }
530 ]));
531 let ctx = test_context("not json at all");
532 let out = p.execute(ctx).await.unwrap();
533 assert_eq!(out.context.request.body, Bytes::from("not json at all"));
534 assert!(out.context.request.headers.contains_key("content-length"));
536 }
537
538 #[tokio::test]
539 async fn test_data_mask_oversized_body_skipped() {
540 let mut config = HashMap::new();
541 config.insert(
542 "request".to_string(),
543 serde_json::json!([
544 { "type": "body", "body_format": "json", "name": "a", "action": "remove" }
545 ]),
546 );
547 config.insert("max_body_size".to_string(), serde_json::json!(4));
548 let p = DataMaskPlugin::from_config(&config).unwrap();
549 let out = p.execute(test_context(r#"{"a":1}"#)).await.unwrap();
550 assert_eq!(out.context.request.body, Bytes::from(r#"{"a":1}"#));
551 }
552
553 #[tokio::test]
554 async fn test_data_mask_regex_first_match_only() {
555 let body = r#"{"note":"id=123 id=456"}"#;
556 let p = plugin(serde_json::json!([
557 { "type": "body", "body_format": "json", "name": "note",
558 "action": "regex", "regex": r"id=\d+", "value": "id=***" }
559 ]));
560 let out = p.execute(test_context(body)).await.unwrap();
561 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
562 assert_eq!(parsed["note"], "id=*** id=456");
564 }
565
566 #[tokio::test]
571 async fn test_data_mask_regex_value_template_and_capture_ref() {
572 let body = r#"{"note":"id=42"}"#;
573 let p = plugin(serde_json::json!([
574 { "type": "body", "body_format": "json", "name": "note",
575 "action": "regex", "regex": r"id=(\d+)",
576 "value": "{{request.path}}:$1" }
577 ]));
578 let out = p.execute(test_context(body)).await.unwrap();
579 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
580 assert_eq!(parsed["note"], "/api:42");
583 }
584
585 #[test]
586 fn test_data_mask_config_rejections() {
587 let bad = [
588 serde_json::json!([{ "type": "body", "body_format": "urlencoded", "name": "a", "action": "remove" }]),
590 serde_json::json!([{ "type": "body", "name": "a", "action": "remove" }]),
592 serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "value": "x" }]),
594 serde_json::json!([{ "type": "query", "name": "a", "action": "replace" }]),
596 serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "regex": "(", "value": "x" }]),
598 serde_json::json!([{ "type": "cookie", "name": "a", "action": "remove" }]),
600 serde_json::json!([{ "type": "query", "name": "a", "action": "obfuscate" }]),
601 serde_json::json!([{ "type": "body", "body_format": "json", "name": "a..b", "action": "remove" }]),
603 ];
604 for rules in bad {
605 let mut config = HashMap::new();
606 config.insert("request".to_string(), rules.clone());
607 assert!(
608 DataMaskPlugin::from_config(&config).is_err(),
609 "should reject: {rules}"
610 );
611 }
612 }
613
614 #[test]
615 fn test_data_mask_dotted_path_parsing() {
616 assert_eq!(
617 parse_dotted_path("$.user.cards.0").unwrap(),
618 vec![
619 PathSeg::Key("user".to_string()),
620 PathSeg::Key("cards".to_string()),
621 PathSeg::Index(0)
622 ]
623 );
624 assert_eq!(
625 parse_dotted_path("a").unwrap(),
626 vec![PathSeg::Key("a".to_string())]
627 );
628 assert!(parse_dotted_path("").is_err());
629 assert!(parse_dotted_path("$.").is_err());
630 }
631}