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

634 lines
18 KiB
Lua

local pl_path = require "pl.path"
local pl_file = require "pl.file"
local type = type
local pairs = pairs
local ipairs = ipairs
local tostring = tostring
local tonumber = tonumber
local setmetatable = setmetatable
local sort = table.sort
local concat = table.concat
local fmt = string.format
local re_match = ngx.re.match
local join = require("kong.tools.string").join
local isplitn = require("kong.tools.string").isplitn
local splitn = require("kong.tools.string").splitn
local split_once = require("kong.tools.string").split_once
local strip = require("kong.tools.string").strip
local parse_http_time = ngx.parse_http_time
local time = ngx.time
local ngx_re_gmatch = ngx.re.gmatch
local ngx_re_match = ngx.re.match
local lower = string.lower
local max = math.max
local tab_new = require("table.new")
local EMPTY = require("kong.tools.table").EMPTY
local _M = {}
_M.CONTENT_TYPES = {
CONTENT_TYPE = "Content-Type",
CONTENT_TYPE_POST = "application/x-www-form-urlencoded",
CONTENT_TYPE_JSON = "application/json",
CONTENT_TYPE_FORM_DATA = "multipart/form-data",
}
do
local url = require "socket.url"
--- URL escape and format key and value
-- values should be already decoded or the `raw` option should be passed to prevent double-encoding
local function encode_args_value(key, value, raw)
if not raw then
key = url.escape(key)
end
if value ~= nil then
if not raw then
value = url.escape(value)
end
return fmt("%s=%s", key, value)
else
return key
end
end
local function compare_keys(a, b)
local ta = type(a)
if ta == type(b) then
return a < b
end
return ta == "number" -- numbers go first, then the rest of keys (usually strings)
end
-- Recursively URL escape and format key and value
-- Handles nested arrays and tables
local function recursive_encode_args(parent_key, value, raw, no_array_indexes, query)
local sub_keys = {}
for sk in pairs(value) do
sub_keys[#sub_keys + 1] = sk
end
sort(sub_keys, compare_keys)
local sub_value, next_sub_key
for _, sub_key in ipairs(sub_keys) do
sub_value = value[sub_key]
if type(sub_key) == "number" then
if no_array_indexes then
next_sub_key = parent_key .. "[]"
else
next_sub_key = ("%s[%s]"):format(parent_key, tostring(sub_key))
end
else
next_sub_key = ("%s.%s"):format(parent_key, tostring(sub_key))
end
if type(sub_value) == "table" then
recursive_encode_args(next_sub_key, sub_value, raw, no_array_indexes, query)
else
query[#query+1] = encode_args_value(next_sub_key, sub_value, raw)
end
end
end
local ngx_null = ngx.null
--- Encode a Lua table to a querystring
-- Tries to mimic ngx_lua's `ngx.encode_args`, but has differences:
-- * It percent-encodes querystring values.
-- * It also supports encoding for bodies (only because it is used in http_client for specs.
-- * It encodes arrays like Lapis instead of like ngx.encode_args to allow interacting with Lapis
-- * It encodes ngx.null as empty strings
-- * It encodes true and false as "true" and "false"
-- * It is capable of encoding nested data structures:
-- * An array access is encoded as `arr[1]`
-- * A struct access is encoded as `struct.field`
-- * Nested structures can use both: `arr[1].field[3]`
-- @see https://github.com/Mashape/kong/issues/749
-- @param[type=table] args A key/value table containing the query args to encode.
-- @param[type=boolean] raw If true, will not percent-encode any key/value and will ignore special boolean rules.
-- @param[type=boolean] no_array_indexes If true, arrays/map elements will be
-- encoded without an index: 'my_array[]='. By default,
-- array elements will have an index: 'my_array[0]='.
-- @treturn string A valid querystring (without the prefixing '?')
function _M.encode_args(args, raw, no_array_indexes)
local query = {}
local keys = {}
for k in pairs(args) do
keys[#keys+1] = k
end
sort(keys, compare_keys)
for _, key in ipairs(keys) do
local value = args[key]
if type(value) == "table" then
recursive_encode_args(key, value, raw, no_array_indexes, query)
elseif value == ngx_null then
query[#query+1] = encode_args_value(key, "")
elseif value ~= nil or raw then
value = tostring(value)
if value ~= "" then
query[#query+1] = encode_args_value(key, value, raw)
elseif raw or value == "" then
query[#query+1] = key
end
end
end
return concat(query, "&")
end
-- { ["1"] = "a", ["2"] = "b" } becomes {"a", "b"}
-- { "a", "b" } becomes { "a", "b" }
local function decode_array(t)
local keys = {}
local len = 0
for k in pairs(t) do
len = len + 1
local number = tonumber(k)
if not number then
return nil
end
keys[len] = number
end
sort(keys)
local new_t = {}
for i=1,len do
if keys[i] ~= i then
return nil
end
new_t[i] = t[tostring(i)] or t[i]
end
return new_t
end
-- Parses params in post requests
-- Transforms "string-like numbers" inside "array-like" tables into numbers
-- (needs a complete array with no holes starting on "1")
-- { x = {["1"] = "a", ["2"] = "b" } } becomes { x = {"a", "b"} }
-- Transforms empty strings into ngx.null:
-- { x = "" } becomes { x = ngx.null }
-- Transforms the strings "true" and "false" into booleans
-- { x = "true" } becomes { x = true }
function _M.decode_args(args)
local new_args = {}
for k, v in pairs(args) do
if type(v) == "table" then
v = decode_array(v) or v
elseif v == "" then
v = ngx_null
elseif v == "true" then
v = true
elseif v == "false" then
v = false
end
new_args[k] = v
end
return new_args
end
end
--- Checks whether a request is https or was originally https (but already
-- terminated). It will check in the current request (global `ngx` table). If
-- the header `X-Forwarded-Proto` exists -- with value `https` then it will also
-- be considered as an https connection.
-- @param trusted_ip boolean indicating if the client is a trusted IP
-- @param allow_terminated if truthy, the `X-Forwarded-Proto` header will be checked as well.
-- @return boolean or nil+error in case the header exists multiple times
_M.check_https = function(trusted_ip, allow_terminated)
if ngx.var.scheme:lower() == "https" then
return true
end
if not allow_terminated then
return false
end
-- if we trust this IP, examine it's X-Forwarded-Proto header
-- otherwise, we fall back to relying on the client scheme
-- (which was either validated earlier, or we fall through this block)
if trusted_ip then
local scheme = ngx.var.http_x_forwarded_proto
if not scheme then
return false
end
-- we could use the first entry (lower security), or check the contents of
-- each of them (slow). So for now defensive, and error
-- out on multiple entries for the x-forwarded-proto header.
if scheme:find(",", 1, true) then
return nil, "Only one X-Forwarded-Proto header allowed"
end
return scheme:lower() == "https"
end
return false
end
local CONTROLS = [[\x00-\x1F\x7F]]
local HIGHBIT = [[\x80-\xFF]]
local SEPARATORS = [==[ \t()<>@,;:\\\"\/?={}\[\]]==]
local HTTP_TOKEN_FORBID_PATTERN = "[".. CONTROLS .. HIGHBIT .. SEPARATORS .. "]"
--- Validates a token defined by RFC 2616.
-- @param token (string) the string to verify
-- @return the valid token, or `nil+error`
function _M.validate_http_token(token)
if token == nil or token == "" then
return nil, "no token provided"
end
if not re_match(token, HTTP_TOKEN_FORBID_PATTERN, "jo") then
return token
end
return nil, "contains one or more invalid characters. ASCII " ..
"control characters (0-31;127), space, tab and the " ..
"characters ()<>@,;:\\\"/?={}[] are not allowed."
end
-- should we also use validate_http_token for this?
--- Validates a header name.
-- Checks characters used in a header name to be valid, as per nginx only
-- a-z, A-Z, 0-9 and '-' are allowed.
-- @param name (string) the header name to verify
-- @return the valid header name, or `nil+error`
function _M.validate_header_name(name)
if name == nil or name == "" then
return nil, "no header name provided"
end
if re_match(name, "^[a-zA-Z0-9-_]+$", "jo") then
return name
end
return nil, "bad header name '" .. name ..
"', allowed characters are A-Z, a-z, 0-9, '_', and '-'"
end
--- Validates a cookie name.
-- @param name (string) the cookie name to verify
-- @return the valid cookie name, or `nil+error`
_M.validate_cookie_name = _M.validate_http_token
---
-- Given an http status and an optional message, this function will
-- return a body that could be used in `kong.response.exit`.
--
-- * Status 204 will always return nil for the body
-- * 405, 500 and 502 always return a predefined message
-- * If there is a message, it will be used as a body
-- * Otherwise, there's a default body for 401, 404 & 503 responses
--
-- If after applying those rules there's a body, and that body isn't a
-- table, it will be transformed into one of the form `{ message = ... }`,
-- where `...` is the untransformed body.
--
-- This function throws an error on invalid inputs.
--
-- @tparam number status The status to be used
-- @tparam[opt] table|string message The message to be used
-- @tparam[opt] table headers The headers to be used
-- @return table|nil a possible body which can be used in kong.response.exit
-- @usage
--
-- --- 204 always returns nil
-- get_default_exit_body(204) --> nil
-- get_default_exit_body(204, "foo") --> nil
--
-- --- 405, 500 & 502 always return predefined values
--
-- get_default_exit_body(502, "ignored") --> { message = "Bad gateway" }
--
-- --- If message is a table, it is returned
--
-- get_default_exit_body(200, { ok = true }) --> { ok = true }
--
-- --- If message is not a table, it is transformed into one
--
-- get_default_exit_body(200, "ok") --> { message = "ok" }
--
-- --- 401, 404 and 503 provide default values if none is defined
--
-- get_default_exit_body(404) --> { message = "Not found" }
--
do
local _overrides = {
[405] = "Method not allowed",
[500] = "An unexpected error occurred",
[502] = "Bad gateway",
}
local _defaults = {
[401] = "Unauthorized",
[404] = "Not found",
[503] = "Service unavailable",
}
local MIN_STATUS_CODE = 100
local MAX_STATUS_CODE = 599
function _M.get_default_exit_body(status, message)
if type(status) ~= "number" then
error("code must be a number", 2)
elseif status < MIN_STATUS_CODE or status > MAX_STATUS_CODE then
error(fmt("code must be a number between %u and %u", MIN_STATUS_CODE, MAX_STATUS_CODE), 2)
end
if status == 204 then
return nil
end
local body = _overrides[status] or message or _defaults[status]
if body ~= nil and type(body) ~= "table" then
body = { message = body }
end
return body
end
end
do
local CONTENT_TYPE_JSON = "application/json"
local CONTENT_TYPE_GRPC = "application/grpc"
local CONTENT_TYPE_HTML = "text/html"
local CONTENT_TYPE_XML = "application/xml"
local CONTENT_TYPE_PLAIN = "text/plain"
local CONTENT_TYPE_APP = "application"
local CONTENT_TYPE_TEXT = "text"
local CONTENT_TYPE_DEFAULT = "default"
local CONTENT_TYPE_ANY = "*"
local MIME_TYPES = {
[CONTENT_TYPE_GRPC] = "",
[CONTENT_TYPE_HTML] = "text/html; charset=utf-8",
[CONTENT_TYPE_JSON] = "application/json; charset=utf-8",
[CONTENT_TYPE_PLAIN] = "text/plain; charset=utf-8",
[CONTENT_TYPE_XML] = "application/xml; charset=utf-8",
[CONTENT_TYPE_APP] = "application/json; charset=utf-8",
[CONTENT_TYPE_TEXT] = "text/plain; charset=utf-8",
[CONTENT_TYPE_DEFAULT] = "application/json; charset=utf-8",
}
local ERROR_TEMPLATES = {
[CONTENT_TYPE_GRPC] = "",
[CONTENT_TYPE_HTML] = [[
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<h1>Error</h1>
<p>%s.</p>
<p>request_id: %s</p>
</body>
</html>
]],
[CONTENT_TYPE_JSON] = [[
{
"message":"%s",
"request_id":"%s"
}]],
[CONTENT_TYPE_PLAIN] = "%s\nrequest_id: %s\n",
[CONTENT_TYPE_XML] = [[
<?xml version="1.0" encoding="UTF-8"?>
<error>
<message>%s</message>
<requestid>%s</requestid>
</error>
]],
}
local ngx_log = ngx.log
local ERR = ngx.ERR
local custom_error_templates = setmetatable({}, {
__index = function(self, format)
local template_path = kong.configuration["error_template_" .. format]
if not template_path then
rawset(self, format, false)
return false
end
local template, err
if pl_path.exists(template_path) then
template, err = pl_file.read(template_path)
else
err = "file not found"
end
if template then
rawset(self, format, template)
return template
end
ngx_log(ERR, fmt("failed reading the custom %s error template: %s", format, err))
rawset(self, format, false)
return false
end
})
local ACCEPT_PATTERN = [[
((?:[a-z0-9][a-z0-9-!#$&^_+.]+|\*) \/ (?:[a-z0-9][a-z0-9-!#$&^_+.]+|\*))
(?:
\s*;\s*
q = ( 1(?:\.0{0,3}|) | 0(?:\.\d{0,3}|) )
| \s*;\s* [a-z0-9][a-z0-9-!#$&^_+.]+ (?:=[^;]*|)
)*
]]
function _M.get_response_type(accept_header)
local content_type = MIME_TYPES[CONTENT_TYPE_DEFAULT]
if type(accept_header) == "table" then
accept_header = join(",", accept_header)
end
if accept_header ~= nil then
local max_quality = 0
for accept_value in isplitn(accept_header, ",") do
accept_value = strip(accept_value)
local matches = re_match(accept_value, ACCEPT_PATTERN, "ajoxi")
if matches then
local media_type = matches[1]
local q = tonumber(matches[2]) or 1
if q > max_quality then
max_quality = q
content_type = _M.get_mime_type(media_type) or content_type
end
end
end
end
return content_type
end
function _M.get_mime_type(content_header, use_default)
use_default = use_default == nil or use_default
content_header = split_once(strip(content_header), ";")
local mime_type
local entries, count = splitn(content_header, "/", 3)
if count > 1 then
if entries[2] == CONTENT_TYPE_ANY then
if entries[1] == CONTENT_TYPE_ANY then
mime_type = MIME_TYPES[CONTENT_TYPE_DEFAULT]
else
mime_type = MIME_TYPES[entries[1]]
end
else
mime_type = MIME_TYPES[content_header]
end
end
if mime_type or use_default then
return mime_type or MIME_TYPES[CONTENT_TYPE_DEFAULT]
end
return nil, "could not find MIME type"
end
function _M.get_error_template(mime_type)
if mime_type == CONTENT_TYPE_JSON or mime_type == MIME_TYPES[CONTENT_TYPE_JSON] then
return custom_error_templates.json or ERROR_TEMPLATES[CONTENT_TYPE_JSON]
elseif mime_type == CONTENT_TYPE_HTML or mime_type == MIME_TYPES[CONTENT_TYPE_HTML] then
return custom_error_templates.html or ERROR_TEMPLATES[CONTENT_TYPE_HTML]
elseif mime_type == CONTENT_TYPE_XML or mime_type == MIME_TYPES[CONTENT_TYPE_XML] then
return custom_error_templates.xml or ERROR_TEMPLATES[CONTENT_TYPE_XML]
elseif mime_type == CONTENT_TYPE_PLAIN or mime_type == MIME_TYPES[CONTENT_TYPE_PLAIN] then
return custom_error_templates.plain or ERROR_TEMPLATES[CONTENT_TYPE_PLAIN]
elseif mime_type == CONTENT_TYPE_GRPC or mime_type == MIME_TYPES[CONTENT_TYPE_GRPC] then
return ERROR_TEMPLATES[CONTENT_TYPE_GRPC]
end
return nil, "no template found for MIME type " .. (mime_type or "empty")
end
end
do
local replace_dashes = require("kong.tools.string").replace_dashes
function _M.get_header(name, ctx)
local headers
if ctx then
if not ctx.cached_request_headers then
ctx.cached_request_headers = ngx.req.get_headers()
end
headers = ctx.cached_request_headers
else
local value = ngx.var["http_" .. replace_dashes(name)]
if not value or not value:find(", ", 1, true) then
return value
end
headers = ngx.req.get_headers()
end
local value = headers[name]
return type(value) == "table" and value[1] or value
end
end
-- Parses a HTTP header value into a table of directives
-- eg: Cache-Control: public, max-age=3600
-- => { public = true, ["max-age"] = 3600 }
-- @param h (string) the header value to parse
-- @return table a table of directives
function _M.parse_directive_header(h)
if not h then
return EMPTY
end
if type(h) == "table" then
h = concat(h, ", ")
end
local t = {}
local res = tab_new(3, 0)
local iter = ngx_re_gmatch(h, "([^,]+)", "oj")
local m = iter()
while m do
local _, err = ngx_re_match(m[0], [[^\s*([^=]+)(?:=(.+))?]], "oj", nil, res)
if err then
kong.log.err(err)
end
-- store the directive token as a numeric value if it looks like a number;
-- otherwise, store the string value. for directives without token, we just
-- set the key to true
t[lower(res[1])] = tonumber(res[2]) or res[2] or true
m = iter()
end
return t
end
-- Calculates resource Time-To-Live (TTL) based on Cache-Control headers
-- @param res_cc (table) the Cache-Control headers, as parsed by `parse_directive_header`
-- @return number the TTL in seconds
function _M.calculate_resource_ttl(res_cc)
local max_age = res_cc and (res_cc["s-maxage"] or res_cc["max-age"])
if not max_age then
local expires = ngx.var.sent_http_expires
if type(expires) == "table" then
expires = expires[#expires]
end
local exp_time = parse_http_time(tostring(expires))
if exp_time then
max_age = exp_time - time()
end
end
return max_age and max(max_age, 0) or 0
end
return _M