diff --git a/lib/crowdsec.lua b/lib/crowdsec.lua
index 4d37c42..462851c 100644
--- a/lib/crowdsec.lua
+++ b/lib/crowdsec.lua
@@ -5,8 +5,12 @@ local iputils = require "plugins.crowdsec.iputils"
local http = require "resty.http"
local cjson = require "cjson"
local captcha = require "plugins.crowdsec.captcha"
+local flag = require "plugins.crowdsec.flag"
local utils = require "plugins.crowdsec.utils"
local ban = require "plugins.crowdsec.ban"
+local url = require "plugins.crowdsec.url"
+local bit
+if _VERSION == "Lua 5.1" then bit = require "bit" else bit = require "bit32" end
-- contain runtime = {}
local runtime = {}
@@ -21,6 +25,11 @@ runtime.timer_started = false
local csmod = {}
+local PASSTHROUGH = "passthrough"
+local DENY = "deny"
+
+local APPSEC_API_KEY_HEADER = "x-crowdsec-appsec-api-key"
+local REMEDIATION_API_KEY_HEADER = 'x-api-key'
-- init function
@@ -66,6 +75,30 @@ function csmod.init(configFile, userAgent)
table.insert(runtime.conf["EXCLUDE_LOCATION"], runtime.conf["REDIRECT_LOCATION"])
end
+ if runtime.conf["SSL_VERIFY"] == "false" then
+ runtime.conf["SSL_VERIFY"] = false
+ else
+ runtime.conf["SSL_VERIFY"] = true
+ end
+
+ if runtime.conf["ALWAYS_SEND_TO_APPSEC"] == "false" then
+ runtime.conf["ALWAYS_SEND_TO_APPSEC"] = false
+ else
+ runtime.conf["ALWAYS_SEND_TO_APPSEC"] = true
+ end
+
+ runtime.conf["APPSEC_ENABLED"] = false
+
+ if runtime.conf["APPSEC_URL"] ~= "" then
+ u = url.parse(runtime.conf["APPSEC_URL"])
+ runtime.conf["APPSEC_ENABLED"] = true
+ runtime.conf["APPSEC_HOST"] = u.host
+ if u.port ~= nil then
+ runtime.conf["APPSEC_HOST"] = runtime.conf["APPSEC_HOST"] .. ":" .. u.port
+ end
+ ngx.log(ngx.ERR, "APPSEC is enabled on '" .. runtime.conf["APPSEC_HOST"] .. "'")
+ end
+
-- if stream mode, add callback to stream_query and start timer
if runtime.conf["MODE"] == "stream" then
@@ -85,6 +118,8 @@ function csmod.init(configFile, userAgent)
end
end
+
+
return true, nil
end
@@ -94,16 +129,17 @@ function csmod.validateCaptcha(captcha_res, remote_ip)
end
-local function get_http_request(link)
+local function get_remediation_http_request(link)
local httpc = http.new()
httpc:set_timeout(runtime.conf['REQUEST_TIMEOUT'])
local res, err = httpc:request_uri(link, {
method = "GET",
headers = {
['Connection'] = 'close',
- ['X-Api-Key'] = runtime.conf["API_KEY"],
+ [REMEDIATION_API_KEY_HEADER] = runtime.conf["API_KEY"],
['User-Agent'] = runtime.userAgent
},
+ ssl_verify = runtime.conf["SSL_VERIFY"]
})
httpc:close()
return res, err
@@ -226,7 +262,7 @@ local function stream_query(premature)
local is_startup = runtime.cache:get("startup")
ngx.log(ngx.DEBUG, "Stream Query from worker : " .. tostring(ngx.worker.id()) .. " with startup "..tostring(is_startup) .. " | premature: " .. tostring(premature))
local link = runtime.conf["API_URL"] .. "/v1/decisions/stream?startup=" .. tostring(is_startup)
- local res, err = get_http_request(link)
+ local res, err = get_remediation_http_request(link)
if not res then
local ok, err = ngx.timer.at(runtime.conf["UPDATE_FREQUENCY"], stream_query)
if not ok then
@@ -321,7 +357,7 @@ end
local function live_query(ip)
local link = runtime.conf["API_URL"] .. "/v1/decisions?ip=" .. ip
- local res, err = get_http_request(link)
+ local res, err = get_remediation_http_request(link)
if not res then
return true, nil, "request failed: ".. err
end
@@ -365,6 +401,22 @@ local function live_query(ip)
end
end
+local function get_body()
+
+ ngx.req.read_body()
+ local body = ngx.req.get_body_data()
+ if body == nil then
+ local bodyfile = ngx.req.get_body_file()
+ if bodyfile then
+ local fh, err = io.open(bodyfile, "r")
+ if fh then
+ body = fh:read("*a")
+ fh:close()
+ end
+ end
+ end
+ return body
+end
function csmod.GetCaptchaTemplate()
return captcha.GetTemplate()
@@ -438,12 +490,83 @@ function csmod.allowIp(ip)
return true, nil, nil
end
+
+function csmod.AppSecCheck()
+ local httpc = http.new()
+ httpc:set_timeouts(runtime.conf["APPSEC_CONNECT_TIMEOUT"], runtime.conf["APPSEC_SEND_TIMEOUT"], runtime.conf["APPSEC_PROCESS_TIMEOUT"])
+
+ local uri = ngx.var.request_uri
+ local headers = ngx.req.get_headers()
+
+ -- overwrite headers with crowdsec appsec require headers
+ headers["x-crowdsec-appsec-ip"] = ngx.var.remote_addr
+ headers["x-crowdsec-appsec-host"] = ngx.var.http_host
+ headers["x-crowdsec-appsec-verb"] = ngx.var.request_method
+ headers["x-crowdsec-appsec-uri"] = uri
+ headers[APPSEC_API_KEY_HEADER] = runtime.conf["API_KEY"]
+
+ -- set CrowdSec APPSEC Host
+ headers["host"] = runtime.conf["APPSEC_HOST"]
+
+ local ok, remediation = true, "allow"
+ if runtime.conf["APPSEC_FAILURE_ACTION"] == DENY then
+ ok = false
+ remediation = runtime.conf["FALLBACK_REMEDIATION"]
+ end
+
+ local method = "GET"
+
+ local body = get_body()
+ if body ~= nil then
+ if #body > 0 then
+ method = "POST"
+ if headers["content-length"] == nil then
+ headers["content-length"] = tostring(#body)
+ end
+ end
+ end
+
+ local res, err = httpc:request_uri(runtime.conf["APPSEC_URL"], {
+ method = method,
+ headers = headers,
+ body = body,
+ ssl_verify = runtime.conf["SSL_VERIFY"],
+ })
+ httpc:close()
+
+ if err ~= nil then
+ ngx.log(ngx.ERR, "Fallback because of err: " .. err)
+ return ok, remediation, err
+ end
+
+ if res.status == 200 then
+ ok = true
+ remediation = "allow"
+ elseif res.status == 403 then
+ ok = false
+ local response = cjson.decode(res.body)
+ remediation = response.action
+ elseif res.status == 401 then
+ ngx.log(ngx.ERR, "Unauthenticated request to APPSEC")
+ else
+ ngx.log(ngx.ERR, "Bad request to APPSEC (" .. res.status .. "): " .. res.body)
+ end
+
+ return ok, remediation, err
+
+end
+
function csmod.Allow(ip)
-
if runtime.conf["ENABLED"] == "false" then
return "Disabled", nil
end
+ if ngx.req.is_internal() then
+ return
+ end
+
+ local remediationSource = flag.BOUNCER_SOURCE
+
if utils.table_len(runtime.conf["EXCLUDE_LOCATION"]) > 0 then
for k, v in pairs(runtime.conf["EXCLUDE_LOCATION"]) do
if ngx.var.uri == v then
@@ -470,6 +593,23 @@ function csmod.Allow(ip)
ngx.shared.crowdsec_cache:delete("captcha_" .. ip)
end
+ -- check with appSec if the remediation component doesn't have decisions for the IP
+ -- OR
+ -- that user configured the remediation component to always check on the appSec (even if there is a decision for the IP)
+ if ok == true or runtime.conf["ALWAYS_SEND_TO_APPSEC"] == true then
+ if runtime.conf["APPSEC_ENABLED"] == true and ngx.var.no_appsec ~= "1" then
+ local appsecOk, appsecRemediation, err = csmod.AppSecCheck()
+ if err ~= nil then
+ ngx.log(ngx.ERR, "AppSec check: " .. err)
+ end
+ if appsecOk == false then
+ ok = false
+ remediationSource = flag.APPSEC_SOURCE
+ remediation = appsecRemediation
+ end
+ end
+ end
+
local captcha_ok = runtime.cache:get("captcha_ok")
if runtime.fallback ~= "" then
@@ -486,8 +626,9 @@ function csmod.Allow(ip)
if captcha_ok then -- if captcha can be use (configuration is valid)
-- we check if the IP need to validate its captcha before checking it against crowdsec local API
- local previous_uri, state_id = ngx.shared.crowdsec_cache:get("captcha_"..ngx.var.remote_addr)
- if previous_uri ~= nil and state_id == captcha.GetStateID(captcha._VERIFY_STATE) then
+ local previous_uri, flags = ngx.shared.crowdsec_cache:get("captcha_"..ngx.var.remote_addr)
+ local source, state_id, err = flag.GetFlags(flags)
+ if previous_uri ~= nil and state_id == flag.VERIFY_STATE then
ngx.req.read_body()
local captcha_res = ngx.req.get_post_args()[csmod.GetCaptchaBackendKey()] or 0
if captcha_res ~= 0 then
@@ -496,15 +637,22 @@ function csmod.Allow(ip)
ngx.log(ngx.ERR, "Error while validating captcha: " .. err)
end
if valid == true then
+ -- if the captcha is valid and has been applied by the application security component
+ -- then we delete the state from the cache because from the bouncing part, if the user solve the captcha
+ -- we will not propose a captcha until the 'CAPTCHA_EXPIRATION'.
+ -- But for the Application security component, we serve the captcha each time the user trigger it.
+ if source == flag.APPSEC_SOURCE then
+ ngx.shared.crowdsec_cache:delete("captcha_"..ngx.var.remote_addr)
+ else
+ local succ, err, forcible = ngx.shared.crowdsec_cache:set("captcha_"..ngx.var.remote_addr, previous_uri, runtime.conf["CAPTCHA_EXPIRATION"], bit.bor(flag.VALIDATED_STATE, source) )
+ if not succ then
+ ngx.log(ngx.ERR, "failed to add key about captcha for ip '" .. ngx.var.remote_addr .. "' in cache: "..err)
+ end
+ if forcible then
+ ngx.log(ngx.ERR, "Lua shared dict (crowdsec cache) is full, please increase dict size in config")
+ end
+ end
-- captcha is valid, we redirect the IP to its previous URI but in GET method
- local succ, err, forcible = ngx.shared.crowdsec_cache:set("captcha_"..ngx.var.remote_addr, previous_uri, runtime.conf["CAPTCHA_EXPIRATION"], captcha.GetStateID(captcha._VALIDATED_STATE))
- if not succ then
- ngx.log(ngx.ERR, "failed to add key about captcha for ip '" .. ngx.var.remote_addr .. "' in cache: "..err)
- end
- if forcible then
- ngx.log(ngx.ERR, "Lua shared dict (crowdsec cache) is full, please increase dict size in config")
- end
-
ngx.req.set_method(ngx.HTTP_GET)
ngx.redirect(previous_uri)
return
@@ -514,18 +662,18 @@ function csmod.Allow(ip)
end
end
end
-
if not ok then
if remediation == "ban" then
- ngx.log(ngx.ALERT, "[Crowdsec] denied '" .. ngx.var.remote_addr .. "' with '"..remediation.."'")
+ ngx.log(ngx.ALERT, "[Crowdsec] denied '" .. ngx.var.remote_addr .. "' with '"..remediation.."' (by " .. flag.Flags[remediationSource] .. ")")
ban.apply()
return
end
-- if the remediation is a captcha and captcha is well configured
if remediation == "captcha" and captcha_ok and ngx.var.uri ~= "/favicon.ico" then
- local previous_uri, state_id = ngx.shared.crowdsec_cache:get("captcha_"..ngx.var.remote_addr)
+ local previous_uri, flags = ngx.shared.crowdsec_cache:get("captcha_"..ngx.var.remote_addr)
+ source, state_id, err = flag.GetFlags(flags)
-- we check if the IP is already in cache for captcha and not yet validated
- if previous_uri == nil or state_id ~= captcha.GetStateID(captcha._VALIDATED_STATE) then
+ if previous_uri == nil or remediationSource == flag.APPSEC_SOURCE then
ngx.header.content_type = "text/html"
ngx.header.cache_control = "no-cache"
ngx.say(csmod.GetCaptchaTemplate())
@@ -539,7 +687,7 @@ function csmod.Allow(ip)
end
end
end
- local succ, err, forcible = ngx.shared.crowdsec_cache:set("captcha_"..ngx.var.remote_addr, uri , 60, captcha.GetStateID(captcha._VERIFY_STATE))
+ local succ, err, forcible = ngx.shared.crowdsec_cache:set("captcha_"..ngx.var.remote_addr, uri , 60, bit.bor(flag.VERIFY_STATE, remediationSource))
if not succ then
ngx.log(ngx.ERR, "failed to add key about captcha for ip '" .. ngx.var.remote_addr .. "' in cache: "..err)
end
diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua
index 8968b07..36c867b 100644
--- a/lib/plugins/crowdsec/captcha.lua
+++ b/lib/plugins/crowdsec/captcha.lua
@@ -3,7 +3,6 @@ local cjson = require "cjson"
local template = require "plugins.crowdsec.template"
local utils = require "plugins.crowdsec.utils"
-
local M = {_TYPE='module', _NAME='recaptcha.funcs', _VERSION='1.0-0'}
local captcha_backend_url = {}
@@ -21,30 +20,10 @@ captcha_frontend_key["recaptcha"] = "g-recaptcha"
captcha_frontend_key["hcaptcha"] = "h-captcha"
captcha_frontend_key["turnstile"] = "cf-turnstile"
-M._VERIFY_STATE = "to_verify"
-M._VALIDATED_STATE = "validated"
-
-
-M.State = {}
-M.State["1"] = M._VERIFY_STATE
-M.State["2"] = M._VALIDATED_STATE
-
M.SecretKey = ""
M.SiteKey = ""
M.Template = ""
-
-function M.GetStateID(state)
- for k, v in pairs(M.State) do
- if v == state then
- return tonumber(k)
- end
- end
- return nil
-end
-
-
-
function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider)
if siteKey == nil or siteKey == "" then
diff --git a/lib/plugins/crowdsec/config.lua b/lib/plugins/crowdsec/config.lua
index 256c8b4..89d8e63 100644
--- a/lib/plugins/crowdsec/config.lua
+++ b/lib/plugins/crowdsec/config.lua
@@ -39,8 +39,8 @@ function config.loadConfig(file)
return nil, "File ".. file .." doesn't exist"
end
local conf = {}
- local valid_params = {'ENABLED','API_URL', 'API_KEY', 'BOUNCING_ON_TYPE', 'MODE', 'SECRET_KEY', 'SITE_KEY', 'BAN_TEMPLATE_PATH' ,'CAPTCHA_TEMPLATE_PATH', 'REDIRECT_LOCATION', 'RET_CODE', 'EXCLUDE_LOCATION', 'FALLBACK_REMEDIATION', 'CAPTCHA_PROVIDER'}
- local valid_int_params = {'CACHE_EXPIRATION', 'CACHE_SIZE', 'REQUEST_TIMEOUT', 'UPDATE_FREQUENCY', 'CAPTCHA_EXPIRATION'}
+ local valid_params = {'ENABLED','API_URL', 'API_KEY', 'BOUNCING_ON_TYPE', 'MODE', 'SECRET_KEY', 'SITE_KEY', 'BAN_TEMPLATE_PATH' ,'CAPTCHA_TEMPLATE_PATH', 'REDIRECT_LOCATION', 'RET_CODE', 'EXCLUDE_LOCATION', 'FALLBACK_REMEDIATION', 'CAPTCHA_PROVIDER', 'APPSEC_URL', 'APPSEC_FAILURE_ACTION', 'ALWAYS_SEND_TO_APPSEC', 'SSL_VERIFY'}
+ local valid_int_params = {'CACHE_EXPIRATION', 'CACHE_SIZE', 'REQUEST_TIMEOUT', 'UPDATE_FREQUENCY', 'CAPTCHA_EXPIRATION', 'APPSEC_CONNECT_TIMEOUT', 'APPSEC_SEND_TIMEOUT', 'APPSEC_PROCESS_TIMEOUT'}
local valid_bouncing_on_type_values = {'ban', 'captcha', 'all'}
local valid_truefalse_values = {'false', 'true'}
local default_values = {
@@ -53,7 +53,15 @@ function config.loadConfig(file)
['REDIRECT_LOCATION'] = "",
['EXCLUDE_LOCATION'] = {},
['RET_CODE'] = 0,
- ['CAPTCHA_PROVIDER'] = "recaptcha"
+ ['CAPTCHA_PROVIDER'] = "recaptcha",
+ ['APPSEC_URL'] = "",
+ ['APPSEC_CONNECT_TIMEOUT'] = 100,
+ ['APPSEC_SEND_TIMEOUT'] = 100,
+ ['APPSEC_PROCESS_TIMEOUT'] = 500,
+ ['APPSEC_FAILURE_ACTION'] = "passthrough",
+ ['SSL_VERIFY'] = "true",
+ ['ALWAYS_SEND_TO_APPSEC'] = "false",
+
}
for line in io.lines(file) do
local isOk = false
@@ -64,51 +72,57 @@ function config.loadConfig(file)
isOk = true
end
if not isOk then
- local sep_pos = line:find("=")
- if not sep_pos then
- ngx.log(ngx.ERR, "invalid configuration line: " .. line)
- break
- end
+ local sep_pos = line:find("=")
+ if not sep_pos then
+ ngx.log(ngx.ERR, "invalid configuration line: " .. line)
+ break
+ end
local key = trim(line:sub(1, sep_pos - 1))
- local value = trim(line:sub(sep_pos + 1))
- if has_value(valid_params, key) then
- if key == "ENABLED" then
- if not has_value(valid_truefalse_values, value) then
- ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'true' instead")
- conf[key] = "true"
- end
- elseif key == "BOUNCING_ON_TYPE" then
- if not has_value(valid_bouncing_on_type_values, value) then
- ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'ban' instead")
- conf[key] = "ban"
- end
- elseif key == "MODE" then
- if not has_value({'stream', 'live'}, value) then
- ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'stream' instead")
- conf[key] = "stream"
- end
- elseif key == "EXCLUDE_LOCATION" then
- exclude_location = {}
- if value ~= "" then
- for match in (value..","):gmatch("(.-)"..",") do
- table.insert(exclude_location, match)
- end
- end
- conf[key] = exclude_location
- elseif key == "FALLBACK_REMEDIATION" then
- if not has_value({'captcha', 'ban'}, value) then
- ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'ban' instead")
- conf[key] = "ban"
- end
- else
- conf[key] = value
- end
- elseif has_value(valid_int_params, key) then
- conf[key] = tonumber(value)
- else
- ngx.log(ngx.ERR, "unsupported configuration '" .. key .. "'")
- end
- end
+ local value = trim(line:sub(sep_pos + 1))
+ if has_value(valid_params, key) then
+ if key == "ENABLED" then
+ if not has_value(valid_truefalse_values, value) then
+ ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'true' instead")
+ value = "true"
+ end
+ elseif key == "BOUNCING_ON_TYPE" then
+ if not has_value(valid_bouncing_on_type_values, value) then
+ ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'ban' instead")
+ value = "ban"
+ end
+ elseif key == "SSL_VERIFY" then
+ if not has_value(valid_truefalse_values, value) then
+ ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'true' instead")
+ value = "true"
+ end
+ elseif key == "MODE" then
+ if not has_value({'stream', 'live'}, value) then
+ ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'stream' instead")
+ value = "stream"
+ end
+ elseif key == "EXCLUDE_LOCATION" then
+ exclude_location = {}
+ if value ~= "" then
+ for match in (value..","):gmatch("(.-)"..",") do
+ table.insert(exclude_location, match)
+ end
+ end
+ value = exclude_location
+ elseif key == "FALLBACK_REMEDIATION" then
+ if not has_value({'captcha', 'ban'}, value) then
+ ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'ban' instead")
+ value = "ban"
+ end
+ end
+
+ conf[key] = value
+
+ elseif has_value(valid_int_params, key) then
+ conf[key] = tonumber(value)
+ else
+ ngx.log(ngx.ERR, "unsupported configuration '" .. key .. "'")
+ end
+ end
end
for k, v in pairs(default_values) do
if conf[k] == nil then
diff --git a/lib/plugins/crowdsec/flag.lua b/lib/plugins/crowdsec/flag.lua
new file mode 100644
index 0000000..e271084
--- /dev/null
+++ b/lib/plugins/crowdsec/flag.lua
@@ -0,0 +1,52 @@
+local bit
+if _VERSION == "Lua 5.1" then bit = require "bit" else bit = require "bit32" end
+
+local M = {_TYPE='module', _NAME='flag.funcs', _VERSION='1.0-0'}
+
+M.BOUNCER_SOURCE = 0x1
+M.APPSEC_SOURCE = 0x2
+M.VERIFY_STATE = 0x4
+M.VALIDATED_STATE = 0x8
+
+M.Flags = {}
+M.Flags[0x0] = ""
+M.Flags[0x1] = "bouncer"
+M.Flags[0x2] = "appsec"
+M.Flags[0x4] = "to_verify"
+M.Flags[0x8] = "validated"
+
+
+function M.GetFlags(flags)
+ local source = 0x0
+ local err = ""
+ local state = 0x0
+
+ if flags == nil then
+ return source, state, err
+ end
+
+ if bit.band(flags, M.BOUNCER_SOURCE) then
+ source = M.BOUNCER_SOURCE
+ elseif bit.band(flags, M.APPSEC_SOURCE) then
+ source = M.APPSEC_SOURCE
+ end
+
+ if bit.band(flags, M.VERIFY_STATE) then
+ state = M.VERIFY_STATE
+ elseif bit.band(flags, M.VALIDATED_STATE) then
+ state = M.VALIDATED_STATE
+ end
+ return source, state, err
+
+end
+
+function M.GetStateID(state)
+ for k, v in pairs(M.State) do
+ if v == state then
+ return tonumber(k)
+ end
+ end
+ return nil
+end
+
+return M
\ No newline at end of file
diff --git a/lib/plugins/crowdsec/url.lua b/lib/plugins/crowdsec/url.lua
new file mode 100644
index 0000000..ef66209
--- /dev/null
+++ b/lib/plugins/crowdsec/url.lua
@@ -0,0 +1,523 @@
+-- net/url.lua - a robust url parser and builder
+--
+-- Bertrand Mansion, 2011-2021; License MIT
+-- @module net.url
+-- @alias M
+
+local M = {}
+M.version = "1.1.0"
+
+--- url options
+-- - `separator` is set to `&` by default but could be anything like `&` or `;`
+-- - `cumulative_parameters` is false by default. If true, query parameters with the same name will be stored in a table.
+-- - `legal_in_path` is a table of characters that will not be url encoded in path components
+-- - `legal_in_query` is a table of characters that will not be url encoded in query values. Query parameters only support a small set of legal characters (-_.).
+-- - `query_plus_is_space` is true by default, so a plus sign in a query value will be converted to %20 (space), not %2B (plus)
+-- @todo Add option to limit the size of the argument table
+-- @todo Add option to limit the depth of the argument table
+-- @todo Add option to process dots in parameter names, ie. `param.filter=1`
+M.options = {
+ separator = '&',
+ cumulative_parameters = false,
+ legal_in_path = {
+ [":"] = true, ["-"] = true, ["_"] = true, ["."] = true,
+ ["!"] = true, ["~"] = true, ["*"] = true, ["'"] = true,
+ ["("] = true, [")"] = true, ["@"] = true, ["&"] = true,
+ ["="] = true, ["$"] = true, [","] = true,
+ [";"] = true
+ },
+ legal_in_query = {
+ [":"] = true, ["-"] = true, ["_"] = true, ["."] = true,
+ [","] = true, ["!"] = true, ["~"] = true, ["*"] = true,
+ ["'"] = true, [";"] = true, ["("] = true, [")"] = true,
+ ["@"] = true, ["$"] = true,
+ },
+ query_plus_is_space = true
+}
+
+--- list of known and common scheme ports
+-- as documented in IANA URI scheme list
+M.services = {
+ acap = 674,
+ cap = 1026,
+ dict = 2628,
+ ftp = 21,
+ gopher = 70,
+ http = 80,
+ https = 443,
+ iax = 4569,
+ icap = 1344,
+ imap = 143,
+ ipp = 631,
+ ldap = 389,
+ mtqp = 1038,
+ mupdate = 3905,
+ news = 2009,
+ nfs = 2049,
+ nntp = 119,
+ rtsp = 554,
+ sip = 5060,
+ snmp = 161,
+ telnet = 23,
+ tftp = 69,
+ vemmi = 575,
+ afs = 1483,
+ jms = 5673,
+ rsync = 873,
+ prospero = 191,
+ videotex = 516
+}
+
+local function decode(str)
+ return (str:gsub("%%(%x%x)", function(c)
+ return string.char(tonumber(c, 16))
+ end))
+end
+
+local function encode(str, legal)
+ return (str:gsub("([^%w])", function(v)
+ if legal[v] then
+ return v
+ end
+ return string.upper(string.format("%%%02x", string.byte(v)))
+ end))
+end
+
+-- for query values, + can mean space if configured as such
+local function decodeValue(str)
+ if M.options.query_plus_is_space then
+ str = str:gsub('+', ' ')
+ end
+ return decode(str)
+end
+
+local function concat(a, b)
+ if type(a) == 'table' then
+ return a:build() .. b
+ else
+ return a .. b:build()
+ end
+end
+
+function M:addSegment(path)
+ if type(path) == 'string' then
+ self.path = self.path .. '/' .. encode(path:gsub("^/+", ""), M.options.legal_in_path)
+ end
+ return self
+end
+
+--- builds the url
+-- @return a string representing the built url
+function M:build()
+ local url = ''
+ if self.path then
+ local path = self.path
+ url = url .. tostring(path)
+ end
+ if self.query then
+ local qstring = tostring(self.query)
+ if qstring ~= "" then
+ url = url .. '?' .. qstring
+ end
+ end
+ if self.host then
+ local authority = self.host
+ if self.port and self.scheme and M.services[self.scheme] ~= self.port then
+ authority = authority .. ':' .. self.port
+ end
+ local userinfo
+ if self.user and self.user ~= "" then
+ userinfo = self.user
+ if self.password then
+ userinfo = userinfo .. ':' .. self.password
+ end
+ end
+ if userinfo and userinfo ~= "" then
+ authority = userinfo .. '@' .. authority
+ end
+ if authority then
+ if url ~= "" then
+ url = '//' .. authority .. '/' .. url:gsub('^/+', '')
+ else
+ url = '//' .. authority
+ end
+ end
+ end
+ if self.scheme then
+ url = self.scheme .. ':' .. url
+ end
+ if self.fragment then
+ url = url .. '#' .. self.fragment
+ end
+ return url
+end
+
+--- builds the querystring
+-- @param tab The key/value parameters
+-- @param sep The separator to use (optional)
+-- @param key The parent key if the value is multi-dimensional (optional)
+-- @return a string representing the built querystring
+function M.buildQuery(tab, sep, key)
+ local query = {}
+ if not sep then
+ sep = M.options.separator or '&'
+ end
+ local keys = {}
+ for k in pairs(tab) do
+ keys[#keys+1] = k
+ end
+ table.sort(keys, function (a, b)
+ local function padnum(n, rest) return ("%03d"..rest):format(tonumber(n)) end
+ return tostring(a):gsub("(%d+)(%.)",padnum) < tostring(b):gsub("(%d+)(%.)",padnum)
+ end)
+ for _,name in ipairs(keys) do
+ local value = tab[name]
+ name = encode(tostring(name), {["-"] = true, ["_"] = true, ["."] = true})
+ if key then
+ if M.options.cumulative_parameters and string.find(name, '^%d+$') then
+ name = tostring(key)
+ else
+ name = string.format('%s[%s]', tostring(key), tostring(name))
+ end
+ end
+ if type(value) == 'table' then
+ query[#query+1] = M.buildQuery(value, sep, name)
+ else
+ local value = encode(tostring(value), M.options.legal_in_query)
+ if value ~= "" then
+ query[#query+1] = string.format('%s=%s', name, value)
+ else
+ query[#query+1] = name
+ end
+ end
+ end
+ return table.concat(query, sep)
+end
+
+--- Parses the querystring to a table
+-- This function can parse multidimensional pairs and is mostly compatible
+-- with PHP usage of brackets in key names like ?param[key]=value
+-- @param str The querystring to parse
+-- @param sep The separator between key/value pairs, defaults to `&`
+-- @todo limit the max number of parameters with M.options.max_parameters
+-- @return a table representing the query key/value pairs
+function M.parseQuery(str, sep)
+ if not sep then
+ sep = M.options.separator or '&'
+ end
+
+ local values = {}
+ for key,val in str:gmatch(string.format('([^%q=]+)(=*[^%q=]*)', sep, sep)) do
+ local key = decodeValue(key)
+ local keys = {}
+ key = key:gsub('%[([^%]]*)%]', function(v)
+ -- extract keys between balanced brackets
+ if string.find(v, "^-?%d+$") then
+ v = tonumber(v)
+ else
+ v = decodeValue(v)
+ end
+ table.insert(keys, v)
+ return "="
+ end)
+ key = key:gsub('=+.*$', "")
+ key = key:gsub('%s', "_") -- remove spaces in parameter name
+ val = val:gsub('^=+', "")
+
+ if not values[key] then
+ values[key] = {}
+ end
+ if #keys > 0 and type(values[key]) ~= 'table' then
+ values[key] = {}
+ elseif #keys == 0 and type(values[key]) == 'table' then
+ values[key] = decodeValue(val)
+ elseif M.options.cumulative_parameters
+ and type(values[key]) == 'string' then
+ values[key] = { values[key] }
+ table.insert(values[key], decodeValue(val))
+ end
+
+ local t = values[key]
+ for i,k in ipairs(keys) do
+ if type(t) ~= 'table' then
+ t = {}
+ end
+ if k == "" then
+ k = #t+1
+ end
+ if not t[k] then
+ t[k] = {}
+ end
+ if i == #keys then
+ t[k] = val
+ end
+ t = t[k]
+ end
+
+ end
+ setmetatable(values, { __tostring = M.buildQuery })
+ return values
+end
+
+--- set the url query
+-- @param query Can be a string to parse or a table of key/value pairs
+-- @return a table representing the query key/value pairs
+function M:setQuery(query)
+ local query = query
+ if type(query) == 'table' then
+ query = M.buildQuery(query)
+ end
+ self.query = M.parseQuery(query)
+ return query
+end
+
+--- set the authority part of the url
+-- The authority is parsed to find the user, password, port and host if available.
+-- @param authority The string representing the authority
+-- @return a string with what remains after the authority was parsed
+function M:setAuthority(authority)
+ self.authority = authority
+ self.port = nil
+ self.host = nil
+ self.userinfo = nil
+ self.user = nil
+ self.password = nil
+
+ authority = authority:gsub('^([^@]*)@', function(v)
+ self.userinfo = v
+ return ''
+ end)
+
+ authority = authority:gsub(':(%d+)$', function(v)
+ self.port = tonumber(v)
+ return ''
+ end)
+
+ local function getIP(str)
+ -- ipv4
+ local chunks = { str:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") }
+ if #chunks == 4 then
+ for _, v in pairs(chunks) do
+ if tonumber(v) > 255 then
+ return false
+ end
+ end
+ return str
+ end
+ -- ipv6
+ local chunks = { str:match("^%["..(("([a-fA-F0-9]*):"):rep(8):gsub(":$","%%]$"))) }
+ if #chunks == 8 or #chunks < 8 and
+ str:match('::') and not str:gsub("::", "", 1):match('::') then
+ for _,v in pairs(chunks) do
+ if #v > 0 and tonumber(v, 16) > 65535 then
+ return false
+ end
+ end
+ return str
+ end
+ return nil
+ end
+
+ local ip = getIP(authority)
+ if ip then
+ self.host = ip
+ elseif type(ip) == 'nil' then
+ -- domain
+ if authority ~= '' and not self.host then
+ local host = authority:lower()
+ if string.match(host, '^[%d%a%-%.]+$') ~= nil and
+ string.sub(host, 0, 1) ~= '.' and
+ string.sub(host, -1) ~= '.' and
+ string.find(host, '%.%.') == nil then
+ self.host = host
+ end
+ end
+ end
+
+ if self.userinfo then
+ local userinfo = self.userinfo
+ userinfo = userinfo:gsub(':([^:]*)$', function(v)
+ self.password = v
+ return ''
+ end)
+ if string.find(userinfo, "^[%w%+%.]+$") then
+ self.user = userinfo
+ else
+ -- incorrect userinfo
+ self.userinfo = nil
+ self.user = nil
+ self.password = nil
+ end
+ end
+
+ return authority
+end
+
+--- Parse the url into the designated parts.
+-- Depending on the url, the following parts can be available:
+-- scheme, userinfo, user, password, authority, host, port, path,
+-- query, fragment
+-- @param url Url string
+-- @return a table with the different parts and a few other functions
+function M.parse(url)
+ local comp = {}
+ M.setAuthority(comp, "")
+ M.setQuery(comp, "")
+
+ local url = tostring(url or '')
+ url = url:gsub('#(.*)$', function(v)
+ comp.fragment = v
+ return ''
+ end)
+ url =url:gsub('^([%w][%w%+%-%.]*)%:', function(v)
+ comp.scheme = v:lower()
+ return ''
+ end)
+ url = url:gsub('%?(.*)', function(v)
+ M.setQuery(comp, v)
+ return ''
+ end)
+ url = url:gsub('^//([^/]*)', function(v)
+ M.setAuthority(comp, v)
+ return ''
+ end)
+
+ comp.path = url:gsub("([^/]+)", function (s) return encode(decode(s), M.options.legal_in_path) end)
+
+ setmetatable(comp, {
+ __index = M,
+ __tostring = M.build,
+ __concat = concat,
+ __div = M.addSegment
+ })
+ return comp
+end
+
+--- removes dots and slashes in urls when possible
+-- This function will also remove multiple slashes
+-- @param path The string representing the path to clean
+-- @return a string of the path without unnecessary dots and segments
+function M.removeDotSegments(path)
+ local fields = {}
+ if string.len(path) == 0 then
+ return ""
+ end
+ local startslash = false
+ local endslash = false
+ if string.sub(path, 1, 1) == "/" then
+ startslash = true
+ end
+ if (string.len(path) > 1 or startslash == false) and string.sub(path, -1) == "/" then
+ endslash = true
+ end
+
+ path:gsub('[^/]+', function(c) table.insert(fields, c) end)
+
+ local new = {}
+ local j = 0
+
+ for i,c in ipairs(fields) do
+ if c == '..' then
+ if j > 0 then
+ j = j - 1
+ end
+ elseif c ~= "." then
+ j = j + 1
+ new[j] = c
+ end
+ end
+ local ret = ""
+ if #new > 0 and j > 0 then
+ ret = table.concat(new, '/', 1, j)
+ else
+ ret = ""
+ end
+ if startslash then
+ ret = '/'..ret
+ end
+ if endslash then
+ ret = ret..'/'
+ end
+ return ret
+end
+
+local function reducePath(base_path, relative_path)
+ if string.sub(relative_path, 1, 1) == "/" then
+ return '/' .. string.gsub(relative_path, '^[%./]+', '')
+ end
+ local path = base_path
+ local startslash = string.sub(path, 1, 1) ~= "/";
+ if relative_path ~= "" then
+ path = (startslash and '' or '/') .. path:gsub("[^/]*$", "")
+ end
+ path = path .. relative_path
+ path = path:gsub("([^/]*%./)", function (s)
+ if s ~= "./" then return s else return "" end
+ end)
+ path = string.gsub(path, "/%.$", "/")
+ local reduced
+ while reduced ~= path do
+ reduced = path
+ path = string.gsub(reduced, "([^/]*/%.%./)", function (s)
+ if s ~= "../../" then return "" else return s end
+ end)
+ end
+ path = string.gsub(path, "([^/]*/%.%.?)$", function (s)
+ if s ~= "../.." then return "" else return s end
+ end)
+ local reduced
+ while reduced ~= path do
+ reduced = path
+ path = string.gsub(reduced, '^/?%.%./', '')
+ end
+ return (startslash and '' or '/') .. path
+end
+
+--- builds a new url by using the one given as parameter and resolving paths
+-- @param other A string or a table representing a url
+-- @return a new url table
+function M:resolve(other)
+ if type(self) == "string" then
+ self = M.parse(self)
+ end
+ if type(other) == "string" then
+ other = M.parse(other)
+ end
+ if other.scheme then
+ return other
+ else
+ other.scheme = self.scheme
+ if not other.authority or other.authority == "" then
+ other:setAuthority(self.authority)
+ if not other.path or other.path == "" then
+ other.path = self.path
+ local query = other.query
+ if not query or not next(query) then
+ other.query = self.query
+ end
+ else
+ other.path = reducePath(self.path, other.path)
+ end
+ end
+ return other
+ end
+end
+
+--- normalize a url path following some common normalization rules
+-- described on The URL normalization page of Wikipedia
+-- @return the normalized path
+function M:normalize()
+ if type(self) == 'string' then
+ self = M.parse(self)
+ end
+ if self.path then
+ local path = self.path
+ path = reducePath(path, "")
+ -- normalize multiple slashes
+ path = string.gsub(path, "//+", "/")
+ self.path = path
+ end
+ return self
+end
+
+return M
\ No newline at end of file
diff --git a/lib/plugins/crowdsec/utils.lua b/lib/plugins/crowdsec/utils.lua
index 9d73237..a0ee333 100644
--- a/lib/plugins/crowdsec/utils.lua
+++ b/lib/plugins/crowdsec/utils.lua
@@ -13,6 +13,7 @@ M.HTTP_CODE["401"] = ngx.HTTP_UNAUTHORIZED
M.HTTP_CODE["403"] = ngx.HTTP_FORBIDDEN
M.HTTP_CODE["404"] = ngx.HTTP_NOT_FOUND
M.HTTP_CODE["405"] = ngx.HTTP_NOT_ALLOWED
+M.HTTP_CODE["406"] = ngx.HTTP_NOT_ACCEPTABLE
M.HTTP_CODE["500"] = ngx.HTTP_INTERNAL_SERVER_ERROR
function M.read_file(path)