Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion config_example.conf
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ CACHE_EXPIRATION=1
# bounce for all type of remediation that the bouncer can receive from the local API
BOUNCING_ON_TYPE=all
FALLBACK_REMEDIATION=ban
# Force every bounced decision to this remediation, whatever type the LAPI or the appsec
# component returned. Valid values are captcha and ban. Leave empty to honour the
# remediation as received. Forcing captcha with a misconfigured captcha provider still
# degrades to FALLBACK_REMEDIATION.
OVERRIDE_REMEDIATION=
REQUEST_TIMEOUT=3000
UPDATE_FREQUENCY=10
# By default internal requests are ignored, such as any path affected by rewrite rule.
Expand All @@ -30,14 +35,24 @@ BAN_TEMPLATE_PATH=/var/lib/crowdsec/lua/templates/ban.html
REDIRECT_LOCATION=
RET_CODE=
#those apply for "captcha" action
#valid providers are recaptcha, hcaptcha, turnstile
#valid providers are recaptcha, hcaptcha, turnstile, cap
CAPTCHA_PROVIDER=
# Captcha Secret Key
SECRET_KEY=
# Captcha Site key
SITE_KEY=
CAPTCHA_TEMPLATE_PATH=/var/lib/crowdsec/lua/templates/captcha.html
CAPTCHA_EXPIRATION=3600
# Base URL of your self-hosted Cap instance (https://capjs.js.org), required when
# CAPTCHA_PROVIDER=cap. Browsers load the widget from here, so it must be publicly
# reachable. Example: https://cap.example.com
CAPTCHA_API_ENDPOINT=
# Optional base URL used only for server-side verification, when the bouncer reaches
# the Cap instance on a different address than the browser does. Defaults to
# CAPTCHA_API_ENDPOINT. Example: http://cap:3000
# If that URL is https with a self-signed certificate, SSL_VERIFY=false applies here
# too. Prefer trusting the CA over disabling verification outside of testing.
CAPTCHA_VERIFY_ENDPOINT=

APPSEC_URL=
APPSEC_FAILURE_ACTION=passthrough
Expand Down
9 changes: 8 additions & 1 deletion lib/crowdsec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function csmod.init(configFile, userAgent)
end

local captcha_ok = true
local err = captcha.New(runtime.conf["SITE_KEY"], runtime.conf["SECRET_KEY"], runtime.conf["CAPTCHA_TEMPLATE_PATH"], runtime.conf["CAPTCHA_PROVIDER"], runtime.conf["CAPTCHA_RET_CODE"])
local err = captcha.New(runtime.conf["SITE_KEY"], runtime.conf["SECRET_KEY"], runtime.conf["CAPTCHA_TEMPLATE_PATH"], runtime.conf["CAPTCHA_PROVIDER"], runtime.conf["CAPTCHA_RET_CODE"], runtime.conf["CAPTCHA_API_ENDPOINT"], runtime.conf["CAPTCHA_VERIFY_ENDPOINT"], runtime.conf["SSL_VERIFY"])
if err ~= nil then
ngx.log(ngx.ERR, "error loading captcha plugin: " .. err)
captcha_ok = false
Expand Down Expand Up @@ -759,6 +759,13 @@ function csmod.Allow(ip)

local captcha_ok = runtime.cache:get("captcha_ok")

-- serve one remediation for every bounced decision, whatever type the LAPI or the
-- appsec component returned. Applied before the fallback below, so forcing captcha
-- while the captcha provider is misconfigured still degrades to FALLBACK_REMEDIATION.
if not ok and runtime.conf["OVERRIDE_REMEDIATION"] ~= "" then
remediation = runtime.conf["OVERRIDE_REMEDIATION"]
end

