Skip to main content

featherbit/plugins/native/
data_mask.rs

1//! The `data-mask` node — masks sensitive fields in the request before they
2//! reach loggers or the upstream: query parameters, headers, and JSON body
3//! fields can be removed, replaced with a fixed value, or partially rewritten
4//! with a regex substitution.
5//!
6//! Port of APISIX's `data-mask` plugin. Deviations from the Lua original:
7//! - Body rules support `body_format: json` only; `urlencoded` is rejected at
8//!   config load.
9//! - Body field selection uses **dotted paths** (`user.cards.0.number`), not
10//!   JSONPath — no `$..` recursive descent or wildcards. A leading `$.` is
11//!   tolerated and stripped. Purely numeric segments index into arrays.
12//! - APISIX runs this in the log phase (masking what loggers see); featherbit
13//!   runs it wherever the node sits in the graph — place it before `logging`
14//!   and/or `upstream` nodes that must not see the raw values.
15
16use 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
24/// Masks request query parameters, headers, and JSON body fields according
25/// to a list of rules. Masking is best-effort and never fails at execution
26/// time: bodies that are absent, non-JSON, or larger than `max_body_size`
27/// simply skip the body rules. The plugin only touches `context.request`;
28/// it never writes `context.message` or `context.errors`.
29pub struct DataMaskPlugin {
30    rules: Vec<MaskRule>,
31    max_body_size: usize,
32}
33
34/// Where a rule applies.
35#[derive(Debug, Clone, Copy, PartialEq)]
36enum Target {
37    Query,
38    Header,
39    Body,
40}
41
42/// What a rule does to the matched field.
43enum Action {
44    /// Delete the field entirely.
45    Remove,
46    /// Overwrite the field with a fixed value.
47    Replace(String),
48    /// Rewrite the first regex match inside a string value (mirrors APISIX's
49    /// `ngx.re.sub`, which substitutes only the first occurrence).
50    Regex { regex: Regex, value: String },
51}
52
53/// One masking rule from the `request` array.
54struct MaskRule {
55    target: Target,
56    /// Query/header name, or the raw dotted path for body rules.
57    name: String,
58    /// Parsed dotted path; only populated for body rules.
59    path: Vec<PathSeg>,
60    action: Action,
61}
62
63/// One segment of a dotted body path.
64#[derive(Debug, Clone, PartialEq)]
65enum PathSeg {
66    Key(String),
67    Index(usize),
68}
69
70/// Parses a dotted path (`user.cards.0.number`), tolerating a leading `$.`
71/// or `$`. Purely numeric segments become array indices. Empty segments
72/// (e.g. `a..b`) are rejected.
73fn 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
95/// Applies `action` to the value addressed by `path` inside a parsed JSON
96/// document. Returns `true` if anything changed. Paths that do not resolve
97/// (missing key, out-of-range index, type mismatch) are silently skipped,
98/// as are regex actions on non-string values — mirroring the Lua plugin's
99/// warn-and-continue behavior.
100fn 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    // Walk to the parent of the addressed value.
107    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
167/// Replaces the first regex match in `s` with `replacement` (which may use
168/// `$1`-style capture references). Returns `true` if a match was rewritten.
169fn 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
177/// Applies a rule to a multi-valued map (query params or headers). Regex
178/// actions run against every value of the field; `replace` collapses the
179/// field to the single configured value.
180fn 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    /// Builds the plugin from node config.
206    ///
207    /// Accepted keys:
208    /// - `request` (array of rule objects, default `[]`): each rule has
209    ///   - `type` (string, required): `query`, `header`, or `body`.
210    ///   - `name` (string, required): the query parameter / header name, or a
211    ///     dotted path into the JSON body (`user.cards.0.number`; numeric
212    ///     segments index arrays, a leading `$.` is tolerated).
213    ///   - `action` (string, required): `remove`, `replace`, or `regex`.
214    ///   - `value` (string): replacement value; required for `replace` and
215    ///     `regex` (for `regex` it may use `$1` capture references).
216    ///   - `regex` (string): pattern; required for `action: regex`, compiled
217    ///     here at config load.
218    ///   - `body_format` (string): required for `type: body`; only `json` is
219    ///     supported (**deviation**: APISIX also accepts `urlencoded`).
220    /// - `max_body_size` (integer > 0, default `1048576`): bodies larger than
221    ///   this skip body rules.
222    ///
223    /// ```yaml
224    /// type: data-mask
225    /// config:
226    ///   request:
227    ///     - { type: header, name: authorization, action: replace, value: "***" }
228    ///     - { type: query, name: token, action: remove }
229    ///     - type: body
230    ///       body_format: json
231    ///       name: user.card_number
232    ///       action: regex
233    ///       regex: "^(\\d{4})\\d+(\\d{4})$"
234    ///       value: "$1********$2"
235    /// ```
236    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
266/// Parses and validates one rule object, compiling regexes and dotted paths.
267fn 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        // Body rules share one lazily-parsed JSON document.
344        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; // absent or oversized body: skip, never fail
362                    }
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, // non-JSON body: skip body rules
367                        }
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                // Body-mutation convention: the server layer recomputes
381                // content-length from the final body.
382                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        // body was rewritten -> stale content-length removed
485        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        // untouched body -> content-length preserved
511        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        // mirrors ngx.re.sub: only the first occurrence is rewritten
545        assert_eq!(parsed["note"], "id=*** id=456");
546    }
547
548    #[test]
549    fn test_data_mask_config_rejections() {
550        let bad = [
551            // urlencoded body_format is a documented deviation
552            serde_json::json!([{ "type": "body", "body_format": "urlencoded", "name": "a", "action": "remove" }]),
553            // body rule without body_format
554            serde_json::json!([{ "type": "body", "name": "a", "action": "remove" }]),
555            // regex action without pattern
556            serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "value": "x" }]),
557            // replace without value
558            serde_json::json!([{ "type": "query", "name": "a", "action": "replace" }]),
559            // invalid regex
560            serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "regex": "(", "value": "x" }]),
561            // unknown type / action
562            serde_json::json!([{ "type": "cookie", "name": "a", "action": "remove" }]),
563            serde_json::json!([{ "type": "query", "name": "a", "action": "obfuscate" }]),
564            // empty dotted path segment
565            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}