diff --git a/apisix/plugins/response-rewrite.lua b/apisix/plugins/response-rewrite.lua index 7706bc96ed3e..b2c94f2aff1e 100644 --- a/apisix/plugins/response-rewrite.lua +++ b/apisix/plugins/response-rewrite.lua @@ -16,10 +16,15 @@ -- local core = require("apisix.core") local expr = require("resty.expr.v1") +local re_compile = require("resty.core.regex").re_match_compile local plugin_name = "response-rewrite" local ngx = ngx +local re_sub = ngx.re.sub +local re_gsub = ngx.re.gsub local pairs = pairs +local ipairs = ipairs local type = type +local pcall = pcall local schema = { @@ -48,6 +53,40 @@ local schema = { vars = { type = "array", }, + filters = { + description = "a group of filters that modify response body" .. + "by replacing one specified string by another", + type = "array", + minItems = 1, + items = { + description = "filter that modifies response body", + type = "object", + required = {"regex", "replace"}, + properties = { + regex = { + description = "match pattern on response body", + type = "string", + minLength = 1, + }, + scope = { + description = "regex substitution range", + type = "string", + enum = {"once", "global"}, + default = "once", + }, + replace = { + description = "regex substitution content", + type = "string", + }, + options = { + description = "regex options", + type = "string", + default = "jo", + } + }, + }, + }, + oneOf = {"body", "filters"}, }, minProperties = 1, } @@ -115,6 +154,16 @@ function _M.check_schema(conf) end end + if conf.filters then + for _, filter in ipairs(conf.filters) do + local ok, err = pcall(re_compile, filter.regex, filter.options) + if not ok then + return false, "regex \"" .. filter.regex .. + "\" validation failed: " .. err + end + end + end + return true end @@ -126,6 +175,29 @@ function _M.body_filter(conf, ctx) return end + if conf.filters then + + local body = core.response.hold_body_chunk(ctx) + if not body then + return + end + + local err + for _, filter in ipairs(conf.filters) do + if filter.scope == "once" then + body, _, err = re_sub(body, filter.regex, filter.replace, filter.options) + else + body, _, err = re_gsub(body, filter.regex, filter.replace, filter.options) + end + if err ~= nil then + core.log.error("regex \"" .. filter.regex .. "\" substitutes failed:" .. err) + end + end + + ngx.arg[1] = body + return + end + if conf.body then if conf.body_base64 then @@ -148,7 +220,8 @@ function _M.header_filter(conf, ctx) ngx.status = conf.status_code end - if conf.body then + -- if filters have no any match, response body won't be modified. + if conf.filters or conf.body then core.response.clear_header_as_body_modified() end diff --git a/docs/en/latest/plugins/response-rewrite.md b/docs/en/latest/plugins/response-rewrite.md index 6920e2643886..a32ccfed3399 100644 --- a/docs/en/latest/plugins/response-rewrite.md +++ b/docs/en/latest/plugins/response-rewrite.md @@ -32,13 +32,20 @@ response rewrite plugin, rewrite the content returned by the upstream as well as ## Attributes -| Name | Type | Requirement | Default | Valid | Description | -| ----------- | ------- | ----------- | ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| status_code | integer | optional | | [200, 598] | New `status code` to client, keep the original response code by default. | -| body | string | optional | | | New `body` to client, and the content-length will be reset too. | -| body_base64 | boolean | optional | false | | Identify if `body` in configuration need base64 decoded before rewrite to client. | -| headers | object | optional | | | Set the new `headers` for client, can set up multiple. If it exists already from upstream, will rewrite the header, otherwise will add the header. You can set the corresponding value to an empty string to remove a header. The value can contain Nginx variables in `$var` format, like `$remote_addr $balancer_ip` | -| vars | array[] | optional | | | A DSL to evaluate with the given ngx.var. See `vars` [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list). if the `vars` is empty, then all rewrite operations will be executed unconditionally | +| Name | Type | Requirement | Default | Valid | Description | +|-----------------|---------|-------------|---------|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| status_code | integer | optional | | [200, 598] | New `status code` to client, keep the original response code by default. | +| body | string | optional | | | New `body` to client, and the content-length will be reset too. | +| body_base64 | boolean | optional | false | | Identify if `body` in configuration need base64 decoded before rewrite to client. | +| headers | object | optional | | | Set the new `headers` for client, can set up multiple. If it exists already from upstream, will rewrite the header, otherwise will add the header. You can set the corresponding value to an empty string to remove a header. The value can contain Nginx variables in `$var` format, like `$remote_addr $balancer_ip`. | +| vars | array[] | optional | | | A DSL to evaluate with the given ngx.var. See `vars` [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list). if the `vars` is empty, then all rewrite operations will be executed unconditionally. | +| filters | array[] | optional | | | A group of filters that modify response body by replacing one specified string by another. | +| filters.regex | string | required | | | match pattern on response body. | +| filters.scope | string | optional | "once" | "once","global" | substitution range, "once" substitutes the first match of `filters.regex` on response body, "global" does global substitution. | +| filters.replace | string | required | | | substitution content. | +| filters.options | string | optional | "jo" | | regex options, See [ngx.re.match](https://github.com/openresty/lua-nginx-module#ngxrematch). | + +Only one of `body`, `filters` can be specified. ## How To Enable diff --git a/docs/zh/latest/plugins/response-rewrite.md b/docs/zh/latest/plugins/response-rewrite.md index 7cee59665c8c..15e77c9fa3ba 100644 --- a/docs/zh/latest/plugins/response-rewrite.md +++ b/docs/zh/latest/plugins/response-rewrite.md @@ -33,13 +33,20 @@ title: response-rewrite ## 属性 -| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | -| ----------- | ------- | ------ | ------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| status_code | integer | 可选 | | [200, 598] | 修改上游返回状态码,默认保留原始响应代码。 | -| body | string | 可选 | | | 修改上游返回的 `body` 内容,如果设置了新内容,header 里面的 content-length 字段也会被去掉 | -| body_base64 | boolean | 可选 | false | | 描述 `body` 字段是否需要 base64 解码之后再返回给客户端,用在某些图片和 Protobuffer 场景 | -| headers | object | 可选 | | | 返回给客户端的 `headers`,这里可以设置多个。头信息如果存在将重写,不存在则添加。想要删除某个 header 的话,把对应的值设置为空字符串即可。这个值能够以 `$var` 的格式包含 Nginx 变量,比如 `$remote_addr $balancer_ip` | -| vars | array[] | 可选 | | | `vars` 是一个表达式列表,只有满足条件的请求和响应才会修改 body 和 header 信息,来自 [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list)。如果 `vars` 字段为空,那么所有的重写动作都会被无条件的执行。 | +| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | +|-----------------|---------|-----|--------|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| status_code | integer | 可选 | | [200, 598] | 修改上游返回状态码,默认保留原始响应代码。 | +| body | string | 可选 | | | 修改上游返回的 `body` 内容,如果设置了新内容,header 里面的 content-length 字段也会被去掉。 | +| body_base64 | boolean | 可选 | false | | 描述 `body` 字段是否需要 base64 解码之后再返回给客户端,用在某些图片和 Protobuffer 场景。 | +| headers | object | 可选 | | | 返回给客户端的 `headers`,这里可以设置多个。头信息如果存在将重写,不存在则添加。想要删除某个 header 的话,把对应的值设置为空字符串即可。这个值能够以 `$var` 的格式包含 Nginx 变量,比如 `$remote_addr $balancer_ip`。 | +| vars | array[] | 可选 | | | `vars` 是一个表达式列表,只有满足条件的请求和响应才会修改 body 和 header 信息,来自 [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list)。如果 `vars` 字段为空,那么所有的重写动作都会被无条件的执行。 | +| filters | array[] | 可选 | | | 一组过滤器,采用指定字符串表达式修改响应体。 | +| filters.regex | string | 必选 | | | 用于匹配响应体正则表达式。 | +| filters.scope | string | 可选 | "once" | "once","global" | 替换范围,"once" 表达式 `filters.regex` 仅替换首次匹配上响应体的内容,"global" 则进行全局替换。 | +| filters.replace | string | 必选 | | | 替换后的内容。 | +| filters.options | string | 可选 | "jo" | | 正则匹配有效参数,可选项见 [ngx.re.match](https://github.com/openresty/lua-nginx-module#ngxrematch)。 | + +`body` 和 `filters`,两个只能配置其中一个。 ## 示例 diff --git a/t/plugin/response-rewrite2.t b/t/plugin/response-rewrite2.t new file mode 100644 index 000000000000..88712888a28b --- /dev/null +++ b/t/plugin/response-rewrite2.t @@ -0,0 +1,547 @@ +# +# 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. +# +BEGIN { + if ($ENV{TEST_NGINX_CHECK_LEAK}) { + $SkipReason = "unavailable for the hup tests"; + + } else { + $ENV{TEST_NGINX_USE_HUP} = 1; + undef $ENV{TEST_NGINX_USE_STAP}; + } +} + +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); +run_tests; + +__DATA__ + +=== TEST 1: add plugin with valid filters +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.response-rewrite") + local ok, err = plugin.check_schema({ + filters = { + { + regex = "Hello", + scope = "global", + replace = "World", + options = "jo" + } + } + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 2: add plugin with invalid filter required filed +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.response-rewrite") + local ok, err = plugin.check_schema({ + filters = { + { + regex = "Hello", + } + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- request +GET /t +--- response_body +property "filters" validation failed: failed to validate item 1: property "replace" is required +--- no_error_log +[error] + + + +=== TEST 3: add plugin with invalid filter scope +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.response-rewrite") + local ok, err = plugin.check_schema({ + filters = { + { + regex = "Hello", + scope = "two", + replace = "World", + options = "jo" + } + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- request +GET /t +--- response_body +property "filters" validation failed: failed to validate item 1: property "scope" validation failed: matches none of the enum values +--- no_error_log +[error] + + + +=== TEST 4: add plugin with invalid filter empty value +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.response-rewrite") + local ok, err = plugin.check_schema({ + filters = { + { + regex = "", + replace = "world" + } + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- request +GET /t +--- response_body +property "filters" validation failed: failed to validate item 1: property "regex" validation failed: string too short, expected at least 1, got 0 +--- no_error_log +[error] + + + +=== TEST 5: add plugin with invalid filter regex options +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.response-rewrite") + local ok, err = plugin.check_schema({ + filters = { + { + regex = "hello", + replace = "HELLO", + options = "h" + } + } + }) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + } + } +--- request +GET /t +--- error_code eval +200 +--- response_body +regex "hello" validation failed: unknown flag "h" (flags "h") +--- no_error_log +[error] + + + +=== TEST 6: set route with filters and vars expr +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "filters": [ + { + "regex": "hello", + "replace": "test" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 7: check http body that matches filters +--- request +GET /hello +--- response_body +test world + + + +=== TEST 8: filter substitute global +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "filters": [ + { + "regex": "l", + "replace": "t", + "scope": "global" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 9: check http body that substitute global +--- request +GET /hello +--- response_body +hetto wortd + + + +=== TEST 10: filter replace with empty +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "filters": [ + { + "regex": "hello", + "replace": "" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 11: check http body that replace with empty +--- request +GET /hello +--- response_body + world + + + +=== TEST 12: filter replace with words +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "filters": [ + { + "regex": "\\w\\S+$", + "replace": "*" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 13: check http body that replace with words +--- request +GET /hello +--- response_body +hello * + + + +=== TEST 14: set body and filters(body no effect) +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "body": "new body", + "filters": [ + { + "regex": "hello", + "replace": "HELLO" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 15: check http body that set body and filters +--- request +GET /hello +--- response_body +HELLO world + + + +=== TEST 16: set multiple filters +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "filters": [ + { + "regex": "hello", + "replace": "HELLO" + }, + { + "regex": "L", + "replace": "T" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 17: check http body that set multiple filters +--- request +GET /hello +--- response_body +HETLO world + + + +=== TEST 18: filters no any match +--- 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": { + "response-rewrite": { + "vars": [ + ["status","==",200] + ], + "filters": [ + { + "regex": "test", + "replace": "TEST" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 19: check http body that filters no any match +--- request +GET /hello +--- response_body +hello world