lua-cs-bouncer-mcaptcha/nginx/crowdsec.lua

469 lines
16 KiB
Lua
Raw Normal View History

2022-01-05 15:10:54 +01:00
package.path = package.path .. ";./?.lua"
local config = require "plugins.crowdsec.config"
2022-01-13 09:48:05 +01:00
local iputils = require "plugins.crowdsec.iputils"
2022-01-05 15:10:54 +01:00
local http = require "resty.http"
local cjson = require "cjson"
2022-01-26 12:23:49 +01:00
local recaptcha = require "plugins.crowdsec.recaptcha"
2022-01-26 18:41:57 +01:00
local utils = require "plugins.crowdsec.utils"
2022-01-30 17:07:51 +01:00
local ban = require "plugins.crowdsec.ban"
2022-01-05 15:10:54 +01:00
-- contain runtime = {}
local runtime = {}
2022-01-24 11:08:41 +01:00
-- remediations are stored in cache as int (shared dict tags)
-- we need to translate IDs to text with this.
2022-01-13 09:48:05 +01:00
runtime.remediations = {}
runtime.remediations["1"] = "ban"
runtime.remediations["2"] = "captcha"
2022-01-05 15:10:54 +01:00
local csmod = {}
2022-01-25 11:40:05 +01:00
2022-01-05 15:10:54 +01:00
-- init function
function csmod.init(configFile, userAgent)
local conf, err = config.loadConfig(configFile)
if conf == nil then
return nil, err
end
runtime.conf = conf
runtime.userAgent = userAgent
2022-01-05 18:33:41 +01:00
runtime.cache = ngx.shared.crowdsec_cache
2022-01-26 18:41:57 +01:00
captcha_ok = true
2022-01-26 18:15:09 +01:00
2022-01-26 12:41:45 +01:00
err = recaptcha.New(runtime.conf["SITE_KEY"], runtime.conf["SECRET_KEY"], runtime.conf["CAPTCHA_TEMPLATE_PATH"])
2022-01-30 17:07:51 +01:00
err = ban.new(runtime.conf["BAN_TEMPLATE_PATH"], runtime.conf["REDIRECT_LOCATION"], runtime.conf["RET_CODE"])
2022-01-25 11:40:05 +01:00
2022-01-28 17:08:08 +01:00
if runtime.conf["REDIRECT_LOCATION"] ~= "" then
table.insert(runtime.conf["EXCLUDE_LOCATION"], runtime.conf["REDIRECT_LOCATION"])
end
2022-01-26 18:15:09 +01:00
if err ~= nil then
ngx.log(ngx.ERR, "using reCaptcha is not possible: " .. err)
2022-01-26 18:41:57 +01:00
captcha_ok = false
2022-01-26 18:15:09 +01:00
end
2022-01-28 17:29:13 +01:00
local succ, err, forcible = runtime.cache:set("captcha_ok", captcha_ok)
if not succ then
ngx.log(ngx.ERR, "failed to add captcha state key 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
2022-01-05 15:10:54 +01:00
-- if stream mode, add callback to stream_query and start timer
if runtime.conf["MODE"] == "stream" then
2022-01-28 17:29:13 +01:00
local succ, err, forcible = runtime.cache:set("startup", true)
if not succ then
ngx.log(ngx.ERR, "failed to add startup key 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
local succ, err, forcible = runtime.cache:set("first_run", true)
if not succ then
ngx.log(ngx.ERR, "failed to add first_run key 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
2022-01-05 15:10:54 +01:00
end
return true, nil
end
2022-01-25 17:22:01 +01:00
function csmod.validateCaptcha(g_captcha_res, remote_ip)
2022-01-26 16:58:55 +01:00
return recaptcha.Validate(g_captcha_res, remote_ip)
2022-01-25 17:22:01 +01:00
end
function get_http_request(link)
2022-01-05 15:10:54 +01:00
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"],
['User-Agent'] = runtime.userAgent
},
})
return res, err
end
function parse_duration(duration)
2022-01-13 09:48:05 +01:00
local match, err = ngx.re.match(duration, "^((?<hours>[0-9]+)h)?((?<minutes>[0-9]+)m)?(?<seconds>[0-9]+)")
2022-01-05 15:10:54 +01:00
local ttl = 0
if not match then
if err then
return ttl, err
end
end
2022-01-05 18:27:17 +01:00
if match["hours"] ~= nil and match["hours"] ~= false then
2022-01-05 15:10:54 +01:00
local hours = tonumber(match["hours"])
ttl = ttl + (hours * 3600)
end
2022-01-05 18:27:17 +01:00
if match["minutes"] ~= nil and match["minutes"] ~= false then
2022-01-05 15:10:54 +01:00
local minutes = tonumber(match["minutes"])
ttl = ttl + (minutes * 60)
end
2022-01-05 18:27:17 +01:00
if match["seconds"] ~= nil and match["seconds"] ~= false then
2022-01-05 15:10:54 +01:00
local seconds = tonumber(match["seconds"])
ttl = ttl + seconds
end
return ttl, nil
end
2022-01-13 09:48:05 +01:00
function get_remediation_id(remediation)
for key, value in pairs(runtime.remediations) do
if value == remediation then
return tonumber(key)
end
end
return nil
end
2022-01-17 18:17:43 +01:00
function item_to_string(item, scope)
local ip, cidr, ip_version
2022-01-17 18:17:43 +01:00
if scope:lower() == "ip" then
ip = item
end
if scope:lower() == "range" then
ip, cidr = iputils.splitRange(item, scope)
2022-01-13 09:48:05 +01:00
end
2022-01-21 21:31:23 +01:00
local ip_network_address, is_ipv4 = iputils.parseIPAddress(ip)
if is_ipv4 then
2022-01-13 09:48:05 +01:00
ip_version = "ipv4"
2022-01-17 18:17:43 +01:00
if cidr == nil then
cidr = 32
end
else
2022-01-13 09:48:05 +01:00
ip_version = "ipv6"
2022-01-21 21:31:23 +01:00
ip_network_address = ip_network_address.uint32[3]..":"..ip_network_address.uint32[2]..":"..ip_network_address.uint32[1]..":"..ip_network_address.uint32[0]
2022-01-17 18:17:43 +01:00
if cidr == nil then
cidr = 128
end
2022-01-13 09:48:05 +01:00
end
2022-01-17 18:17:43 +01:00
if ip_version == nil then
return "normal_"..item
2022-01-13 09:48:05 +01:00
end
2022-01-17 18:17:43 +01:00
local ip_netmask = iputils.cidrToInt(cidr, ip_version)
2022-01-13 09:48:05 +01:00
return ip_version.."_"..ip_netmask.."_"..ip_network_address
end
2022-01-05 15:10:54 +01:00
function stream_query()
-- As this function is running inside coroutine (with ngx.timer.every),
-- we need to raise error instead of returning them
2022-01-13 09:48:05 +01:00
local is_startup = runtime.cache:get("startup")
ngx.log(ngx.DEBUG, "Stream Query from worker : " .. tostring(ngx.worker.id()) .. " with startup "..tostring(is_startup))
local link = runtime.conf["API_URL"] .. "/v1/decisions/stream?startup=" .. tostring(is_startup)
2022-01-25 17:22:01 +01:00
local res, err = get_http_request(link)
2022-01-05 15:10:54 +01:00
if not res then
2022-01-07 18:07:11 +01:00
if ngx.timer.every == nil then
local ok, err = ngx.timer.at(runtime.conf["UPDATE_FREQUENCY"], stream_query)
if not ok then
error("Failed to create the timer: " .. (err or "unknown"))
end
end
2022-01-05 15:10:54 +01:00
error("request failed: ".. err)
end
local status = res.status
local body = res.body
if status~=200 then
2022-01-07 18:07:11 +01:00
if ngx.timer.every == nil then
local ok, err = ngx.timer.at(runtime.conf["UPDATE_FREQUENCY"], stream_query)
if not ok then
error("Failed to create the timer: " .. (err or "unknown"))
end
end
2022-01-05 15:10:54 +01:00
error("Http error " .. status .. " with message (" .. tostring(body) .. ")")
end
local decisions = cjson.decode(body)
-- process deleted decisions
if type(decisions.deleted) == "table" then
2022-01-13 09:48:05 +01:00
if not is_startup then
for i, decision in pairs(decisions.deleted) do
2022-01-28 12:26:31 +01:00
if decision.type == "captcha" then
runtime.cache:delete("captcha_" .. decision.value)
end
2022-01-17 18:17:43 +01:00
local key = item_to_string(decision.value, decision.scope)
2022-01-13 09:48:05 +01:00
runtime.cache:delete(key)
ngx.log(ngx.DEBUG, "Deleting '" .. key .. "'")
end
2022-01-05 15:10:54 +01:00
end
end
-- process new decisions
if type(decisions.new) == "table" then
for i, decision in pairs(decisions.new) do
if runtime.conf["BOUNCING_ON_TYPE"] == decision.type or runtime.conf["BOUNCING_ON_TYPE"] == "all" then
local ttl, err = parse_duration(decision.duration)
if err ~= nil then
ngx.log(ngx.ERR, "[Crowdsec] failed to parse ban duration '" .. decision.duration .. "' : " .. err)
end
2022-01-13 09:48:05 +01:00
local remediation_id = get_remediation_id(decision.type)
if remediation_id == nil then
remediation_id = 1
end
2022-01-17 18:17:43 +01:00
local key = item_to_string(decision.value, decision.scope)
2022-01-13 09:48:05 +01:00
local succ, err, forcible = runtime.cache:set(key, false, ttl, remediation_id)
2022-01-05 15:10:54 +01:00
if not succ then
ngx.log(ngx.ERR, "failed to add ".. decision.value .." : "..err)
end
if forcible then
ngx.log(ngx.ERR, "Lua shared dict (crowdsec cache) is full, please increase dict size in config")
end
2022-01-13 09:48:05 +01:00
ngx.log(ngx.DEBUG, "Adding '" .. key .. "' in cache for '" .. ttl .. "' seconds")
2022-01-05 15:10:54 +01:00
end
end
end
-- not startup anymore after first callback
2022-01-28 17:29:13 +01:00
local succ, err, forcible = runtime.cache:set("startup", false)
if not succ then
ngx.log(ngx.ERR, "failed to set startup key 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
2022-01-07 17:29:33 +01:00
-- re-occuring timer if there is no timer.every available
if ngx.timer.every == nil then
local ok, err = ngx.timer.at(runtime.conf["UPDATE_FREQUENCY"], stream_query)
if not ok then
error("Failed to create the timer: " .. (err or "unknown"))
end
end
2022-01-05 15:10:54 +01:00
return nil
end
function live_query(ip)
local link = runtime.conf["API_URL"] .. "/v1/decisions?ip=" .. ip
2022-01-25 17:22:01 +01:00
local res, err = get_http_request(link)
2022-01-05 15:10:54 +01:00
if not res then
2022-01-24 11:32:01 +01:00
return true, nil, "request failed: ".. err
2022-01-05 15:10:54 +01:00
end
local status = res.status
local body = res.body
if status~=200 then
2022-01-24 11:32:01 +01:00
return true, nil, "Http error " .. status .. " while talking to LAPI (" .. link .. ")"
2022-01-05 15:10:54 +01:00
end
if body == "null" then -- no result from API, no decision for this IP
-- set ip in cache and DON'T block it
2022-01-28 17:29:13 +01:00
local succ, err, forcible = runtime.cache:set(ip, true,runtime.conf["CACHE_EXPIRATION"])
if not succ then
ngx.log(ngx.ERR, "failed to add ip '" .. ip .. "' 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
2022-01-24 11:32:01 +01:00
return true, nil, nil
2022-01-05 15:10:54 +01:00
end
local decision = cjson.decode(body)[1]
if runtime.conf["BOUNCING_ON_TYPE"] == decision.type or runtime.conf["BOUNCING_ON_TYPE"] == "all" then
2022-01-24 11:32:01 +01:00
local remediation_id = get_remediation_id(decision.type)
if remediation_id == nil then
remediation_id = 1
end
local key = item_to_string(decision.value, decision.scope)
local succ, err, forcible = runtime.cache:set(key, false, runtime.conf["CACHE_EXPIRATION"], remediation_id)
if not succ then
ngx.log(ngx.ERR, "failed to add ".. decision.value .." : "..err)
end
if forcible then
ngx.log(ngx.ERR, "Lua shared dict (crowdsec cache) is full, please increase dict size in config")
end
ngx.log(ngx.DEBUG, "Adding '" .. key .. "' in cache for '" .. runtime.conf["CACHE_EXPIRATION"] .. "' seconds")
return false, decision.type, nil
2022-01-05 15:10:54 +01:00
else
2022-01-24 11:32:01 +01:00
return true, nil, nil
2022-01-05 15:10:54 +01:00
end
end
2022-01-25 17:22:01 +01:00
function csmod.GetCaptchaTemplate()
2022-01-26 12:41:45 +01:00
return recaptcha.GetTemplate()
2022-01-25 17:22:01 +01:00
end
2022-01-05 15:10:54 +01:00
function csmod.allowIp(ip)
if runtime.conf == nil then
2022-01-24 11:32:01 +01:00
return true, nil, "Configuration is bad, cannot run properly"
2022-01-05 15:10:54 +01:00
end
-- if it stream mode and startup start timer
2022-01-24 11:08:41 +01:00
if runtime.cache:get("first_run") == true and runtime.conf["MODE"] == "stream" then
2022-01-07 17:38:47 +01:00
local ok, err
2022-01-07 17:29:33 +01:00
if ngx.timer.every == nil then
2022-01-07 17:38:47 +01:00
ok, err = ngx.timer.at(runtime.conf["UPDATE_FREQUENCY"], stream_query)
2022-01-07 17:29:33 +01:00
else
2022-01-07 17:38:47 +01:00
ok, err = ngx.timer.every(runtime.conf["UPDATE_FREQUENCY"], stream_query)
end
if not ok then
2022-01-28 17:29:13 +01:00
local succ, err, forcible = runtime.cache:set("first_run", true)
if not succ then
ngx.log(ngx.ERR, "failed to set startup key 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
2022-01-24 11:32:01 +01:00
return true, nil, "Failed to create the timer: " .. (err or "unknown")
2022-01-07 17:29:33 +01:00
end
2022-01-28 17:29:13 +01:00
local succ, err, forcible = runtime.cache:set("first_run", false)
if not succ then
ngx.log(ngx.ERR, "failed to set first_run key 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
2022-01-05 15:10:54 +01:00
ngx.log(ngx.DEBUG, "Timer launched")
end
2022-01-17 18:17:43 +01:00
local key = item_to_string(ip, "ip")
2022-01-13 09:48:05 +01:00
local key_parts = {}
for i in key.gmatch(key, "([^_]+)") do
table.insert(key_parts, i)
end
2022-01-20 10:47:08 +01:00
2022-01-13 09:48:05 +01:00
local key_type = key_parts[1]
if key_type == "normal" then
local in_cache, remediation_id = runtime.cache:get(key)
if in_cache ~= nil then -- we have it in cache
ngx.log(ngx.DEBUG, "'" .. key .. "' is in cache")
return in_cache, runtime.remediations[tostring(remediation_id)], nil
end
end
2022-01-20 10:47:08 +01:00
local ip_network_address = key_parts[3]
2022-01-13 09:48:05 +01:00
local netmasks = iputils.netmasks_by_key_type[key_type]
2022-01-20 10:47:08 +01:00
for i, netmask in pairs(netmasks) do
2022-01-21 21:31:23 +01:00
local item
if key_type == "ipv4" then
item = key_type.."_"..netmask.."_"..iputils.ipv4_band(ip_network_address, netmask)
end
if key_type == "ipv6" then
item = key_type.."_"..table.concat(netmask, ":").."_"..iputils.ipv6_band(ip_network_address, netmask)
end
2022-01-24 12:25:27 +01:00
local in_cache, remediation_id = runtime.cache:get(item)
2022-01-13 09:48:05 +01:00
if in_cache ~= nil then -- we have it in cache
ngx.log(ngx.DEBUG, "'" .. key .. "' is in cache")
return in_cache, runtime.remediations[tostring(remediation_id)], nil
end
2022-01-05 15:10:54 +01:00
end
-- if live mode, query lapi
if runtime.conf["MODE"] == "live" then
2022-01-24 11:32:01 +01:00
local ok, remediation, err = live_query(ip)
return ok, remediation, err
2022-01-05 15:10:54 +01:00
end
2022-01-24 11:32:01 +01:00
return true, nil, nil
2022-01-05 15:10:54 +01:00
end
2022-01-27 18:57:05 +01:00
2022-01-26 16:50:29 +01:00
function csmod.Allow(ip)
2022-01-30 17:11:05 +01:00
if utils.table_len(runtime.conf["EXCLUDE_LOCATION"]) > 0 then
2022-01-28 17:08:08 +01:00
for k, v in pairs(runtime.conf["EXCLUDE_LOCATION"]) do
if ngx.var.uri == v then
ngx.log(ngx.ERR, "whitelisted location: " .. v)
return
end
if utils.ends_with(v, "/") == false then
uri_to_check = v .. "/"
end
if utils.starts_with(ngx.var.uri, uri_to_check) then
ngx.log(ngx.ERR, "whitelisted location: " .. uri_to_check)
end
2022-01-28 11:03:15 +01:00
end
2022-01-26 23:02:27 +01:00
end
2022-01-28 12:40:58 +01:00
local ok, remediation, err = csmod.allowIp(ip)
if err ~= nil then
ngx.log(ngx.ERR, "[Crowdsec] bouncer error: " .. err)
end
2022-01-28 13:00:27 +01:00
if ok == true then
ngx.shared.crowdsec_cache:delete("captcha_" .. ip)
2022-01-28 12:40:58 +01:00
end
2022-01-26 18:41:57 +01:00
captcha_ok = runtime.cache:get("captcha_ok")
if captcha_ok then -- if captcha can be use (configuration is valid)
2022-01-26 18:15:09 +01:00
-- we check if the IP need to validate its captcha before checking it against crowdsec local API
previous_uri, state_id = ngx.shared.crowdsec_cache:get("captcha_"..ngx.var.remote_addr)
if previous_uri ~= nil and state_id == recaptcha.GetStateID(recaptcha._VERIFY_STATE) then
ngx.req.read_body()
local recaptcha_res = ngx.req.get_post_args()["g-recaptcha-response"] or 0
if recaptcha_res ~= 0 then
valid, err = cs.validateCaptcha(recaptcha_res, ngx.var.remote_addr)
if err ~= nil then
ngx.log(ngx.ERR, "Error while validating captcha: " .. err)
end
if valid == true then
-- captcha is valid, we redirect the IP to its previous URI but in GET method
2022-01-28 17:29:13 +01:00
local succ, err, forcible = ngx.shared.crowdsec_cache:set("captcha_"..ngx.var.remote_addr, previous_uri, runtime.conf["CAPTCHA_EXPIRATION"], recaptcha.GetStateID(recaptcha._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
2022-01-26 18:15:09 +01:00
ngx.req.set_method(ngx.HTTP_GET)
ngx.redirect(previous_uri)
return
else
ngx.log(ngx.ALERT, "Invalid captcha from " .. ngx.var.remote_addr)
end
end
end
2022-01-26 16:50:29 +01:00
end
2022-01-26 18:15:09 +01:00
2022-01-26 16:50:29 +01:00
if not ok then
ngx.log(ngx.ALERT, "[Crowdsec] denied '" .. ngx.var.remote_addr .. "' with '"..remediation.."'")
if remediation == "ban" then
2022-01-30 17:07:51 +01:00
ban.apply()
2022-01-27 18:57:05 +01:00
return
2022-01-26 16:50:29 +01:00
end
2022-01-26 18:15:09 +01:00
-- if the remediation is a captcha and captcha is well configured
2022-01-26 18:41:57 +01:00
if remediation == "captcha" and captcha_ok then
2022-01-26 16:58:02 +01:00
previous_uri, state_id = ngx.shared.crowdsec_cache:get("captcha_"..ngx.var.remote_addr)
2022-01-26 18:15:09 +01:00
-- we check if the IP is already in cache for captcha and not yet validated
2022-01-26 17:14:13 +01:00
if previous_uri == nil or state_id ~= recaptcha.GetStateID(recaptcha._VALIDATED_STATE) then
2022-01-26 16:50:29 +01:00
ngx.header.content_type = "text/html"
ngx.say(cs.GetCaptchaTemplate())
2022-01-26 17:14:13 +01:00
local uri = ngx.var.uri
2022-01-26 18:15:09 +01:00
-- in case its not a GET request, we prefer to fallback on referer
2022-01-26 17:14:13 +01:00
if ngx.req.get_method() ~= "GET" then
2022-01-26 17:00:22 +01:00
headers, err = ngx.req.get_headers()
2022-01-26 16:58:02 +01:00
for k, v in pairs(headers) do
2022-01-26 17:14:13 +01:00
if k == "referer" then
uri = v
end
2022-01-26 16:58:02 +01:00
end
end
2022-01-28 17:29:13 +01:00
local succ, err, forcible = ngx.shared.crowdsec_cache:set("captcha_"..ngx.var.remote_addr, uri , 60, recaptcha.GetStateID(recaptcha._VERIFY_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
2022-01-26 16:50:29 +01:00
end
end
end
end
2022-01-05 15:10:54 +01:00
-- Use it if you are able to close at shuttime
function csmod.close()
end
return csmod