if runtime.fallback ~= "" then
-- if we can't use captcha, fallback
if remediation == "captcha" and captcha_ok == false then
Expand Down
126 changes: 125 additions & 1 deletion lib/plugins/crowdsec/captcha.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,29 @@ local captcha_backend_url = {}
captcha_backend_url["recaptcha"] = "https://www.recaptcha.net/recaptcha/api/siteverify"
captcha_backend_url["hcaptcha"] = "https://hcaptcha.com/siteverify"
captcha_backend_url["turnstile"] = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
-- cap is self-hosted, so its verify URL is built from the configured endpoint in M.New()

local captcha_frontend_js = {}
captcha_frontend_js["recaptcha"] = "https://www.recaptcha.net/recaptcha/api.js"
captcha_frontend_js["hcaptcha"] = "https://js.hcaptcha.com/1/api.js"
captcha_frontend_js["turnstile"] = "https://challenges.cloudflare.com/turnstile/v0/api.js"
-- cap-widget is still pre-1.0, so pin the version rather than tracking the latest tag
captcha_frontend_js["cap"] = "https://cdn.jsdelivr.net/npm/cap-widget@0.1.56"

local captcha_frontend_key = {}
captcha_frontend_key["recaptcha"] = "g-recaptcha"
captcha_frontend_key["hcaptcha"] = "h-captcha"
captcha_frontend_key["turnstile"] = "cf-turnstile"
-- yields the "cap-response" form field name via M.GetCaptchaBackendKey()
captcha_frontend_key["cap"] = "cap"

M.SecretKey = ""
M.SiteKey = ""
M.Template = ""
M.ret_code = ngx.HTTP_OK
M.SSLVerify = true

function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code)
function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code, api_endpoint, verify_endpoint, ssl_verify)

if siteKey == nil or siteKey == "" then
return "no recaptcha site key provided, can't use recaptcha"
Expand All @@ -52,6 +58,33 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code)

M.CaptchaProvider = captcha_provider

-- New() runs before crowdsec.lua turns SSL_VERIFY into a boolean, so the raw
-- config string arrives here and "false" would be truthy in Lua. Normalize both
-- shapes, and only ever opt out on an explicit false.
M.SSLVerify = not (ssl_verify == false or ssl_verify == "false")

-- the provider drives every lookup below, so reject an unknown one here rather
-- than letting it surface as a nil concatenation while rendering the template
if captcha_frontend_key[M.CaptchaProvider] == nil then
return "unsupported captcha provider '" .. tostring(captcha_provider) .. "'"
end

-- cap is self-hosted, so every URL derives from the operator's own instance.
-- The browser and the bouncer can reach that instance on different addresses,
-- so verification may target a private endpoint while the widget uses the public one.
if M.CaptchaProvider == "cap" then
if api_endpoint == nil or api_endpoint == "" then
return "CAPTCHA_API_ENDPOINT is required when CAPTCHA_PROVIDER is 'cap'"
end
local public_base = api_endpoint:gsub("/+$", "")
local verify_base = public_base
if verify_endpoint ~= nil and verify_endpoint ~= "" then
verify_base = verify_endpoint:gsub("/+$", "")
end
M.ApiEndpoint = public_base .. "/" .. M.SiteKey .. "/"
captcha_backend_url["cap"] = verify_base .. "/" .. M.SiteKey .. "/siteverify"
end

local ret_code_ok = false
if ret_code ~= nil and ret_code ~= 0 and ret_code ~= "" then
for k, v in pairs(utils.HTTP_CODE) do
Expand All @@ -67,9 +100,33 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code)
end

local template_data = {}
-- still exported so templates written against the previous layout keep rendering
template_data["captcha_site_key"] = M.SiteKey
template_data["captcha_frontend_js"] = captcha_frontend_js[M.CaptchaProvider]
template_data["captcha_frontend_key"] = captcha_frontend_key[M.CaptchaProvider]

