Files
2026-07-13 12:32:21 +08:00

561 lines
16 KiB
Lua

local multipart = require "multipart"
local cjson = require("cjson.safe").new()
local pl_template = require "pl.template"
local sandbox = require "kong.tools.sandbox"
local cycle_aware_deep_copy = require("kong.tools.table").cycle_aware_deep_copy
local table_insert = table.insert
local get_uri_args = kong.request.get_query
local set_uri_args = kong.service.request.set_query
local clear_header = kong.service.request.clear_header
local get_header = kong.request.get_header
local set_header = kong.service.request.set_header
local get_headers = kong.request.get_headers
local set_headers = kong.service.request.set_headers
local set_method = kong.service.request.set_method
local set_path = kong.service.request.set_path
local get_raw_body = kong.request.get_raw_body
local set_raw_body = kong.service.request.set_raw_body
local encode_args = ngx.encode_args
local ngx_decode_args = ngx.decode_args
local type = type
local str_find = string.find
local pairs = pairs
local error = error
local rawset = rawset
local lua_enabled = sandbox.configuration.enabled
local sandbox_enabled = sandbox.configuration.sandbox_enabled
local _M = {}
local template_cache = setmetatable( {}, { __mode = "k" })
local DEBUG = ngx.DEBUG
local CONTENT_LENGTH = "content-length"
local CONTENT_TYPE = "content-type"
local HOST = "host"
local JSON, MULTI, ENCODED = "json", "multi_part", "form_encoded"
local EMPTY = require("kong.tools.table").EMPTY
local compile_opts = {
escape = "\xff", -- disable '#' as a valid template escape
}
cjson.decode_array_with_array_mt(true)
local function parse_json(body)
if body then
return cjson.decode(body)
end
end
local function decode_args(body)
if body then
return ngx_decode_args(body)
end
return {}
end
local function get_content_type(content_type)
if content_type == nil then
return
end
if str_find(content_type:lower(), "application/json", nil, true) then
return JSON
elseif str_find(content_type:lower(), "multipart/form-data", nil, true) then
return MULTI
elseif str_find(content_type:lower(), "application/x-www-form-urlencoded", nil, true) then
return ENCODED
end
end
local function param_value(source_template, config_array, template_env)
if not source_template or source_template == "" then
return nil
end
if not lua_enabled then
-- Detect expressions in the source template
local expr = str_find(source_template, "%$%(.*%)")
if expr then
return nil, "loading of untrusted Lua code disabled because " ..
"'untrusted_lua' config option is set to 'off'"
end
-- Lua is disabled, no need to render the template
return source_template
end
-- find compiled templates for this plugin-configuration array
local compiled_templates = template_cache[config_array]
if not compiled_templates then
compiled_templates = {}
-- store it by `config_array` which is part of the plugin `conf` table
-- it will be GC'ed at the same time as `conf` and hence invalidate the
-- compiled templates here as well as the cache-table has weak-keys
template_cache[config_array] = compiled_templates
end
-- Find or compile the specific template
local compiled_template = compiled_templates[source_template]
if not compiled_template then
compiled_template = assert(pl_template.compile(source_template, compile_opts))
compiled_templates[source_template] = compiled_template
end
return compiled_template:render(template_env)
end
local function iter(config_array, template_env)
return function(config_array, i, previous_name, previous_value)
i = i + 1
local current_pair = config_array[i]
if current_pair == nil then -- n + 1
return nil
end
local current_name, current_value = current_pair:match("^([^:]+):*(.-)$")
if current_value == "" then
return i, current_name
end
local res, err = param_value(current_value, config_array, template_env)
if err then
return error("[request-transformer] failed to render the template " ..
current_value .. ", error:" .. err)
end
kong.log.debug("[request-transformer] template `", current_value,
"` rendered to `", res, "`")
return i, current_name, res
end, config_array, 0
end
local function append_value(current_value, value)
local current_value_type = type(current_value)
if current_value_type == "string" then
return { current_value, value }
elseif current_value_type == "table" then
table_insert(current_value, value)
return current_value
else
return { value }
end
end
local function rename(tbl, old_name, new_name)
if old_name == new_name then
return
end
local value = tbl[old_name]
if value then
tbl[old_name] = nil
tbl[new_name] = value
return true
end
end
local function transform_headers(conf, template_env)
local headers = get_headers()
local headers_to_remove = {}
headers.host = nil
-- Remove header(s)
for _, name, value in iter(conf.remove.headers, template_env) do
name = name:lower()
if headers[name] then
headers[name] = nil
headers_to_remove[name] = true
end
end
-- Rename headers(s)
for _, old_name, new_name in iter(conf.rename.headers, template_env) do
local lower_old_name, lower_new_name = old_name:lower(), new_name:lower()
-- headers by default are case-insensitive
-- but if we have a case change, we need to handle it as a special case
local need_remove
if lower_old_name == lower_new_name then
need_remove = rename(headers, old_name, new_name)
else
need_remove = rename(headers, lower_old_name, lower_new_name)
end
if need_remove then
headers_to_remove[old_name] = true
end
end
-- Replace header(s)
for _, name, value in iter(conf.replace.headers, template_env) do
name = name:lower()
if headers[name] or name == HOST then
headers[name] = value
end
end
-- Add header(s)
for _, name, value in iter(conf.add.headers, template_env) do
if not headers[name] and name:lower() ~= HOST then
headers[name] = value
end
end
-- Append header(s)
for _, name, value in iter(conf.append.headers, template_env) do
local name_lc = name:lower()
if name_lc ~= HOST and name ~= name_lc and headers[name] ~= nil then
-- keep original content, use configd case
-- note: the __index method of table returned by ngx.req.get_header
-- is overwritten to check for lower case as well, see documentation
-- for ngx.req.get_header to get more information
-- effectively, it does this: headers[name] = headers[name] or headers[name_lc]
headers[name] = headers[name]
headers[name_lc] = nil
end
headers[name] = append_value(headers[name], value)
end
for name, _ in pairs(headers_to_remove) do
clear_header(name)
end
set_headers(headers)
end
local function transform_querystrings(conf, template_env)
if not (#conf.remove.querystring > 0 or #conf.rename.querystring > 0 or
#conf.replace.querystring > 0 or #conf.add.querystring > 0 or
#conf.append.querystring > 0) then
return
end
local querystring = cycle_aware_deep_copy(template_env.query_params)
-- Remove querystring(s)
for _, name, value in iter(conf.remove.querystring, template_env) do
querystring[name] = nil
end
-- Rename querystring(s)
for _, old_name, new_name in iter(conf.rename.querystring, template_env) do
rename(querystring, old_name, new_name)
end
for _, name, value in iter(conf.replace.querystring, template_env) do
if querystring[name] then
querystring[name] = value
end
end
-- Add querystring(s)
for _, name, value in iter(conf.add.querystring, template_env) do
if not querystring[name] then
querystring[name] = value
end
end
-- Append querystring(s)
for _, name, value in iter(conf.append.querystring, template_env) do
querystring[name] = append_value(querystring[name], value)
end
set_uri_args(querystring)
end
local function transform_json_body(conf, body, content_length, template_env)
local removed, renamed, replaced, added, appended = false, false, false, false, false
local content_length = (body and #body) or 0
local parameters = parse_json(body)
if parameters == nil then
if content_length > 0 then
return false, nil
end
parameters = {}
end
if content_length > 0 and #conf.remove.body > 0 then
for _, name, value in iter(conf.remove.body, template_env) do
parameters[name] = nil
removed = true
end
end
if content_length > 0 and #conf.rename.body > 0 then
for _, old_name, new_name in iter(conf.rename.body, template_env) do
renamed = rename(parameters, old_name, new_name) or renamed
end
end
if content_length > 0 and #conf.replace.body > 0 then
for _, name, value in iter(conf.replace.body, template_env) do
if parameters[name] then
parameters[name] = value
replaced = true
end
end
end
if #conf.add.body > 0 then
for _, name, value in iter(conf.add.body, template_env) do
if not parameters[name] then
parameters[name] = value
added = true
end
end
end
if #conf.append.body > 0 then
for _, name, value in iter(conf.append.body, template_env) do
local old_value = parameters[name]
parameters[name] = append_value(old_value, value)
appended = true
end
end
if removed or renamed or replaced or added or appended then
return true, assert(cjson.encode(parameters))
end
end
local function transform_url_encoded_body(conf, body, content_length, template_env)
local renamed, removed, replaced, added, appended = false, false, false, false, false
local parameters = decode_args(body)
if content_length > 0 and #conf.remove.body > 0 then
for _, name, value in iter(conf.remove.body, template_env) do
parameters[name] = nil
removed = true
end
end
if content_length > 0 and #conf.rename.body > 0 then
for _, old_name, new_name in iter(conf.rename.body, template_env) do
renamed = rename(parameters, old_name, new_name) or renamed
end
end
if content_length > 0 and #conf.replace.body > 0 then
for _, name, value in iter(conf.replace.body, template_env) do
if parameters[name] then
parameters[name] = value
replaced = true
end
end
end
if #conf.add.body > 0 then
for _, name, value in iter(conf.add.body, template_env) do
if parameters[name] == nil then
parameters[name] = value
added = true
end
end
end
if #conf.append.body > 0 then
for _, name, value in iter(conf.append.body, template_env) do
local old_value = parameters[name]
parameters[name] = append_value(old_value, value)
appended = true
end
end
if removed or renamed or replaced or added or appended then
return true, encode_args(parameters)
end
end
local function transform_multipart_body(conf, body, content_length, content_type_value, template_env)
local removed, renamed, replaced, added, appended = false, false, false, false, false
local parameters = multipart(body and body or "", content_type_value)
if content_length > 0 and #conf.rename.body > 0 then
for _, old_name, new_name in iter(conf.rename.body, template_env) do
local para = parameters:get(old_name)
if para and old_name ~= new_name then
local value = para.value
parameters:set_simple(new_name, value)
parameters:delete(old_name)
renamed = true
end
end
end
if content_length > 0 and #conf.remove.body > 0 then
for _, name, value in iter(conf.remove.body, template_env) do
parameters:delete(name)
removed = true
end
end
if content_length > 0 and #conf.replace.body > 0 then
for _, name, value in iter(conf.replace.body, template_env) do
if parameters:get(name) then
parameters:delete(name)
parameters:set_simple(name, value)
replaced = true
end
end
end
if #conf.add.body > 0 then
for _, name, value in iter(conf.add.body, template_env) do
if not parameters:get(name) then
parameters:set_simple(name, value)
added = true
end
end
end
if removed or renamed or replaced or added or appended then
return true, parameters:tostring()
end
end
local function transform_body(conf, template_env)
local content_type_value = get_header(CONTENT_TYPE)
local content_type = get_content_type(content_type_value)
if content_type == nil or #conf.rename.body < 1 and
#conf.remove.body < 1 and #conf.replace.body < 1 and
#conf.add.body < 1 and #conf.append.body < 1 then
return
end
-- Call req_read_body to read the request body first
local body, err = get_raw_body()
if err then
kong.log.warn(err)
end
local is_body_transformed = false
local content_length = (body and #body) or 0
if content_type == ENCODED then
is_body_transformed, body = transform_url_encoded_body(conf, body, content_length, template_env)
elseif content_type == MULTI then
is_body_transformed, body = transform_multipart_body(conf, body, content_length, content_type_value, template_env)
elseif content_type == JSON then
is_body_transformed, body = transform_json_body(conf, body, content_length, template_env)
end
if is_body_transformed then
set_raw_body(body)
set_header(CONTENT_LENGTH, #body)
end
end
local function transform_method(conf)
if conf.http_method then
set_method(conf.http_method:upper())
if conf.http_method == "GET" or conf.http_method == "HEAD" or conf.http_method == "TRACE" then
local content_type_value = get_header(CONTENT_TYPE)
local content_type = get_content_type(content_type_value)
if content_type == ENCODED then
-- Also put the body into querystring
local body = get_raw_body()
local parameters = decode_args(body)
-- Append to querystring
if type(parameters) == "table" and next(parameters) then
local querystring = get_uri_args()
for name, value in pairs(parameters) do
if querystring[name] then
if type(querystring[name]) == "table" then
append_value(querystring[name], value)
else
querystring[name] = { querystring[name], value }
end
else
querystring[name] = value
end
end
set_uri_args(querystring)
end
end
end
end
end
local function transform_uri(conf, template_env)
if conf.replace.uri then
local res, err = param_value(conf.replace.uri, conf.replace, template_env)
if err then
error("[request-transformer] failed to render the template " ..
tostring(conf.replace.uri) .. ", error:" .. err)
end
kong.log.debug(DEBUG, "[request-transformer] template `", conf.replace.uri,
"` rendered to `", res, "`")
if res then
set_path(res)
end
end
end
function _M.execute(conf)
-- meta table for the sandbox, exposing lazily loaded values
local __meta_environment = {
__index = function(self, key)
local lazy_loaders = {
headers = function(self)
return get_headers() or EMPTY
end,
query_params = function(self)
return get_uri_args() or EMPTY
end,
uri_captures = function(self)
return (ngx.ctx.router_matches or EMPTY).uri_captures or EMPTY
end,
shared = function(self)
return ((kong or EMPTY).ctx or EMPTY).shared or EMPTY
end,
}
local loader = lazy_loaders[key]
if not loader then
if lua_enabled and not sandbox_enabled then
return _G[key]
end
return
end
-- set the result on the table to not load again
local value = loader()
rawset(self, key, value)
return value
end,
__newindex = function(self)
error("This environment is read-only.")
end,
}
local template_env = {}
if lua_enabled and sandbox_enabled then
-- load the sandbox environment to be used to render the template
template_env = cycle_aware_deep_copy(sandbox.configuration.environment)
-- here we can optionally add functions to expose to the sandbox, eg:
-- tostring = tostring,
-- because headers may contain array elements such as duplicated headers
-- type is a useful function in these cases. See issue #25.
template_env.type = type
end
setmetatable(template_env, __meta_environment)
transform_uri(conf, template_env)
transform_method(conf)
transform_headers(conf, template_env)
transform_body(conf, template_env)
transform_querystrings(conf, template_env)
end
return _M