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};
23
24pub struct DataMaskPlugin {
30 rules: Vec<MaskRule>,
31 max_body_size: usize,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
36enum Target {
37 Query,
38 Header,
39 Body,
40}
41
42enum Action {
44 Remove,
46 Replace(String),
48 Regex { regex: Regex, value: String },
51}
52
53struct MaskRule {
55 target: Target,
56 name: String,
58 path: Vec<PathSeg>,
60 action: Action,
61}
62
63#[derive(Debug, Clone, PartialEq)]
65enum PathSeg {
66 Key(String),
67 Index(usize),
68}
69
70fn parse_dotted_path(raw: &str) -> Result<Vec<PathSeg>, String> {
74 let trimmed = raw
75 .strip_prefix("$.")
76 .or_else(|| raw.strip_prefix('$'))
77 .unwrap_or(raw);
78 if trimmed.is_empty() {
79 return Err(format!("body rule path '{}' is empty", raw));
80 }
81 trimmed
82 .split('.')
83 .map(|seg| {
84 if seg.is_empty() {
85 Err(format!("body rule path '{}' has an empty segment", raw))
86 } else if let Ok(idx) = seg.parse::<usize>() {
87 Ok(PathSeg::Index(idx))
88 } else {
89 Ok(PathSeg::Key(seg.to_string()))
90 }
91 })
92 .collect()
93}
94
95fn apply_to_json(root: &mut serde_json::Value, path: &[PathSeg], action: &Action) -> bool {
101 let (last, parents) = match path.split_last() {
102 Some(pair) => pair,
103 None => return false,
104 };
105
106 let mut current = root;
108 for seg in parents {
109 current = match seg {
110 PathSeg::Key(k) => match current.get_mut(k.as_str()) {
111 Some(v) => v,
112 None => return false,
113 },
114 PathSeg::Index(i) => match current.get_mut(*i) {
115 Some(v) => v,
116 None => return false,
117 },
118 };
119 }
120
121 match (last, current) {
122 (PathSeg::Key(k), serde_json::Value::Object(map)) => {
123 if !map.contains_key(k.as_str()) {
124 return false;
125 }
126 match action {
127 Action::Remove => map.remove(k.as_str()).is_some(),
128 Action::Replace(value) => {
129 map.insert(k.clone(), serde_json::Value::String(value.clone()));
130 true
131 }
132 Action::Regex { regex, value } => {
133 if let Some(serde_json::Value::String(s)) = map.get_mut(k.as_str()) {
134 regex_mask(s, regex, value)
135 } else {
136 false
137 }
138 }
139 }
140 }
141 (PathSeg::Index(i), serde_json::Value::Array(arr)) => {
142 if *i >= arr.len() {
143 return false;
144 }
145 match action {
146 Action::Remove => {
147 arr.remove(*i);
148 true
149 }
150 Action::Replace(value) => {
151 arr[*i] = serde_json::Value::String(value.clone());
152 true
153 }
154 Action::Regex { regex, value } => {
155 if let serde_json::Value::String(s) = &mut arr[*i] {
156 regex_mask(s, regex, value)
157 } else {
158 false
159 }
160 }
161 }
162 }
163 _ => false,
164 }
165}
166
167fn regex_mask(s: &mut String, regex: &Regex, replacement: &str) -> bool {
170 if !regex.is_match(s) {
171 return false;
172 }
173 *s = regex.replace(s, replacement).into_owned();
174 true
175}
176
177fn apply_to_multimap(map: &mut HashMap<String, Vec<String>>, name: &str, action: &Action) -> bool {
181 if !map.contains_key(name) {
182 return false;
183 }
184 match action {
185 Action::Remove => map.remove(name).is_some(),
186 Action::Replace(value) => {
187 map.insert(name.to_string(), vec![value.clone()]);
188 true
189 }
190 Action::Regex { regex, value } => {
191 let mut masked = false;
192 if let Some(values) = map.get_mut(name) {
193 for v in values.iter_mut() {
194 if regex_mask(v, regex, value) {
195 masked = true;
196 }
197 }
198 }
199 masked
200 }
201 }
202}
203
204impl DataMaskPlugin {
205 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
237 let mut rules = Vec::new();
238
239 if let Some(raw) = config.get("request") {
240 let items = raw
241 .as_array()
242 .ok_or("data-mask: 'request' must be an array of rule objects")?;
243 for (idx, item) in items.iter().enumerate() {
244 rules.push(parse_rule(item).map_err(|e| format!("data-mask rule {}: {}", idx, e))?);
245 }
246 }
247
248 let max_body_size = match config.get("max_body_size") {
249 None => 1024 * 1024,
250 Some(v) => {
251 let n = v
252 .as_u64()
253 .filter(|n| *n > 0)
254 .ok_or("data-mask: 'max_body_size' must be a positive integer")?;
255 n as usize
256 }
257 };
258
259 Ok(Self {
260 rules,
261 max_body_size,
262 })
263 }
264}
265
266fn parse_rule(item: &serde_json::Value) -> Result<MaskRule, String> {
268 let obj = item.as_object().ok_or("must be an object")?;
269
270 let get_str = |key: &str| obj.get(key).and_then(|v| v.as_str());
271
272 let target = match get_str("type") {
273 Some("query") => Target::Query,
274 Some("header") => Target::Header,
275 Some("body") => Target::Body,
276 Some(other) => return Err(format!("unknown type '{}' (query|header|body)", other)),
277 None => return Err("'type' is required".to_string()),
278 };
279
280 let name = get_str("name")
281 .filter(|s| !s.is_empty())
282 .ok_or("'name' is required")?
283 .to_string();
284
285 if target == Target::Body {
286 match get_str("body_format") {
287 Some("json") => {}
288 Some(other) => {
289 return Err(format!(
290 "body_format '{}' is not supported — featherbit implements 'json' only",
291 other
292 ));
293 }
294 None => return Err("'body_format' is required for body rules".to_string()),
295 }
296 }
297
298 let action = match get_str("action") {
299 Some("remove") => Action::Remove,
300 Some("replace") => {
301 let value = get_str("value").ok_or("'value' is required for action 'replace'")?;
302 Action::Replace(value.to_string())
303 }
304 Some("regex") => {
305 let pattern = get_str("regex").ok_or("'regex' is required for action 'regex'")?;
306 let value = get_str("value").ok_or("'value' is required for action 'regex'")?;
307 let regex =
308 Regex::new(pattern).map_err(|e| format!("invalid regex '{}': {}", pattern, e))?;
309 Action::Regex {
310 regex,
311 value: value.to_string(),
312 }
313 }
314 Some(other) => return Err(format!("unknown action '{}' (remove|replace|regex)", other)),
315 None => return Err("'action' is required".to_string()),
316 };
317
318 let path = if target == Target::Body {
319 parse_dotted_path(&name)?
320 } else {
321 Vec::new()
322 };
323
324 Ok(MaskRule {
325 target,
326 name,
327 path,
328 action,
329 })
330}
331
332#[async_trait]
333impl Plugin for DataMaskPlugin {
334 fn plugin_type(&self) -> &str {
335 "data-mask"
336 }
337
338 async fn execute(
339 &self,
340 mut ctx: Context,
341 _named_inputs: &HashMap<String, serde_json::Value>,
342 ) -> PluginResult {
343 let mut json_body: Option<serde_json::Value> = None;
345 let mut body_masked = false;
346
347 for rule in &self.rules {
348 match rule.target {
349 Target::Query => {
350 apply_to_multimap(&mut ctx.request.query_params, &rule.name, &rule.action);
351 }
352 Target::Header => {
353 apply_to_multimap(
354 &mut ctx.request.headers,
355 &rule.name.to_lowercase(),
356 &rule.action,
357 );
358 }
359 Target::Body => {
360 if ctx.request.body.is_empty() || ctx.request.body.len() > self.max_body_size {
361 continue; }
363 if json_body.is_none() {
364 match serde_json::from_slice(&ctx.request.body) {
365 Ok(parsed) => json_body = Some(parsed),
366 Err(_) => continue, }
368 }
369 if let Some(doc) = json_body.as_mut() {
370 if apply_to_json(doc, &rule.path, &rule.action) {
371 body_masked = true;
372 }
373 }
374 }
375 }
376 }
377
378 if body_masked {
379 if let Some(doc) = &json_body {
380 ctx.request.body = Bytes::from(serde_json::to_vec(doc).unwrap_or_default());
383 ctx.request.headers.remove("content-length");
384 }
385 }
386
387 Ok(PluginOutput {
388 context: ctx,
389 named_outputs: HashMap::new(),
390 })
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
398
399 fn test_context(body: &str) -> Context {
400 let mut headers = HashMap::new();
401 headers.insert(
402 "authorization".to_string(),
403 vec!["Bearer secret-token".to_string()],
404 );
405 headers.insert("content-length".to_string(), vec![body.len().to_string()]);
406 let mut query = HashMap::new();
407 query.insert("token".to_string(), vec!["abc123".to_string()]);
408 query.insert("name".to_string(), vec!["jack".to_string()]);
409
410 Context {
411 request: GatewayRequest {
412 method: "POST".to_string(),
413 path: "/api".to_string(),
414 host: "localhost".to_string(),
415 scheme: "http".to_string(),
416 headers,
417 query_params: query,
418 body: Bytes::from(body.to_string()),
419 remote_addr: "127.0.0.1:12345".to_string(),
420 protocol: Protocol::Http1,
421 },
422 response: GatewayResponse {
423 status_code: 0,
424 headers: HashMap::new(),
425 body: Bytes::new(),
426 },
427 message: HashMap::new(),
428 errors: Vec::new(),
429 }
430 }
431
432 fn plugin(rules: serde_json::Value) -> DataMaskPlugin {
433 let mut config = HashMap::new();
434 config.insert("request".to_string(), rules);
435 DataMaskPlugin::from_config(&config).unwrap()
436 }
437
438 #[tokio::test]
439 async fn test_data_mask_query_remove_and_replace() {
440 let p = plugin(serde_json::json!([
441 { "type": "query", "name": "token", "action": "remove" },
442 { "type": "query", "name": "name", "action": "replace", "value": "***" }
443 ]));
444 let out = p.execute(test_context(""), &HashMap::new()).await.unwrap();
445 assert!(!out.context.request.query_params.contains_key("token"));
446 assert_eq!(
447 out.context.request.query_params.get("name"),
448 Some(&vec!["***".to_string()])
449 );
450 }
451
452 #[tokio::test]
453 async fn test_data_mask_header_regex() {
454 let p = plugin(serde_json::json!([
455 { "type": "header", "name": "Authorization", "action": "regex",
456 "regex": "Bearer .*", "value": "Bearer ***" }
457 ]));
458 let out = p.execute(test_context(""), &HashMap::new()).await.unwrap();
459 assert_eq!(
460 out.context.request.headers.get("authorization"),
461 Some(&vec!["Bearer ***".to_string()])
462 );
463 }
464
465 #[tokio::test]
466 async fn test_data_mask_body_nested_and_array_paths() {
467 let body = r#"{"user":{"name":"jack","cards":[{"number":"4111111111111111"},{"number":"5500000000000004"}]},"password":"hunter2"}"#;
468 let p = plugin(serde_json::json!([
469 { "type": "body", "body_format": "json", "name": "password", "action": "remove" },
470 { "type": "body", "body_format": "json", "name": "user.cards.0.number",
471 "action": "regex", "regex": r"^(\d{4})\d+(\d{4})$", "value": "$1********$2" },
472 { "type": "body", "body_format": "json", "name": "user.cards.1.number",
473 "action": "replace", "value": "MASKED" },
474 { "type": "body", "body_format": "json", "name": "user.missing", "action": "remove" }
475 ]));
476 let out = p
477 .execute(test_context(body), &HashMap::new())
478 .await
479 .unwrap();
480 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
481 assert!(parsed.get("password").is_none());
482 assert_eq!(parsed["user"]["cards"][0]["number"], "4111********1111");
483 assert_eq!(parsed["user"]["cards"][1]["number"], "MASKED");
484 assert!(!out.context.request.headers.contains_key("content-length"));
486 }
487
488 #[tokio::test]
489 async fn test_data_mask_body_array_element_remove() {
490 let body = r#"{"items":["a","b","c"]}"#;
491 let p = plugin(serde_json::json!([
492 { "type": "body", "body_format": "json", "name": "items.1", "action": "remove" }
493 ]));
494 let out = p
495 .execute(test_context(body), &HashMap::new())
496 .await
497 .unwrap();
498 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
499 assert_eq!(parsed["items"], serde_json::json!(["a", "c"]));
500 }
501
502 #[tokio::test]
503 async fn test_data_mask_non_json_body_skipped() {
504 let p = plugin(serde_json::json!([
505 { "type": "body", "body_format": "json", "name": "password", "action": "remove" }
506 ]));
507 let ctx = test_context("not json at all");
508 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
509 assert_eq!(out.context.request.body, Bytes::from("not json at all"));
510 assert!(out.context.request.headers.contains_key("content-length"));
512 }
513
514 #[tokio::test]
515 async fn test_data_mask_oversized_body_skipped() {
516 let mut config = HashMap::new();
517 config.insert(
518 "request".to_string(),
519 serde_json::json!([
520 { "type": "body", "body_format": "json", "name": "a", "action": "remove" }
521 ]),
522 );
523 config.insert("max_body_size".to_string(), serde_json::json!(4));
524 let p = DataMaskPlugin::from_config(&config).unwrap();
525 let out = p
526 .execute(test_context(r#"{"a":1}"#), &HashMap::new())
527 .await
528 .unwrap();
529 assert_eq!(out.context.request.body, Bytes::from(r#"{"a":1}"#));
530 }
531
532 #[tokio::test]
533 async fn test_data_mask_regex_first_match_only() {
534 let body = r#"{"note":"id=123 id=456"}"#;
535 let p = plugin(serde_json::json!([
536 { "type": "body", "body_format": "json", "name": "note",
537 "action": "regex", "regex": r"id=\d+", "value": "id=***" }
538 ]));
539 let out = p
540 .execute(test_context(body), &HashMap::new())
541 .await
542 .unwrap();
543 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
544 assert_eq!(parsed["note"], "id=*** id=456");
546 }
547
548 #[test]
549 fn test_data_mask_config_rejections() {
550 let bad = [
551 serde_json::json!([{ "type": "body", "body_format": "urlencoded", "name": "a", "action": "remove" }]),
553 serde_json::json!([{ "type": "body", "name": "a", "action": "remove" }]),
555 serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "value": "x" }]),
557 serde_json::json!([{ "type": "query", "name": "a", "action": "replace" }]),
559 serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "regex": "(", "value": "x" }]),
561 serde_json::json!([{ "type": "cookie", "name": "a", "action": "remove" }]),
563 serde_json::json!([{ "type": "query", "name": "a", "action": "obfuscate" }]),
564 serde_json::json!([{ "type": "body", "body_format": "json", "name": "a..b", "action": "remove" }]),
566 ];
567 for rules in bad {
568 let mut config = HashMap::new();
569 config.insert("request".to_string(), rules.clone());
570 assert!(
571 DataMaskPlugin::from_config(&config).is_err(),
572 "should reject: {rules}"
573 );
574 }
575 }
576
577 #[test]
578 fn test_data_mask_dotted_path_parsing() {
579 assert_eq!(
580 parse_dotted_path("$.user.cards.0").unwrap(),
581 vec![
582 PathSeg::Key("user".to_string()),
583 PathSeg::Key("cards".to_string()),
584 PathSeg::Index(0)
585 ]
586 );
587 assert_eq!(
588 parse_dotted_path("a").unwrap(),
589 vec![PathSeg::Key("a".to_string())]
590 );
591 assert!(parse_dotted_path("").is_err());
592 assert!(parse_dotted_path("$.").is_err());
593 }
594}