diff --git a/config_example.conf b/config_example.conf
index f69f2cb1..b0f1498c 100644
--- a/config_example.conf
+++ b/config_example.conf
@@ -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.
@@ -30,7 +35,7 @@ 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=
@@ -38,6 +43,16 @@ SECRET_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
diff --git a/lib/crowdsec.lua b/lib/crowdsec.lua
index 28e474f3..c005dc73 100644
--- a/lib/crowdsec.lua
+++ b/lib/crowdsec.lua
@@ -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
@@ -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
diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua
index 81d54346..fb3331c3 100644
--- a/lib/plugins/crowdsec/captcha.lua
+++ b/lib/plugins/crowdsec/captcha.lua
@@ -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"
@@ -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
@@ -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"] =
+ ''
+ -- 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"] =
+ '' ..
+ -- wrapped in a function so captchaCallback resolves when the event fires
+ -- rather than while this script is parsed: it is declared further down
+ ''
+ else
+ template_data["captcha_frontend_js_tag"] =
+ ''
+ template_data["captcha_widget"] =
+ '
'
+ end
+
local view = template.compile(captcha_template, template_data)
M.Template = view
@@ -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,
diff --git a/lib/plugins/crowdsec/config.lua b/lib/plugins/crowdsec/config.lua
index e5dd08f7..1a332b08 100644
--- a/lib/plugins/crowdsec/config.lua
+++ b/lib/plugins/crowdsec/config.lua
@@ -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'}
@@ -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,
@@ -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
diff --git a/t/23_live_captcha_cap.t b/t/23_live_captcha_cap.t
new file mode 100644
index 00000000..b55c3697
--- /dev/null
+++ b/t/23_live_captcha_cap.t
@@ -0,0 +1,154 @@
+# Live mode, CAPTCHA_PROVIDER=cap.
+# --- init GET /t -> served the cap widget, primes the verify state
+# --- request POST /t -> token verified against the stub siteverify -> 302
+#
+# The stub on 8081 mirrors cap's contract (standalone/src/siteverify.js): it answers
+# {"success":true} only for a JSON body carrying the configured secret.
+
+use Test::Nginx::Socket 'no_plan';
+
+run_tests();
+
+__DATA__
+
+=== TEST 23: Live mode cap captcha
+
+--- init
+
+use LWP::UserAgent;
+
+my $ua = LWP::UserAgent->new;
+my $url = 'http://127.0.0.1:1984/t';
+
+open my $out_fh, '>', 't/servroot/logs/perl.init.log' or die $!;
+print $out_fh "Starting initialization...\n";
+
+my $req = HTTP::Request->new(GET => $url);
+$req->header('X-Forwarded-For' => '1.1.1.1');
+
+my $resp = $ua->request($req);
+if (!$resp->is_success || $resp->code != 200) {
+ print $out_fh "Expected the captcha page, got HTTP " . $resp->code . "\n";
+ exit 1;
+}
+
+my $content = $resp->decoded_content;
+
+if ($content !~ /CrowdSec Captcha<\/title>/i) {
+ print $out_fh "Captcha template was not served\n";
+ exit 1;
+}
+
+# the widget must be a cap-widget pointed at the public endpoint, not the verify one
+if ($content !~ m{CrowdSec Captcha
+--- no_error_log
+[Crowdsec] denied '1.1.1.1' with 'ban'
diff --git a/t/25_live_captcha_cap_fail_closed.t b/t/25_live_captcha_cap_fail_closed.t
new file mode 100644
index 00000000..af7068d5
--- /dev/null
+++ b/t/25_live_captcha_cap_fail_closed.t
@@ -0,0 +1,123 @@
+# Live mode, CAPTCHA_PROVIDER=cap, verification fails at the transport level.
+# --- init GET /t -> served the cap widget, primes the verify state
+# --- request POST /t -> siteverify answers 429 -> NOT let through
+#
+# The stub answers the way an nginx edge in front of cap does when limit_req trips:
+# a 429 carrying an HTML error page rather than cap's JSON. Reusing conf 23, which
+# is the same deployment; only the stub's behaviour differs.
+#
+# Cap is self-hosted and rate limited per client IP, so a visitor can provoke this
+# response for themselves on demand. Treating it as a solve would let anyone skip
+# the captcha for CAPTCHA_EXPIRATION by submitting junk with a spent rate limit
+# bucket, so the response here must be the captcha page again, never a 302.
+
+use Test::Nginx::Socket 'no_plan';
+
+run_tests();
+
+__DATA__
+
+=== TEST 25: Live mode cap captcha fails closed when siteverify errors
+
+--- init
+
+use LWP::UserAgent;
+
+my $ua = LWP::UserAgent->new;
+my $url = 'http://127.0.0.1:1984/t';
+
+open my $out_fh, '>', 't/servroot/logs/perl.init.log' or die $!;
+print $out_fh "Starting initialization...\n";
+
+my $req = HTTP::Request->new(GET => $url);
+$req->header('X-Forwarded-For' => '1.1.1.1');
+
+my $resp = $ua->request($req);
+if (!$resp->is_success || $resp->code != 200) {
+ print $out_fh "Expected the captcha page, got HTTP " . $resp->code . "\n";
+ exit 1;
+}
+
+if ($resp->decoded_content !~ /CrowdSec Captcha<\/title>/i) {
+ print $out_fh "Captcha template was not served\n";
+ exit 1;
+}
+
+print $out_fh "Captcha page served as expected.\n";
+close $out_fh or warn "Could not close filehandle: $!";
+
+--- main_config
+load_module /usr/share/nginx/modules/ndk_http_module.so;
+load_module /usr/share/nginx/modules/ngx_http_lua_module.so;
+
+--- http_config
+
+lua_package_path './lib/?.lua;;';
+lua_shared_dict crowdsec_cache 50m;
+lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
+
+init_by_lua_block
+{
+ cs = require "crowdsec"
+ local ok, err = cs.init("./t/conf_t/23_live_captcha_cap_crowdsec_nginx_bouncer.conf", "crowdsec-nginx-bouncer/v1.0.8")
+ if ok == nil then
+ ngx.log(ngx.ERR, "[Crowdsec] " .. err)
+ error()
+ end
+ ngx.log(ngx.ALERT, "[Crowdsec] Initialisation done")
+}
+
+access_by_lua_block {
+ local cs = require "crowdsec"
+ cs.Allow(ngx.var.remote_addr)
+}
+
+server {
+ listen 8081;
+
+ location = /v1/decisions {
+ content_by_lua_block {
+ local args, err = ngx.req.get_uri_args()
+ if args.ip == "1.1.1.1" then
+ ngx.say('[{"duration":"1h00m00s","id":4091593,"origin":"CAPI","scenario":"crowdsecurity/vpatch-CVE-2024-4577","scope":"Ip","type":"captcha","value":"1.1.1.1"}]')
+ else
+ ngx.say('[{}]')
+ end
+ }
+ }
+
+ # stub cap instance behind an edge whose rate limiter has tripped
+ location = /capsitekey/siteverify {
+ content_by_lua_block {
+ ngx.status = 429
+ ngx.header.content_type = "text/html"
+ ngx.log(ngx.ALERT, "STUB SITEVERIFY: rate limited")
+ ngx.say('429 Too Many Requests')
+ }
+ }
+}
+
+--- config
+
+location = /t {
+ set_real_ip_from 127.0.0.1;
+ real_ip_header X-Forwarded-For;
+ real_ip_recursive on;
+ content_by_lua_block {
+ ngx.print("ok")
+ }
+}
+
+--- more_headers
+X-Forwarded-For: 1.1.1.1
+Content-Type: application/x-www-form-urlencoded
+
+--- request eval
+"POST /t
+cap-response=capsitekey:redeemid:redeemsecret"
+
+--- error_code: 200
+--- response_body_like: cap-widget
+--- error_log
+cap returned a non-JSON response (HTTP 429)
+Invalid captcha from 1.1.1.1
diff --git a/t/conf_t/23_live_captcha_cap_crowdsec_nginx_bouncer.conf b/t/conf_t/23_live_captcha_cap_crowdsec_nginx_bouncer.conf
new file mode 100644
index 00000000..a9e14339
--- /dev/null
+++ b/t/conf_t/23_live_captcha_cap_crowdsec_nginx_bouncer.conf
@@ -0,0 +1,32 @@
+APPSEC_URL=
+ENABLED=true
+API_URL=http://127.0.0.1:8081
+API_KEY=LFrdL+aiecMTSxpGE9vLkx5sGMwdIpgVovpVMfXp3J0
+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
+REQUEST_TIMEOUT=3000
+UPDATE_FREQUENCY=10
+# live or stream
+MODE=live
+# exclude the bouncing on those location
+# the bouncer's own call to the cap instance must be excluded, otherwise verification
+# is bounced back to the captcha page and the user can never get through
+EXCLUDE_LOCATION=/v1/decisions,/capsitekey/siteverify
+#those apply for "ban" action
+# /!\ REDIRECT_LOCATION and RET_CODE can't be used together. REDIRECT_LOCATION take priority over RET_CODE
+BAN_TEMPLATE_PATH=./ban
+REDIRECT_LOCATION=
+RET_CODE=
+#those apply for "captcha" action
+#valid providers are recaptcha, hcaptcha, turnstile, cap
+CAPTCHA_PROVIDER=cap
+# Captcha Secret Key
+SECRET_KEY=capsecret
+# Captcha Site key
+SITE_KEY=capsitekey
+CAPTCHA_TEMPLATE_PATH=templates/captcha.html
+CAPTCHA_EXPIRATION=3600
+CAPTCHA_API_ENDPOINT=https://cap.example.com
+CAPTCHA_VERIFY_ENDPOINT=http://127.0.0.1:8081
diff --git a/t/conf_t/24_live_override_remediation_crowdsec_nginx_bouncer.conf b/t/conf_t/24_live_override_remediation_crowdsec_nginx_bouncer.conf
new file mode 100644
index 00000000..58d42351
--- /dev/null
+++ b/t/conf_t/24_live_override_remediation_crowdsec_nginx_bouncer.conf
@@ -0,0 +1,33 @@
+APPSEC_URL=
+ENABLED=true
+API_URL=http://127.0.0.1:8081
+API_KEY=LFrdL+aiecMTSxpGE9vLkx5sGMwdIpgVovpVMfXp3J0
+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
+OVERRIDE_REMEDIATION=captcha
+REQUEST_TIMEOUT=3000
+UPDATE_FREQUENCY=10
+# live or stream
+MODE=live
+# exclude the bouncing on those location
+# the bouncer's own call to the cap instance must be excluded, otherwise verification
+# is bounced back to the captcha page and the user can never get through
+EXCLUDE_LOCATION=/v1/decisions,/capsitekey/siteverify
+#those apply for "ban" action
+# /!\ REDIRECT_LOCATION and RET_CODE can't be used together. REDIRECT_LOCATION take priority over RET_CODE
+BAN_TEMPLATE_PATH=./ban
+REDIRECT_LOCATION=
+RET_CODE=
+#those apply for "captcha" action
+#valid providers are recaptcha, hcaptcha, turnstile, cap
+CAPTCHA_PROVIDER=cap
+# Captcha Secret Key
+SECRET_KEY=capsecret
+# Captcha Site key
+SITE_KEY=capsitekey
+CAPTCHA_TEMPLATE_PATH=templates/captcha.html
+CAPTCHA_EXPIRATION=3600
+CAPTCHA_API_ENDPOINT=https://cap.example.com
+CAPTCHA_VERIFY_ENDPOINT=http://127.0.0.1:8081
diff --git a/templates/captcha.html b/templates/captcha.html
index b01f5e11..3c567f36 100644
--- a/templates/captcha.html
+++ b/templates/captcha.html
@@ -5,7 +5,7 @@
-
+ {{captcha_frontend_js_tag}}
@@ -17,7 +17,7 @@
CrowdSec Captcha