-- providers disagree on how the widget is loaded and declared, and the template
-- engine has no conditionals, so the markup is rendered here and injected whole
if M.CaptchaProvider == "cap" then
template_data["captcha_frontend_js_tag"] =
'<script type="module" src="' .. captcha_frontend_js["cap"] .. '"></script>'
-- the widget injects its own hidden input, named so that it matches
-- M.GetCaptchaBackendKey() instead of cap's "cap-token" default
template_data["captcha_widget"] =
'<cap-widget id="captcha" data-cap-api-endpoint="' .. M.ApiEndpoint ..
'" data-cap-hidden-field-name="' .. M.GetCaptchaBackendKey() .. '"></cap-widget>' ..
-- wrapped in a function so captchaCallback resolves when the event fires
-- rather than while this script is parsed: it is declared further down
'<script>document.getElementById("captcha")' ..
'.addEventListener("solve", function () { captchaCallback() })</script>'
else
template_data["captcha_frontend_js_tag"] =
'<script src="' .. captcha_frontend_js[M.CaptchaProvider] .. '" async defer></script>'
template_data["captcha_widget"] =
'<div id="captcha" class="' .. captcha_frontend_key[M.CaptchaProvider] ..
'" data-sitekey="' .. M.SiteKey .. '" data-callback="captchaCallback"></div>'
end

local view = template.compile(captcha_template, template_data)
M.Template = view

Expand All @@ -94,7 +151,74 @@ function table_to_encoded_url(args)
return table.concat(params, "&")
end

-- cap is reCAPTCHA-shaped but not reCAPTCHA-compatible: it takes a JSON body and
-- reports failures as a plain "error" string rather than an "error-codes" array,
-- so it gets its own request path instead of branching through the one below.
function M.ValidateCap(captcha_res, remote_ip)
local body = cjson.encode({
secret = M.SecretKey,
response = captcha_res
})

local httpc = http.new()
httpc:set_timeout(2000)
local res, err = httpc:request_uri(captcha_backend_url["cap"], {
method = "POST",
body = body,
headers = {
["Content-Type"] = "application/json",
-- verification is server-to-server, so the connecting address is the
-- bouncer's own. Forward the address that actually solved the challenge
-- so the cap instance (or a proxy in front of it) logs and rate limits
-- against the client rather than against nginx.
["X-Real-IP"] = remote_ip,
},
-- a self-hosted instance is commonly reached over TLS it terminates itself,
-- so honor SSL_VERIFY here the way the LAPI and appsec calls already do.
-- Only cap gets this: the hosted providers present publicly trusted certs,
-- and skipping verification against them would be a downgrade for no gain.
ssl_verify = M.SSLVerify,
})
httpc:close()

-- Every path below fails closed: anything short of cap explicitly answering
-- "success" leaves the visitor on the captcha page to try again. The instance
-- is self-hosted and rate limited per client, so a visitor can provoke these
-- failures for themselves on demand; treating them as a solve would hand out
-- CAPTCHA_EXPIRATION worth of access for a token nothing ever verified.
if err ~= nil then
return false, err
end

-- a self-hosted instance can sit behind a proxy that answers with an HTML error
-- page, so a failed decode must not raise out of the access phase
local ok, result = pcall(cjson.decode, res.body)
if not ok or type(result) ~= "table" then
return false, "cap returned a non-JSON response (HTTP " .. tostring(res.status) .. ")"
end

-- separated from the decode failure above so an outage or a tripped rate limit
-- is distinguishable in the logs from a visitor submitting a bad token
if res.status ~= ngx.HTTP_OK then
return false, "cap verification failed with HTTP " .. tostring(res.status) ..
" (" .. tostring(result.error) .. ")"
end

if result.success ~= true and result.error ~= nil then
ngx.log(ngx.ERR, "cap captcha validation failed: " .. tostring(result.error))
end

return result.success == true, nil
end

function M.Validate(captcha_res, remote_ip)
if M.CaptchaProvider == "cap" then
-- cap has no remoteip body field, so the IP travels as an X-Real-IP header.
-- Note this path fails closed, unlike the hosted providers below: their
-- endpoints are not something a visitor can knock over to skip the captcha.
return M.ValidateCap(captcha_res, remote_ip)
end

