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{<cap-widget id="captcha" data-cap-api-endpoint="https://cap\.example\.com/capsitekey/"}) { + print $out_fh "cap-widget missing or wrong api endpoint\n"; + exit 1; +} + +# the hidden input name must match what GetCaptchaBackendKey() reads back +if ($content !~ m{data-cap-hidden-field-name="cap-response"}) { + print $out_fh "cap-widget is not renaming its hidden field to cap-response\n"; + exit 1; +} + +# cap-widget is an ES module, a plain script tag would never define the element +if ($content !~ m{<script type="module" src="https://cdn\.jsdelivr\.net/npm/cap-widget\@}) { + print $out_fh "widget script tag is missing type=module\n"; + exit 1; +} + +if ($content =~ /\{\{/) { + print $out_fh "template still contains unsubstituted placeholders\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 + location = /capsitekey/siteverify { + content_by_lua_block { + local cjson = require "cjson" + ngx.req.read_body() + local body = ngx.req.get_body_data() + local ok, payload = pcall(cjson.decode, body) + if not ok or payload.secret ~= "capsecret" or payload.response == nil then + ngx.log(ngx.ERR, "STUB SITEVERIFY: rejected body " .. tostring(body)) + ngx.status = 400 + ngx.say('{"success":false,"error":"Missing required parameters"}') + return + end + -- the bouncer, not the browser, calls siteverify, so the solving client's + -- address is only visible here if it was forwarded as X-Real-IP + ngx.log(ngx.ALERT, "STUB SITEVERIFY: accepted response=" .. payload.response .. " x-real-ip=" .. tostring(ngx.var.http_x_real_ip)) + ngx.say('{"success":true}') + } + } +} + +--- 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: 302 +--- response_headers +Location: /t +--- grep_error_log eval +qr/STUB SITEVERIFY: [^,]*/ +--- grep_error_log_out +STUB SITEVERIFY: accepted response=capsitekey:redeemid:redeemsecret x-real-ip=1.1.1.1 diff --git a/t/24_live_override_remediation.t b/t/24_live_override_remediation.t new file mode 100644 index 00000000..6915732d --- /dev/null +++ b/t/24_live_override_remediation.t @@ -0,0 +1,74 @@ +# OVERRIDE_REMEDIATION=captcha. +# The LAPI returns a "ban" decision, so without the override the bouncer would serve +# the ban template with 403. With it, the request is challenged with a captcha instead. + +use Test::Nginx::Socket 'no_plan'; + +run_tests(); + +__DATA__ + +=== TEST 24: ban decision forced to captcha + +--- 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/24_live_override_remediation_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":"ban","value":"1.1.1.1"}]') + else + ngx.say('[{}]') + end + } + } +} + +--- 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 + +--- request +GET /t + +--- error_code: 200 +--- response_body_like: <title>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('<html><head><title>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

-
+ {{captcha_widget}}