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};
23use crate::vars::template::Template;
24
25/// Masks request query parameters, headers, and JSON body fields according
26/// to a list of rules. Masking is best-effort and never fails at execution
27/// time: bodies that are absent, non-JSON, or larger than `max_body_size`
28/// simply skip the body rules. The plugin only touches `context.request`;
29/// it never writes `context.message` or `context.errors`.
30pub struct DataMaskPlugin {
31    rules: Vec<MaskRule>,
32    max_body_size: usize,
33}
34
35/// Where a rule applies.
36#[derive(Debug, Clone, Copy, PartialEq)]
37enum Target {
38    Query,
39    Header,
40    Body,
41}
42
43/// What a rule does to the matched field.
44///
45/// `Replace`/`Regex` values support `{{namespace.path}}` template references,
46/// rendered per request via plain [`Template::render`] (never `$var`
47/// interpolation — these fields have no legacy `$` history). `Regex` values
48/// may also carry `$1`-style capture references: `render` never touches `$`
49/// syntax, so a capture reference in the configured value survives rendering
50/// byte-identical and is resolved afterwards by the regex engine itself.
51enum Action {
52    /// Delete the field entirely.
53    Remove,
54    /// Overwrite the field with a fixed (templated) value.
55    Replace(Template),
56    /// Rewrite the first regex match inside a string value (mirrors APISIX's
57    /// `ngx.re.sub`, which substitutes only the first occurrence). `value` is
58    /// rendered before being handed to the regex engine, which then resolves
59    /// any `$1`-style capture references itself.
60    Regex { regex: Regex, value: Template },
61}
62
63/// A rule's [`Action`] with its template(s) rendered against the current
64/// request — the shape [`apply_to_json`]/[`apply_to_multimap`] operate on.
65enum ResolvedAction<'a> {
66    Remove,
67    Replace(String),
68    Regex { regex: &'a Regex, value: String },
69}
70
71impl Action {
72    /// Renders this action's template(s) against `ctx`, producing the
73    /// concrete strings this request's rule application should use.
74    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
86/// One masking rule from the `request` array.
87struct MaskRule {
88    target: Target,
89    /// Query/header name, or the raw dotted path for body rules.
90    name: String,
91    /// Parsed dotted path; only populated for body rules.
92    path: Vec<PathSeg>,
93    action: Action,
94}
95
96/// One segment of a dotted body path.
97#[derive(Debug, Clone, PartialEq)]
98enum PathSeg {
99    Key(String),
100    Index(usize),
101}
102
103/// Parses a dotted path (`user.cards.0.number`), tolerating a leading `$.`
104/// or `$`. Purely numeric segments become array indices. Empty segments
105/// (e.g. `a..b`) are rejected.
106fn 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
128/// Applies `action` to the value addressed by `path` inside a parsed JSON
129/// document. Returns `true` if anything changed. Paths that do not resolve
130/// (missing key, out-of-range index, type mismatch) are silently skipped,
131/// as are regex actions on non-string values — mirroring the Lua plugin's
132/// warn-and-continue behavior.
133fn 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    // Walk to the parent of the addressed value.
140    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
200/// Replaces the first regex match in `s` with `replacement` (which may use
201/// `$1`-style capture references). Returns `true` if a match was rewritten.
202fn 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
210/// Applies a rule to a multi-valued map (query params or headers). Regex
211/// actions run against every value of the field; `replace` collapses the
212/// field to the single configured value.
213fn 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    /// Builds the plugin from node config.
243    ///
244    /// Accepted keys:
245    /// - `request` (array of rule objects, default `[]`): each rule has
246    ///   - `type` (string, required): `query`, `header`, or `body`.
247    ///   - `name` (string, required): the query parameter / header name, or a
248    ///     dotted path into the JSON body (`user.cards.0.number`; numeric
249    ///     segments index arrays, a leading `$.` is tolerated).
250    ///   - `action` (string, required): `remove`, `replace`, or `regex`.
251    ///   - `value` (string): replacement value; required for `replace` and
252    ///     `regex` (for `regex` it may use `$1` capture references).
253    ///   - `regex` (string): pattern; required for `action: regex`, compiled
254    ///     here at config load.
255    ///   - `body_format` (string): required for `type: body`; only `json` is
256    ///     supported (**deviation**: APISIX also accepts `urlencoded`).
257    /// - `max_body_size` (integer > 0, default `1048576`): bodies larger than
258    ///   this skip body rules.
259    ///
260    /// ```yaml
261    /// type: data-mask
262    /// config:
263    ///   request:
264    ///     - { type: header, name: authorization, action: replace, value: "***" }
265    ///     - { type: query, name: token, action: remove }
266    ///     - type: body
267    ///       body_format: json
268    ///       name: user.card_number
269    ///       action: regex
270    ///       regex: "^(\\d{4})\\d+(\\d{4})$"
271    ///       value: "$1********$2"
272    /// ```
273    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
303/// Parses and validates one rule object, compiling regexes and dotted paths.
304fn 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    // Discard warnings here — the compile-time walk (a later task) reports
336    // well-formed-but-unknown references; execution must not.
337    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        // Body rules share one lazily-parsed JSON document.
379        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; // absent or oversized body: skip, never fail
394                    }
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, // non-JSON body: skip body rules
399                        }
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                // Body-mutation convention: the server layer recomputes
413                // content-length from the final body.
414                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        // body was rewritten -> stale content-length removed
512        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        // untouched body -> content-length preserved
535        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        // mirrors ngx.re.sub: only the first occurrence is rewritten
563        assert_eq!(parsed["note"], "id=*** id=456");
564    }
565
566    /// TDD (Task 3): a regex-action `value` supports `{{...}}` template
567    /// references (rendered per request) while `$1`-style capture references
568    /// stay literal through the plain `render` pass and are still resolved by
569    /// the regex engine — the safety property this sweep exists to prove.
570    #[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        // {{request.path}} renders; $1 is preserved and resolved by the regex
581        // engine's own backreference handling, not by the template engine.
582        assert_eq!(parsed["note"], "/api:42");
583    }
584
585    #[test]
586    fn test_data_mask_config_rejections() {
587        let bad = [
588            // urlencoded body_format is a documented deviation
589            serde_json::json!([{ "type": "body", "body_format": "urlencoded", "name": "a", "action": "remove" }]),
590            // body rule without body_format
591            serde_json::json!([{ "type": "body", "name": "a", "action": "remove" }]),
592            // regex action without pattern
593            serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "value": "x" }]),
594            // replace without value
595            serde_json::json!([{ "type": "query", "name": "a", "action": "replace" }]),
596            // invalid regex
597            serde_json::json!([{ "type": "query", "name": "a", "action": "regex", "regex": "(", "value": "x" }]),
598            // unknown type / action
599            serde_json::json!([{ "type": "cookie", "name": "a", "action": "remove" }]),
600            serde_json::json!([{ "type": "query", "name": "a", "action": "obfuscate" }]),
601            // empty dotted path segment
602            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}