local body = {
secret = M.SecretKey,
response = captcha_res,
Expand Down
11 changes: 10 additions & 1 deletion lib/plugins/crowdsec/config.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
local config = {}

local valid_params = {'ENABLED', 'ENABLE_INTERNAL', 'API_URL', 'API_KEY', 'BOUNCING_ON_TYPE', 'MODE', 'SECRET_KEY', 'SITE_KEY', 'BAN_TEMPLATE_PATH' ,'CAPTCHA_TEMPLATE_PATH', 'REDIRECT_LOCATION', 'RET_CODE', 'CAPTCHA_RET_CODE', 'EXCLUDE_LOCATION', 'FALLBACK_REMEDIATION', 'CAPTCHA_PROVIDER', 'APPSEC_URL', 'APPSEC_FAILURE_ACTION', 'ALWAYS_SEND_TO_APPSEC', 'APPSEC_DROP_UNREADABLE_BODY', 'SSL_VERIFY', 'USE_TLS_AUTH', 'TLS_CLIENT_CERT', 'TLS_CLIENT_KEY', 'SCENARIOS_CONTAINING', 'SCENARIOS_NOT_CONTAINING'}
local valid_params = {'ENABLED', 'ENABLE_INTERNAL', 'API_URL', 'API_KEY', 'BOUNCING_ON_TYPE', 'MODE', 'SECRET_KEY', 'SITE_KEY', 'BAN_TEMPLATE_PATH' ,'CAPTCHA_TEMPLATE_PATH', 'REDIRECT_LOCATION', 'RET_CODE', 'CAPTCHA_RET_CODE', 'EXCLUDE_LOCATION', 'FALLBACK_REMEDIATION', 'OVERRIDE_REMEDIATION', 'CAPTCHA_PROVIDER', 'CAPTCHA_API_ENDPOINT', 'CAPTCHA_VERIFY_ENDPOINT', 'APPSEC_URL', 'APPSEC_FAILURE_ACTION', 'ALWAYS_SEND_TO_APPSEC', 'APPSEC_DROP_UNREADABLE_BODY', 'SSL_VERIFY', 'USE_TLS_AUTH', 'TLS_CLIENT_CERT', 'TLS_CLIENT_KEY', 'SCENARIOS_CONTAINING', 'SCENARIOS_NOT_CONTAINING'}
local valid_int_params = {'CACHE_EXPIRATION', 'CACHE_SIZE', 'REQUEST_TIMEOUT', 'UPDATE_FREQUENCY', 'CAPTCHA_EXPIRATION', 'APPSEC_CONNECT_TIMEOUT', 'APPSEC_SEND_TIMEOUT', 'APPSEC_PROCESS_TIMEOUT', 'STREAM_REQUEST_TIMEOUT'}
-- CACHE_SIZE is not used in the code, but as is was valid parameter for the configuration file, not removing it now
local valid_bouncing_on_type_values = {'ban', 'captcha', 'all'}
Expand All @@ -18,7 +18,10 @@ local default_values = {
['REDIRECT_LOCATION'] = "",
['EXCLUDE_LOCATION'] = {},
['RET_CODE'] = 0,
['OVERRIDE_REMEDIATION'] = "",
['CAPTCHA_PROVIDER'] = "recaptcha",
['CAPTCHA_API_ENDPOINT'] = "",
['CAPTCHA_VERIFY_ENDPOINT'] = "",
['APPSEC_URL'] = "",
['APPSEC_CONNECT_TIMEOUT'] = 100,
['APPSEC_SEND_TIMEOUT'] = 100,
Expand Down Expand Up @@ -130,6 +133,12 @@ function config.loadConfig(file, default)
ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value 'ban' instead")
value = "ban"
end
elseif key == "OVERRIDE_REMEDIATION" then
-- empty means the remediation returned by the LAPI is used as-is
if value ~= "" and not has_value({'captcha', 'ban'}, value) then
ngx.log(ngx.ERR, "unsupported value '" .. value .. "' for variable '" .. key .. "'. Using default value '' instead")
value = ""
end
end
conf[key] = value

Expand Down
Loading
Loading