-- -- 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 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 ngx_header = ngx.header local re_match = ngx.re.match 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 content_decode = require("apisix.utils.content-decode") local lrucache = core.lrucache.new({ type = "plugin", }) local schema = { type = "object", properties = { headers = { description = "new headers for response", anyOf = { { type = "object", minProperties = 1, patternProperties = { ["^[^:]+$"] = { oneOf = { {type = "string"}, {type = "number"}, } } }, }, { properties = { add = { type = "array", minItems = 1, items = { type = "string", -- "Set-Cookie: =; Max-Age=" pattern = "^[^:]+:[^:]*[^/]$" } }, set = { type = "object", minProperties = 1, patternProperties = { ["^[^:]+$"] = { oneOf = { {type = "string"}, {type = "number"}, } } }, }, remove = { type = "array", minItems = 1, items = { type = "string", -- "Set-Cookie" pattern = "^[^:]+$" } }, }, } } }, body = { description = "new body for response", type = "string", }, body_base64 = { description = "whether new body for response need base64 decode before return", type = "boolean", default = false, }, status_code = { description = "new status code for response", type = "integer", minimum = 200, maximum = 598, }, 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", } }, }, }, }, dependencies = { body = { ["not"] = {required = {"filters"}} }, filters = { ["not"] = {required = {"body"}} } } } local _M = { version = 0.1, priority = 899, name = plugin_name, schema = schema, } local function vars_matched(conf, ctx) if not conf.vars then return true end if not conf.response_expr then local response_expr, _ = expr.new(conf.vars) conf.response_expr = response_expr end local match_result = conf.response_expr:eval(ctx.var) return match_result end local function is_new_headers_conf(headers) return (headers.add and type(headers.add) == "table") or (headers.set and type(headers.set) == "table") or (headers.remove and type(headers.remove) == "table") end local function check_set_headers(headers) for field, value in pairs(headers) do if type(field) ~= 'string' then return false, 'invalid type as header field' end if type(value) ~= 'string' and type(value) ~= 'number' then return false, 'invalid type as header value' end if #field == 0 then return false, 'invalid field length in header' end end return true end function _M.check_schema(conf) local ok, err = core.schema.check(schema, conf) if not ok then return false, err end if conf.headers then if not is_new_headers_conf(conf.headers) then ok, err = check_set_headers(conf.headers) if not ok then return false, err end end end if conf.body_base64 then if not conf.body or #conf.body == 0 then return false, 'invalid base64 content' end local body = ngx.decode_base64(conf.body) if not body then return false, 'invalid base64 content' end end if conf.vars then local ok, err = expr.new(conf.vars) if not ok then return false, "failed to validate the 'vars' expression: " .. err 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 do function _M.body_filter(conf, ctx) if not ctx.response_rewrite_matched then return end if conf.filters then local body = core.response.hold_body_chunk(ctx) if not body then return end local err if ctx.response_encoding ~= nil then local decoder = content_decode.dispatch_decoder(ctx.response_encoding) if not decoder then core.log.error("filters may not work as expected ", "due to unsupported compression encoding type: ", ctx.response_encoding) return end body, err = decoder(body) if err ~= nil then core.log.error("filters may not work as expected: ", err) return end end 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 ngx.arg[2] = true if conf.body_base64 then ngx.arg[1] = ngx.decode_base64(conf.body) else ngx.arg[1] = conf.body end end end local function create_header_operation(hdr_conf) local set = {} local add = {} if is_new_headers_conf(hdr_conf) then if hdr_conf.add then for _, value in ipairs(hdr_conf.add) do local m, err = re_match(value, [[^([^:\s]+)\s*:\s*([^:]+)$]], "jo") if not m then return nil, err end core.table.insert_tail(add, m[1], m[2]) end end if hdr_conf.set then for field, value in pairs(hdr_conf.set) do --reform header from object into array, so can avoid use pairs, which is NYI core.table.insert_tail(set, field, value) end end else for field, value in pairs(hdr_conf) do core.table.insert_tail(set, field, value) end end return { add = add, set = set, remove = hdr_conf.remove or {}, } end function _M.header_filter(conf, ctx) ctx.response_rewrite_matched = vars_matched(conf, ctx) if not ctx.response_rewrite_matched then return end if conf.status_code then ngx.status = conf.status_code end -- if filters have no any match, response body won't be modified. if conf.filters or conf.body then local response_encoding = ngx_header["Content-Encoding"] core.response.clear_header_as_body_modified() ctx.response_encoding = response_encoding end if not conf.headers then return end local hdr_op, err = core.lrucache.plugin_ctx(lrucache, ctx, nil, create_header_operation, conf.headers) if not hdr_op then core.log.error("failed to create header operation: ", err) return end local field_cnt = #hdr_op.add for i = 1, field_cnt, 2 do local val = core.utils.resolve_var(hdr_op.add[i+1], ctx.var) core.response.add_header(hdr_op.add[i], val) end local field_cnt = #hdr_op.set for i = 1, field_cnt, 2 do local val = core.utils.resolve_var(hdr_op.set[i+1], ctx.var) core.response.set_header(hdr_op.set[i], val) end local field_cnt = #hdr_op.remove for i = 1, field_cnt do core.response.set_header(hdr_op.remove[i], nil) end end end -- do return _M