This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new ece5ccacf feat: add data-mask plugin (#13347)
ece5ccacf is described below

commit ece5ccacf0dd449bf2c5fdf1fe0812c773073fae
Author: AlinsRan <[email protected]>
AuthorDate: Fri May 15 08:51:48 2026 +0800

    feat: add data-mask plugin (#13347)
---
 apisix/cli/config.lua               |   1 +
 apisix/cli/ngx_tpl.lua              |   2 +
 apisix/core/ctx.lua                 |   1 +
 apisix/init.lua                     |   3 +
 apisix/plugins/data-mask.lua        | 316 ++++++++++++++++
 conf/config.yaml.example            |   1 +
 docs/en/latest/config.json          |   3 +-
 docs/en/latest/plugins/data-mask.md | 308 +++++++++++++++
 docs/zh/latest/config.json          |   3 +-
 docs/zh/latest/plugins/data-mask.md | 309 +++++++++++++++
 t/APISIX.pm                         |   4 +-
 t/admin/plugins.t                   |   1 +
 t/plugin/data-mask.t                | 722 ++++++++++++++++++++++++++++++++++++
 13 files changed, 1671 insertions(+), 3 deletions(-)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index f6daf6e9d..c42ecbdee 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -226,6 +226,7 @@ local _M = {
     "forward-auth",
     "opa",
     "authz-keycloak",
+    "data-mask",
     "proxy-cache",
     "body-transformer",
     "ai-prompt-template",
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index 6bd33368b..b823bd182 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -626,6 +626,7 @@ http {
         set $upstream_scheme             'http';
         set $upstream_host               $http_host;
         set $upstream_uri                '';
+        set $request_line                '';
 
         {%if allow_admin then%}
         {% for _, allow_ip in ipairs(allow_admin) do %}
@@ -791,6 +792,7 @@ http {
             set $upstream_scheme             'http';
             set $upstream_host               $http_host;
             set $upstream_uri                '';
+            set $request_line                '';
             set $ctx_ref                     '';
 
             {% if wasm then %}
diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua
index 1f97cb38f..dda4ccddc 100644
--- a/apisix/core/ctx.lua
+++ b/apisix/core/ctx.lua
@@ -251,6 +251,7 @@ do
         upstream_upgrade           = true,
         upstream_connection        = true,
         upstream_uri               = true,
+        request_line               = true,
         llm_content_risk_level     = true,
         apisix_request_id          = true,
 
diff --git a/apisix/init.lua b/apisix/init.lua
index 5bffaa316..cc83c49cf 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -743,6 +743,9 @@ function _M.http_access_phase()
     api_ctx.var.real_request_uri = api_ctx.var.request_uri
     api_ctx.var.request_uri = api_ctx.var.uri .. api_ctx.var.is_args .. 
(api_ctx.var.args or "")
 
+    -- var.request is read-only; copy to a writable variable so data-mask can 
redact query params
+    api_ctx.var.request_line = api_ctx.var.request
+
     handle_x_forwarded_headers(api_ctx)
 
     local match_span = tracer.start(ngx_ctx, "http_router_match", 
tracer.kind.internal)
diff --git a/apisix/plugins/data-mask.lua b/apisix/plugins/data-mask.lua
new file mode 100644
index 000000000..de79e740a
--- /dev/null
+++ b/apisix/plugins/data-mask.lua
@@ -0,0 +1,316 @@
+--
+-- Licensed to the Apache Software Foundation (ASF) under one or more
+-- contributor license agreements.  See the NOTICE file distributed with
+-- this work for additional information regarding copyright ownership.
+-- The ASF licenses this file to You under the Apache License, Version 2.0
+-- (the "License"); you may not use this file except in compliance with
+-- the License.  You may obtain a copy of the License at
+--
+--     http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+--
+local ngx       = ngx
+local ipairs    = ipairs
+local next      = next
+local type      = type
+local re_sub    = ngx.re.sub
+local core      = require("apisix.core")
+local jp        = require("jsonpath")
+
+local plugin_name = "data-mask"
+
+local schema = {
+    type = "object",
+    properties = {
+        request = {
+            type = "array",
+            items = {
+                type = "object",
+                properties = {
+                    type = {type = "string", enum = {"query", "header", 
"body"}},
+                    body_format = {type = "string", enum = {"json", 
"urlencoded"}},
+                    name = {type = "string"},
+                    action = {type = "string", enum = {"regex", "replace", 
"remove"}},
+                    regex = {type = "string"},
+                    value = {type = "string"},
+                },
+                required = {"type", "name", "action"},
+                allOf = {
+                    {
+                        ["if"] = {
+                            properties = {type = {const = "body"}},
+                        },
+                        ["then"] = {
+                            required = {"body_format"},
+                        },
+                    },
+                    {
+                        ["if"] = {
+                            properties = {action = {const = "regex"}},
+                        },
+                        ["then"] = {
+                            required = {"regex", "value"},
+                        },
+                    },
+                    {
+                        ["if"] = {
+                            properties = {action = {const = "replace"}},
+                        },
+                        ["then"] = {
+                            required = {"value"},
+                        },
+                    },
+                },
+            },
+        },
+        max_body_size = {
+            type = "integer",
+            exclusiveMinimum = 0,
+            default = 1024 * 1024,
+        },
+        max_req_post_args = {
+            type = "integer",
+            default = 100,
+            minimum = 0,
+        }
+    },
+}
+
+
+local _M = {
+    version = 0.1,
+    priority = 1500,
+    name = plugin_name,
+    schema = schema,
+}
+
+
+function _M.check_schema(conf)
+    local ok, err = core.schema.check(schema, conf)
+    if not ok then
+         return false, err
+    end
+    return true
+end
+
+
+local function regex_replace(origin, regex, new)
+    local res, n, err = re_sub(origin, regex, new, "jo")
+    if not res then
+        core.log.error("failed to apply regex substitution: ", err)
+    end
+    return res, n
+end
+
+
+local function mask_table(tab, conf)
+    if not tab[conf.name] then
+        return false
+    end
+    local masked = false
+    if conf.action == "remove" then
+        tab[conf.name] = nil
+        masked = true
+    elseif conf.action == "replace" then
+        tab[conf.name] = conf.value
+        masked = true
+    elseif conf.action == "regex" then
+        local val = tab[conf.name]
+        if type(val) == "table" then
+            -- same parameter appeared multiple times; apply regex to each 
entry
+            for i, v in ipairs(val) do
+                if type(v) ~= "string" then
+                    core.log.warn("data-mask: skipping regex for non-string 
value in field: ",
+                        conf.name)
+                else
+                    local new_v, n = regex_replace(v, conf.regex, conf.value)
+                    if new_v ~= nil and n > 0 then
+                        val[i] = new_v
+                        masked = true
+                    end
+                end
+            end
+        else
+            if type(val) ~= "string" then
+                core.log.warn("data-mask: skipping regex for non-string value 
in field: ",
+                    conf.name)
+            else
+                local new_val, n = regex_replace(val, conf.regex, conf.value)
+                if new_val ~= nil and n > 0 then
+                    tab[conf.name] = new_val
+                    masked = true
+                end
+            end
+        end
+    end
+    return masked
+end
+
+
+-- jsonpath index of array starts from 0, lua table index starts from 1
+local function table_index(idx)
+    if type(idx) == "number" then
+        return idx + 1
+    end
+    return idx
+end
+
+
+local function mask_json(obj, conf)
+    -- local nodes = jp.nodes(data, '$..author')
+    -- {
+    --   { path = {'$', 'store', 'book', 0, 'author'}, value = 'Nigel Rees' },
+    --   { path = {'$', 'store', 'book', 1, 'author'}, value = 'Evelyn Waugh' 
},
+    -- }
+    local nodes = jp.nodes(obj, conf.name)
+    if not nodes then
+        return false
+    end
+
+    local masked = false
+    for _, node in ipairs(nodes) do
+        local nested = obj
+        -- first element is root($), last element is the field name
+        for i = 2, #node.path - 1 do
+            nested = nested[table_index(node.path[i])]
+        end
+        local index = table_index(node.path[#node.path])
+        if conf.action == "remove" then
+            nested[index] = nil
+            masked = true
+        elseif conf.action == "replace" then
+            nested[index] = conf.value
+            masked = true
+        elseif conf.action == "regex" then
+            if type(node.value) ~= "string" then
+                core.log.warn("data-mask: skipping regex for non-string value 
at path: ",
+                              conf.name)
+            else
+                local new_val, n = regex_replace(node.value, conf.regex, 
conf.value)
+                if new_val ~= nil and n > 0 then
+                    nested[index] = new_val
+                    masked = true
+                end
+            end
+        end
+    end
+    return masked
+end
+
+
+function _M.log(conf, ctx)
+    local args = core.request.get_uri_args(ctx)
+    local query_masked = false
+    local post_args
+    local post_args_masked = false
+    local body = ngx.req.get_body_data()
+    local json_body
+    local body_masked = false
+
+    if conf.request then
+        for _, item in ipairs(conf.request) do
+            if item.type == "query" then
+                if mask_table(args, item) then
+                    query_masked = true
+                end
+            end
+
+            if item.type == "header" then
+                local header = core.request.header(ctx, item.name)
+                if header then
+                    if item.action == "remove" then
+                        core.request.set_header(ctx, item.name, nil)
+                    elseif item.action == "replace" then
+                        core.request.set_header(ctx, item.name, item.value)
+                    elseif item.action == "regex" then
+                        local new_header, n = regex_replace(header, 
item.regex, item.value)
+                        if new_header ~= nil and n > 0 then
+                            core.request.set_header(ctx, item.name, new_header)
+                        end
+                    end
+                end
+            end
+
+            if item.type == "body" then
+                if item.body_format == "urlencoded" then
+                    if not post_args then
+                        if body then
+                            local args_err
+                            post_args, args_err = 
ngx.req.get_post_args(conf.max_req_post_args)
+                            if not post_args then
+                                core.log.warn("data-mask: failed to get post 
args: ", args_err)
+                                post_args = {}
+                            elseif args_err then
+                                core.log.warn("data-mask: post args truncated: 
", args_err)
+                            end
+                        else
+                            if ngx.req.get_body_file() then
+                                core.log.warn("data-mask: request body is 
stored in a " ..
+                                    "temporary file, body masking will be 
skipped")
+                            end
+                            post_args = {}
+                        end
+                    end
+                    if mask_table(post_args, item) then
+                        post_args_masked = true
+                    end
+                elseif item.body_format == "json" then
+                    if body and #body <= conf.max_body_size then
+                        if not json_body then
+                            local js, err = core.json.decode(body)
+                            if not js then
+                                core.log.warn("failed to decode json body: ", 
err)
+                            else
+                                json_body = js
+                            end
+                        end
+                        if json_body then
+                            if mask_json(json_body, item) then
+                                body_masked = true
+                            end
+                        end
+                    elseif body and #body > conf.max_body_size then
+                        core.log.warn("data-mask: skipping body masking for 
field '",
+                            item.name, "' because body size (", #body,
+                            ") exceeds max_body_size (", conf.max_body_size, 
")")
+                    elseif not body and ngx.req.get_body_file() then
+                        core.log.warn("data-mask: request body is stored in a 
" ..
+                            "temporary file, body masking will be skipped")
+                    end
+
+                end
+            end
+        end
+    end
+
+    if query_masked then
+        -- for logger plugins
+        core.request.set_uri_args(ctx, args)
+        if next(args) then
+            ctx.var.request_uri = (ctx.var.uri_before_strip or ctx.var.uri)
+                                        .. "?" .. core.string.encode_args(args)
+        else
+            ctx.var.request_uri = (ctx.var.uri_before_strip or ctx.var.uri)
+        end
+        -- for access log
+        ctx.var.request_line = core.request.get_method() .. " " .. 
ctx.var.request_uri
+                                    .. " HTTP/" .. 
core.request.get_http_version()
+        -- TODO: handle upstream_uri in access log when enable proxy-rewrite
+    end
+
+    if post_args_masked then
+        ngx.req.set_body_data(core.string.encode_args(post_args))
+    end
+
+    if body_masked then
+        ngx.req.set_body_data(core.json.encode(json_body))
+    end
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 9922d3c81..6023c83bc 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -509,6 +509,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - forward-auth                   # priority: 2002
   - opa                            # priority: 2001
   - authz-keycloak                 # priority: 2000
+  - data-mask                      # priority: 1500
   #- error-log-logger              # priority: 1091
   - proxy-cache                    # priority: 1085
   - body-transformer               # priority: 1080
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index 6de6e1d92..115448b95 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -151,7 +151,8 @@
             "plugins/csrf",
             "plugins/public-api",
             "plugins/gm",
-            "plugins/chaitin-waf"
+            "plugins/chaitin-waf",
+            "plugins/data-mask"
           ]
         },
         {
diff --git a/docs/en/latest/plugins/data-mask.md 
b/docs/en/latest/plugins/data-mask.md
new file mode 100644
index 000000000..fec14abbe
--- /dev/null
+++ b/docs/en/latest/plugins/data-mask.md
@@ -0,0 +1,308 @@
+---
+title: data-mask
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - data-mask
+description: This document contains information about the Apache APISIX 
data-mask Plugin.
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/data-mask"; />
+</head>
+
+## Description
+
+The `data-mask` Plugin masks or redacts sensitive fields in request data — 
query parameters, headers, and body — before they appear in access logs or 
logger plugins (such as `file-logger` or `http-logger`).
+
+This is useful for preventing credentials, tokens, payment card numbers, and 
other sensitive information from being written to logs.
+
+The plugin runs in the `log` phase and supports three masking actions:
+
+- `remove`: completely removes the field from the request data.
+- `replace`: replaces the field value with a fixed string.
+- `regex`: applies a regular expression substitution to the field value.
+
+:::note
+
+To have masked query parameters reflected in Nginx access logs, configure 
`nginx_config.http.access_log_format` to use the `$request_line` variable 
instead of the default `$request`:
+
+```yaml
+nginx_config:
+  http:
+    access_log_format: '"$request_line" $status $body_bytes_sent 
"$http_referer" "$http_user_agent"'
+```
+
+:::
+
+## Attributes
+
+| Name                | Type    | Required | Default   | Description           
                                                         |
+|---------------------|---------|----------|-----------|--------------------------------------------------------------------------------|
+| request             | array   | False    |           | List of masking rules 
to apply to request data.                                |
+| max_body_size       | integer | False    | 1048576   | Maximum request body 
size in bytes to process. Bodies larger than this value are skipped for body 
masking. |
+| max_req_post_args   | integer | False    | 100       | Maximum number of 
URL-encoded form fields to parse when masking `urlencoded` body data. |
+
+Each object in the `request` array has the following fields:
+
+| Name          | Type   | Required                                  | 
Description                                                                     
                                    |
+|---------------|--------|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
+| type          | string | True                                      | Type of 
request data to mask. One of `query`, `header`, or `body`.                      
                           |
+| name          | string | True                                      | Field 
name to mask. For `query` and `header` types, this is the parameter or header 
name. For `body` type with `body_format: json`, this is a 
[JSONPath](https://goessner.net/articles/JsonPath/) expression. |
+| action        | string | True                                      | Masking 
action to apply. One of `remove`, `replace`, or `regex`.                        
                           |
+| body_format   | string | Required when `type` is `body`            | Format 
of the request body. One of `json` or `urlencoded`.                             
                            |
+| regex         | string | Required when `action` is `regex`         | Regular 
expression pattern to match against the field value. Capture groups can be 
referenced in `value` as `$1`, `$2`, etc. |
+| value         | string | Required when `action` is `replace` or `regex` | 
Replacement value. When used with `action: regex`, capture groups from `regex` 
can be referenced as `$1`, `$2`, etc. |
+
+## Examples
+
+:::note
+You can fetch the `admin_key` from `config.yaml` and save to an environment 
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+### Mask query parameters
+
+The following example creates a route with `data-mask` configured to mask 
query parameters. The `password` parameter is removed entirely, `token` is 
replaced with a fixed string, and the `card` number is partially masked using a 
regex.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {
+    "data-mask": {
+      "request": [
+        {
+          "type": "query",
+          "name": "password",
+          "action": "remove"
+        },
+        {
+          "type": "query",
+          "name": "token",
+          "action": "replace",
+          "value": "*****"
+        },
+        {
+          "type": "query",
+          "name": "card",
+          "action": "regex",
+          "regex": "(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+          "value": "$1-****-****-$2"
+        }
+      ]
+    },
+    "file-logger": {
+      "path": "logs/access.log"
+    }
+  },
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
+
+Send a request with sensitive query parameters:
+
+```shell
+curl 
"http://127.0.0.1:9080/anything?password=secret&token=mytoken&card=1234-5678-9012-3456";
+```
+
+In `logs/access.log`, the logged request URI will have the sensitive fields 
masked:
+
+```
+/anything?token=*****&card=1234-****-****-3456
+```
+
+The `password` parameter is absent, `token` is replaced with `*****`, and only 
the first and last four digits of the card number are preserved.
+
+### Mask request headers
+
+The following example masks sensitive request headers. The `Authorization` 
header is removed, `X-API-Key` is replaced with a fixed string, and a custom 
`X-Card-Number` header is partially masked using a regex.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {
+    "data-mask": {
+      "request": [
+        {
+          "type": "header",
+          "name": "Authorization",
+          "action": "remove"
+        },
+        {
+          "type": "header",
+          "name": "X-API-Key",
+          "action": "replace",
+          "value": "[REDACTED]"
+        },
+        {
+          "type": "header",
+          "name": "X-Card-Number",
+          "action": "regex",
+          "regex": "(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+          "value": "$1-****-****-$2"
+        }
+      ]
+    },
+    "file-logger": {
+      "path": "logs/access.log"
+    }
+  },
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
+
+Send a request with sensitive headers:
+
+```shell
+curl "http://127.0.0.1:9080/anything"; \
+  -H "Authorization: Bearer secret-token" \
+  -H "X-API-Key: my-api-key" \
+  -H "X-Card-Number: 1234-5678-9012-3456"
+```
+
+In `logs/access.log`, the logged request headers will have the sensitive 
values masked.
+
+### Mask JSON body fields using JSONPath
+
+The following example masks fields in a JSON request body. It removes the 
top-level `password` field, replaces the `token` field of every element in the 
`users` array, and applies a regex to the nested `credit.card` field of each 
user.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {
+    "data-mask": {
+      "request": [
+        {
+          "type": "body",
+          "body_format": "json",
+          "name": "$.password",
+          "action": "remove"
+        },
+        {
+          "type": "body",
+          "body_format": "json",
+          "name": "$.users[*].token",
+          "action": "replace",
+          "value": "*****"
+        },
+        {
+          "type": "body",
+          "body_format": "json",
+          "name": "$.users[*].credit.card",
+          "action": "regex",
+          "regex": "(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+          "value": "$1-****-****-$2"
+        }
+      ]
+    },
+    "file-logger": {
+      "include_req_body": true,
+      "path": "logs/access.log"
+    }
+  },
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
+
+Send a request with a JSON body containing sensitive fields:
+
+```shell
+curl "http://127.0.0.1:9080/anything"; \
+  -H "Content-Type: application/json" \
+  -d '{
+    "password": "secret",
+    "users": [
+      {
+        "name": "alice",
+        "token": "tok_abc123",
+        "credit": { "card": "1234-5678-9012-3456" }
+      },
+      {
+        "name": "bob",
+        "token": "tok_xyz789",
+        "credit": { "card": "9876-5432-1098-7654" }
+      }
+    ]
+  }'
+```
+
+In `logs/access.log`, the logged request body will have the sensitive fields 
masked:
+
+```json
+{
+  "users": [
+    {
+      "name": "alice",
+      "token": "*****",
+      "credit": { "card": "1234-****-****-3456" }
+    },
+    {
+      "name": "bob",
+      "token": "*****",
+      "credit": { "card": "9876-****-****-7654" }
+    }
+  ]
+}
+```
+
+The `password` field is absent, all `token` fields are replaced with `*****`, 
and card numbers are partially masked.
+
+## Delete Plugin
+
+To remove the `data-mask` Plugin, delete the corresponding JSON configuration 
from the Plugin configuration. APISIX will automatically reload and you do not 
have to restart for this to take effect.
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {},
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index 074c571eb..6499144f7 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -140,7 +140,8 @@
             "plugins/csrf",
             "plugins/public-api",
             "plugins/gm",
-            "plugins/chaitin-waf"
+            "plugins/chaitin-waf",
+            "plugins/data-mask"
           ]
         },
         {
diff --git a/docs/zh/latest/plugins/data-mask.md 
b/docs/zh/latest/plugins/data-mask.md
new file mode 100644
index 000000000..ea7d0abf9
--- /dev/null
+++ b/docs/zh/latest/plugins/data-mask.md
@@ -0,0 +1,309 @@
+---
+title: data-mask
+keywords:
+  - APISIX
+  - API 网关
+  - Plugin
+  - data-mask
+description: API 网关 Apache APISIX data-mask 
插件可用于在请求数据写入访问日志或日志插件之前,对敏感字段进行掩码或脱敏处理。
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/data-mask"; />
+</head>
+
+## 描述
+
+`data-mask` 插件可在请求数据(查询参数、请求头、请求体)写入访问日志或日志插件(如 
`file-logger`、`http-logger`)之前,对敏感字段进行掩码或脱敏处理。
+
+该插件适用于防止凭证、令牌、支付卡号及其他敏感信息被写入日志的场景。
+
+插件在 `log` 阶段运行,支持以下三种掩码动作:
+
+- `remove`:从请求数据中完全删除该字段。
+- `replace`:将字段值替换为固定字符串。
+- `regex`:对字段值执行正则表达式替换。
+
+:::note
+
+若需要在 Nginx 访问日志中记录掩码后的查询参数,需将 `nginx_config.http.access_log_format` 中的 
`$request` 替换为 `$request_line`:
+
+```yaml
+nginx_config:
+  http:
+    access_log_format: '"$request_line" $status $body_bytes_sent 
"$http_referer" "$http_user_agent"'
+```
+
+:::
+
+## 属性
+
+| 名称                | 类型    | 必选项 | 默认值    | 描述                                
                                             |
+|---------------------|---------|--------|-----------|----------------------------------------------------------------------------------|
+| request             | array   | 否     |           | 需应用于请求数据的掩码规则列表。         
                                         |
+| max_body_size       | integer | 否     | 1048576   | 
处理请求体时允许的最大字节数。超过此大小的请求体将跳过请求体掩码处理。            |
+| max_req_post_args   | integer | 否     | 100       | 解析 `urlencoded` 
请求体时允许的最大表单字段数量。                                |
+
+`request` 数组中每个对象包含以下字段:
+
+| 名称          | 类型   | 必选项                                    | 描述             
                                                                                
                            |
+|---------------|--------|-------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|
+| type          | string | 是                                        | 
要掩码的请求数据类型。可选值:`query`、`header`、`body`。                                         
                          |
+| name          | string | 是                                        | 
要掩码的字段名称。对于 `query` 和 `header` 类型,填写参数名或请求头名;对于 `body` 类型且 `body_format` 为 
`json` 时,填写 [JSONPath](https://goessner.net/articles/JsonPath/) 表达式。 |
+| action        | string | 是                                        | 
掩码动作。可选值:`remove`、`replace`、`regex`。                                            
                                |
+| body_format   | string | 当 `type` 为 `body` 时必填               | 
请求体格式。可选值:`json`、`urlencoded`。                                                  
                                 |
+| regex         | string | 当 `action` 为 `regex` 时必填            | 
用于匹配字段值的正则表达式。可以在 `value` 中通过 `$1`、`$2` 等方式引用捕获组。                               
              |
+| value         | string | 当 `action` 为 `replace` 或 `regex` 时必填 | 替换值。当与 
`action: regex` 配合使用时,可通过 `$1`、`$2` 等引用正则捕获组。                                   
            |
+
+## 示例
+
+:::note
+
+您可以这样从 `config.yaml` 中获取 `admin_key` 并存入环境变量:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+### 掩码查询参数
+
+以下示例创建一条路由并配置 `data-mask` 插件对查询参数进行掩码处理:删除 `password` 参数,将 `token` 
替换为固定字符串,并通过正则表达式对 `card` 号码进行部分掩码。
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {
+    "data-mask": {
+      "request": [
+        {
+          "type": "query",
+          "name": "password",
+          "action": "remove"
+        },
+        {
+          "type": "query",
+          "name": "token",
+          "action": "replace",
+          "value": "*****"
+        },
+        {
+          "type": "query",
+          "name": "card",
+          "action": "regex",
+          "regex": "(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+          "value": "$1-****-****-$2"
+        }
+      ]
+    },
+    "file-logger": {
+      "path": "logs/access.log"
+    }
+  },
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
+
+发送一个包含敏感查询参数的请求:
+
+```shell
+curl 
"http://127.0.0.1:9080/anything?password=secret&token=mytoken&card=1234-5678-9012-3456";
+```
+
+在 `logs/access.log` 中,记录的请求 URI 将对敏感字段进行掩码处理:
+
+```
+/anything?token=*****&card=1234-****-****-3456
+```
+
+`password` 参数已被删除,`token` 被替换为 `*****`,卡号仅保留首尾各四位数字。
+
+### 掩码请求头
+
+以下示例对敏感请求头进行掩码处理:删除 `Authorization` 请求头,将 `X-API-Key` 替换为固定字符串,并通过正则表达式对自定义请求头 
`X-Card-Number` 进行部分掩码。
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {
+    "data-mask": {
+      "request": [
+        {
+          "type": "header",
+          "name": "Authorization",
+          "action": "remove"
+        },
+        {
+          "type": "header",
+          "name": "X-API-Key",
+          "action": "replace",
+          "value": "[REDACTED]"
+        },
+        {
+          "type": "header",
+          "name": "X-Card-Number",
+          "action": "regex",
+          "regex": "(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+          "value": "$1-****-****-$2"
+        }
+      ]
+    },
+    "file-logger": {
+      "path": "logs/access.log"
+    }
+  },
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
+
+发送一个包含敏感请求头的请求:
+
+```shell
+curl "http://127.0.0.1:9080/anything"; \
+  -H "Authorization: Bearer secret-token" \
+  -H "X-API-Key: my-api-key" \
+  -H "X-Card-Number: 1234-5678-9012-3456"
+```
+
+在 `logs/access.log` 中,记录的请求头将对敏感值进行掩码处理。
+
+### 使用 JSONPath 掩码 JSON 请求体字段
+
+以下示例对 JSON 请求体中的字段进行掩码处理:删除顶层的 `password` 字段,替换 `users` 数组中每个元素的 `token` 
字段,并对每个用户的嵌套字段 `credit.card` 应用正则掩码。
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {
+    "data-mask": {
+      "request": [
+        {
+          "type": "body",
+          "body_format": "json",
+          "name": "$.password",
+          "action": "remove"
+        },
+        {
+          "type": "body",
+          "body_format": "json",
+          "name": "$.users[*].token",
+          "action": "replace",
+          "value": "*****"
+        },
+        {
+          "type": "body",
+          "body_format": "json",
+          "name": "$.users[*].credit.card",
+          "action": "regex",
+          "regex": "(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+          "value": "$1-****-****-$2"
+        }
+      ]
+    },
+    "file-logger": {
+      "include_req_body": true,
+      "path": "logs/access.log"
+    }
+  },
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
+
+发送一个包含敏感字段的 JSON 请求体:
+
+```shell
+curl "http://127.0.0.1:9080/anything"; \
+  -H "Content-Type: application/json" \
+  -d '{
+    "password": "secret",
+    "users": [
+      {
+        "name": "alice",
+        "token": "tok_abc123",
+        "credit": { "card": "1234-5678-9012-3456" }
+      },
+      {
+        "name": "bob",
+        "token": "tok_xyz789",
+        "credit": { "card": "9876-5432-1098-7654" }
+      }
+    ]
+  }'
+```
+
+在 `logs/access.log` 中,记录的请求体将对敏感字段进行掩码处理:
+
+```json
+{
+  "users": [
+    {
+      "name": "alice",
+      "token": "*****",
+      "credit": { "card": "1234-****-****-3456" }
+    },
+    {
+      "name": "bob",
+      "token": "*****",
+      "credit": { "card": "9876-****-****-7654" }
+    }
+  ]
+}
+```
+
+`password` 字段已被删除,所有 `token` 字段被替换为 `*****`,卡号进行了部分掩码处理。
+
+## 删除插件
+
+当你需要删除该插件时,可以通过如下命令删除相应的 JSON 配置,APISIX 将会自动重新加载相关配置,无需重启服务:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X 
PUT -d '
+{
+  "uri": "/anything",
+  "plugins": {},
+  "upstream": {
+    "type": "roundrobin",
+    "nodes": {
+      "httpbin.org:80": 1
+    }
+  }
+}'
+```
diff --git a/t/APISIX.pm b/t/APISIX.pm
index facf13e4d..b3cafb832 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -678,7 +678,7 @@ _EOC_
         require("apisix").http_exit_worker()
     }
 
-    log_format main escape=default '\$remote_addr - \$remote_user 
[\$time_local] \$http_host "\$request" \$status \$body_bytes_sent 
\$request_time "\$http_referer" "\$http_user_agent" \$upstream_addr 
\$upstream_status \$apisix_upstream_response_time 
"\$upstream_scheme://\$upstream_host\$upstream_uri" \$request_llm_model 
\$llm_model \$llm_time_to_first_token \$llm_prompt_tokens 
\$llm_completion_tokens "\$rate_limiting_info"';
+    log_format main escape=default '\$remote_addr - \$remote_user 
[\$time_local] \$http_host "\$request_line" \$status \$body_bytes_sent 
\$request_time "\$http_referer" "\$http_user_agent" \$upstream_addr 
\$upstream_status \$apisix_upstream_response_time 
"\$upstream_scheme://\$upstream_host\$upstream_uri" \$request_llm_model 
\$llm_model \$llm_time_to_first_token \$llm_prompt_tokens 
\$llm_completion_tokens "\$rate_limiting_info"';
 
     # fake server, only for test
     server {
@@ -838,6 +838,7 @@ _EOC_
             set \$upstream_scheme             'http';
             set \$upstream_host               \$http_host;
             set \$upstream_uri                '';
+            set \$request_line                '';
 
             content_by_lua_block {
                 apisix.http_admin()
@@ -855,6 +856,7 @@ _EOC_
             set \$upstream_scheme             'http';
             set \$upstream_host               \$http_host;
             set \$upstream_uri                '';
+            set \$request_line                '';
             set \$ctx_ref                     '';
 
             set \$upstream_cache_zone            off;
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index b9f3846ca..eea7505ca 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -92,6 +92,7 @@ attach-consumer-label
 forward-auth
 opa
 authz-keycloak
+data-mask
 proxy-cache
 body-transformer
 ai-request-rewrite
diff --git a/t/plugin/data-mask.t b/t/plugin/data-mask.t
new file mode 100644
index 000000000..d00aae131
--- /dev/null
+++ b/t/plugin/data-mask.t
@@ -0,0 +1,722 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (! $block->request) {
+        $block->set_value("request", "GET /t");
+        if (!$block->response_body) {
+            $block->set_value("response_body", "passed\n");
+        }
+    }
+});
+
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: mask query
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "remove",
+                                        "name": "password",
+                                        "type": "query"
+                                    },
+                                    {
+                                        "action": "replace",
+                                        "name": "token",
+                                        "type": "query",
+                                        "value": "*****"
+                                    },
+                                    {
+                                        "action": "regex",
+                                        "name": "card",
+                                        "regex": 
"(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+                                        "type": "query",
+                                        "value": "$1-****-****-$2"
+                                    }
+                                ]
+                            },
+                            "file-logger": {
+                                "path": "mask-query.log.1"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 2: verify
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local code = 
t("/hello?password=abc&token=xyz&card=1234-1234-1234-1234", ngx.HTTP_GET)
+            local fd, err = io.open("mask-query.log.1", "r")
+            if not fd then
+                core.log.error("failed to open file: ", err)
+                return
+            end
+            local line = fd:read()
+            local log = core.json.decode(line)
+
+            if log.request.querystring.password then
+                ngx.say("password arg mask failed: " .. 
log.request.querystring.password)
+                return
+            end
+            if log.request.querystring.token ~= "*****" then
+                ngx.say("token arg mask failed: " .. 
log.request.querystring.token)
+                return
+            end
+            if log.request.querystring.card ~= "1234-****-****-1234" then
+                ngx.say("card arg mask failed: " .. 
log.request.querystring.card)
+                return
+            end
+            if log.request.uri ~= 
"/hello?token=*****&card=1234-****-****-1234" and
+               log.request.uri ~= 
"/hello?card=1234-****-****-1234&token=*****" then
+                ngx.say("uri mask failed: " .. log.request.uri)
+                return
+            end
+
+            os.remove("mask-query.log.1")
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 3: mask header
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "remove",
+                                        "name": "password",
+                                        "type": "header"
+                                    },
+                                    {
+                                        "action": "replace",
+                                        "name": "token",
+                                        "type": "header",
+                                        "value": "*****"
+                                    },
+                                    {
+                                        "action": "regex",
+                                        "name": "card",
+                                        "regex": 
"(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+                                        "type": "header",
+                                        "value": "$1-****-****-$2"
+                                    }
+                                ]
+                            },
+                            "file-logger": {
+                                "path": "mask-header.log.2"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 4: verify
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local headers = {}
+            headers["password"] = "abc"
+            headers["token"] = "xyz"
+            headers["card"] = "1234-1234-1234-1234"
+            local code = t("/hello", ngx.HTTP_GET, "", nil, headers)
+
+            local fd, err = io.open("mask-header.log.2", "r")
+            if not fd then
+                core.log.error("failed to open file: ", err)
+                return
+            end
+            local line = fd:read()
+            local log = core.json.decode(line)
+
+            if log.request.headers.password then
+                ngx.say("password header mask failed: " .. 
log.request.headers.password)
+                return
+            end
+            if log.request.headers.token ~= "*****" then
+                ngx.say("token header mask failed: " .. 
log.request.headers.token)
+                return
+            end
+            if log.request.headers.card ~= "1234-****-****-1234" then
+                ngx.say("card header mask failed: " .. 
log.request.headers.card)
+                return
+            end
+
+            os.remove("mask-header.log.2")
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 5: mask urlencoded body
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "remove",
+                                        "body_format": "urlencoded",
+                                        "name": "password",
+                                        "type": "body"
+                                    },
+                                    {
+                                        "action": "replace",
+                                        "body_format": "urlencoded",
+                                        "name": "token",
+                                        "type": "body",
+                                        "value": "*****"
+                                    },
+                                    {
+                                        "action": "regex",
+                                        "body_format": "urlencoded",
+                                        "name": "card",
+                                        "regex": 
"(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+                                        "type": "body",
+                                        "value": "$1-****-****-$2"
+                                    }
+                                ]
+                            },
+                            "file-logger": {
+                                "include_req_body": true,
+                                "path": "mask-urlencoded-body.log"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 6: verify
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local code = t("/hello", ngx.HTTP_POST, 
"password=abc&token=xyz&card=1234-1234-1234-1234")
+
+            local fd, err = io.open("mask-urlencoded-body.log", "r")
+            if not fd then
+                core.log.error("failed to open file: ", err)
+                return
+            end
+            local line = fd:read()
+            local log = core.json.decode(line)
+
+            if log.request.body ~= "token=*****&card=1234-****-****-1234" and
+               log.request.body ~= "card=1234-****-****-1234&token=*****" then
+                ngx.say("urlencoded body mask failed: " .. log.request.body)
+                return
+            end
+
+            os.remove("mask-urlencoded-body.log")
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 7: mask json body
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "remove",
+                                        "body_format": "json",
+                                        "name": "$.password",
+                                        "type": "body"
+                                    },
+                                    {
+                                        "action": "replace",
+                                        "body_format": "json",
+                                        "name": "users[*].token",
+                                        "type": "body",
+                                        "value": "*****"
+                                    },
+                                    {
+                                        "action": "regex",
+                                        "body_format": "json",
+                                        "name": "$.users[*].credit.card",
+                                        "regex": 
"(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+                                        "type": "body",
+                                        "value": "$1-****-****-$2"
+                                    }
+                                ]
+                            },
+                            "file-logger": {
+                                "include_req_body": true,
+                                "path": "mask-json-body.log"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 8: verify
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local code = t("/hello",
+                ngx.HTTP_POST,
+                [[{
+                  "password": "abc",
+                  "users": [
+                    {
+                      "token": "xyz",
+                      "credit": {
+                        "card": "1234-1234-1234-1234"
+                      }
+                    },
+                    {
+                      "token": "xyz",
+                      "credit": {
+                        "card": "1234-1234-1234-1234"
+                      }
+                    }
+                  ]
+                }]]
+            )
+
+            local fd, err = io.open("mask-json-body.log", "r")
+            if not fd then
+                core.log.error("failed to open file: ", err)
+                return
+            end
+            local line = fd:read()
+            local log = core.json.decode(line)
+
+            local body = core.json.decode(log.request.body)
+            if body.password then
+                ngx.say("$.password mask failed: " .. body.password)
+                return
+            end
+            for _, user in ipairs(body.users) do
+                if user.token ~= "*****" then
+                    ngx.say("$.users[*].token mask failed: " .. user.token)
+                    return
+                end
+                if user.credit.card ~= "1234-****-****-1234" then
+                    ngx.say("$.users[*].credit.card mask failed: " .. 
user.credit.card)
+                    return
+                end
+            end
+
+            os.remove("mask-json-body.log")
+            ngx.say("success")
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 9: plugin within global rule should not throw error for missing body.
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/global_rules/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "remove",
+                                        "name": "password",
+                                        "type": "query"
+                                    },
+                                    {
+                                        "action": "replace",
+                                        "name": "token",
+                                        "type": "query",
+                                        "value": "*****"
+                                    },
+                                    {
+                                        "action": "regex",
+                                        "name": "card",
+                                        "regex": 
"(\\d+)\\-\\d+\\-\\d+\\-(\\d+)",
+                                        "type": "query",
+                                        "value": "$1-****-****-$2"
+                                    }
+                                ]
+                            },
+                            "file-logger": {
+                                "path": "mask-query.log.4"
+                            }
+                        }
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 10: verify
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local code = t("/random", ngx.HTTP_POST, 
"password=abc&token=xyz&card=1234-1234-1234-1234")
+
+            ngx.say("code: ", code)
+        }
+    }
+--- response_body
+code: 404
+--- no_error_log
+no request body found
+
+
+
+=== TEST 11: create plugin with default value for `max_req_post_args`
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "regex",
+                                        "body_format": "urlencoded",
+                                        "name": "arg100",
+                                        "regex": "(\\d+)$",
+                                        "type": "body",
+                                        "value": "$1"
+                                    }
+                                ]
+                            },
+                            "file-logger": {
+                                "include_req_body": true,
+                                "path": "mask-urlencoded-body.log"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 12: verify default value for `max_req_post_args`
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local url_encoded = "arg1=1"
+            for i = 2, 110, 1 do
+                url_encoded = url_encoded .. "&arg" .. i .. "=" .. i
+            end
+
+            local code = t("/hello", ngx.HTTP_POST, url_encoded)
+
+            local fd, err = io.open("mask-urlencoded-body.log", "r")
+            if not fd then
+                core.log.error("failed to open file: ", err)
+                return
+            end
+            local line = fd:read()
+            local log = core.json.decode(line)
+            local match100, err = ngx.re.match(log.request.body, "arg100=100")
+            local match101, err = ngx.re.match(log.request.body, "arg101=101")
+            os.remove("mask-urlencoded-body.log")
+            if match100 and not match101 then
+                ngx.say("success")
+                return
+            end
+            ngx.say("failed: match100=" .. tostring(match100) .. ", match101=" 
.. tostring(match101))
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 13: create plugin with custom `max_req_post_args` value
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "regex",
+                                        "body_format": "urlencoded",
+                                        "name": "arg10",
+                                        "regex": "(\\d+)$",
+                                        "type": "body",
+                                        "value": "$1"
+                                    }
+                                ],
+                                "max_req_post_args": 10
+                            },
+                            "file-logger": {
+                                "include_req_body": true,
+                                "path": "mask-urlencoded-body.log"
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 14: verify number of args
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local t = require("lib.test_admin").test
+
+            local url_encoded = "arg1=1"
+            for i = 2, 110, 1 do
+                url_encoded = url_encoded .. "&arg" .. i .. "=" .. i
+            end
+
+            local code = t("/hello", ngx.HTTP_POST, url_encoded)
+
+            local fd, err = io.open("mask-urlencoded-body.log", "r")
+            if not fd then
+                core.log.error("failed to open file: ", err)
+                return
+            end
+            local line = fd:read()
+            local log = core.json.decode(line)
+            local match10, err = ngx.re.match(log.request.body, "arg10=10")
+            local match11, err = ngx.re.match(log.request.body, "arg11=11")
+            os.remove("mask-urlencoded-body.log")
+            if match10 and not match11 then
+                ngx.say("success")
+                return
+            end
+            ngx.say("failed: match10=" .. tostring(match10) .. ", match11=" .. 
tostring(match11))
+
+        }
+    }
+--- response_body
+success
+
+
+
+=== TEST 15: create route for access log masking test
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "plugins": {
+                            "data-mask": {
+                                "request": [
+                                    {
+                                        "action": "remove",
+                                        "name": "password",
+                                        "type": "query"
+                                    },
+                                    {
+                                        "action": "replace",
+                                        "name": "token",
+                                        "type": "query",
+                                        "value": "*****"
+                                    }
+                                ]
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1982": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+
+
+
+=== TEST 16: verify access log masks sensitive query parameters
+--- extra_yaml_config
+nginx_config:
+    http:
+        access_log_format: main '$request_line';
+--- request
+GET /hello?password=secret&token=mytoken
+--- access_log eval
+qr/GET \/hello\?token=\*\*\*\*\* HTTP\/\d+\.\d+/

Reply via email to