From f26faf402f34c27306698128cb671ec7218701b6 Mon Sep 17 00:00:00 2001 From: Tommy Ngo Date: Tue, 11 Aug 2026 16:14:00 +1200 Subject: [PATCH 1/5] feat(captcha): add cap as a self-hosted captcha provider Adds CAPTCHA_PROVIDER=cap, a self-hosted proof-of-work captcha (https://capjs.js.org) that needs no third-party service. Cap is reCAPTCHA-shaped but not reCAPTCHA-compatible, so it gets its own verification path: a JSON POST to //siteverify, which reports failures as a plain "error" string rather than reCAPTCHA's "error-codes" array. Reusing the existing path would crash on pairs(nil). Because the instance is self-hosted, its URLs are configuration rather than constants. The browser and the bouncer can reach it on different addresses, so CAPTCHA_API_ENDPOINT is the public base the widget is pointed at, and the optional CAPTCHA_VERIFY_ENDPOINT lets verification stay on a private network. Both compose with SITE_KEY, which cap embeds in its paths. The widget is a custom element loaded as an ES module, not a script plus a div, so the template now receives the markup pre-rendered as captcha_frontend_js_tag and captcha_widget. The three previous variables are still populated so custom templates keep rendering. Naming the widget's hidden input via data-cap-hidden-field-name keeps GetCaptchaBackendKey() unchanged for every provider. An unknown CAPTCHA_PROVIDER is now rejected in New() instead of surfacing later as a nil concatenation while rendering. --- config_example.conf | 10 +- lib/crowdsec.lua | 2 +- lib/plugins/crowdsec/captcha.lua | 95 ++++++++++- lib/plugins/crowdsec/config.lua | 4 +- t/23_live_captcha_cap.t | 152 ++++++++++++++++++ ...ve_captcha_cap_crowdsec_nginx_bouncer.conf | 32 ++++ templates/captcha.html | 4 +- 7 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 t/23_live_captcha_cap.t create mode 100644 t/conf_t/23_live_captcha_cap_crowdsec_nginx_bouncer.conf diff --git a/config_example.conf b/config_example.conf index f69f2cb1..05c1dd8a 100644 --- a/config_example.conf +++ b/config_example.conf @@ -30,7 +30,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 +38,14 @@ 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 +CAPTCHA_VERIFY_ENDPOINT= APPSEC_URL= APPSEC_FAILURE_ACTION=passthrough diff --git a/lib/crowdsec.lua b/lib/crowdsec.lua index 28e474f3..ad6914a4 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"]) if err ~= nil then ngx.log(ngx.ERR, "error loading captcha plugin: " .. err) captcha_ok = false diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 81d54346..e97aae37 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -9,23 +9,28 @@ 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 -function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) +function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code, api_endpoint, verify_endpoint) if siteKey == nil or siteKey == "" then return "no recaptcha site key provided, can't use recaptcha" @@ -52,6 +57,28 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) M.CaptchaProvider = captcha_provider + -- 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 +94,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 +145,49 @@ 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) + 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", + }, + }) + httpc:close() + if err ~= nil then + return true, 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 true, "cap returned a non-JSON response (HTTP " .. tostring(res.status) .. ")" + 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 field, the caller's IP is not forwarded + return M.ValidateCap(captcha_res) + 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..5572f116 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', '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'} @@ -19,6 +19,8 @@ local default_values = { ['EXCLUDE_LOCATION'] = {}, ['RET_CODE'] = 0, ['CAPTCHA_PROVIDER'] = "recaptcha", + ['CAPTCHA_API_ENDPOINT'] = "", + ['CAPTCHA_VERIFY_ENDPOINT'] = "", ['APPSEC_URL'] = "", ['APPSEC_CONNECT_TIMEOUT'] = 100, ['APPSEC_SEND_TIMEOUT'] = 100, diff --git a/t/23_live_captcha_cap.t b/t/23_live_captcha_cap.t new file mode 100644 index 00000000..0187dcf0 --- /dev/null +++ b/t/23_live_captcha_cap.t @@ -0,0 +1,152 @@ +# 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 + ngx.log(ngx.ALERT, "STUB SITEVERIFY: accepted response=" .. payload.response) + 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 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/templates/captcha.html b/templates/captcha.html index b01f5e11..3c567f36 100644 --- a/templates/captcha.html +++ b/templates/captcha.html @@ -5,7 +5,7 @@ <meta content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <style>/*! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}html{-webkit-text-size-adjust:100%;font-feature-settings:normal;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.fixed{position:fixed}.right-0{right:0}.top-0{top:0}.flex{display:flex}.h-24{height:6rem}.h-3\/5{height:60%}.h-6{height:1.5rem}.h-full{height:100%}.h-screen{height:100vh}.w-24{width:6rem}.w-6{width:1.5rem}.w-full{width:100%}.w-screen{width:100vw}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.rounded-xl{border-radius:.75rem}.border-2{border-width:2px}.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0/var(--tw-border-opacity))}.p-4{padding:1rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.dark .dark\:border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity))}.dark .dark\:bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity))}.dark .dark\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}@media (min-width:640px){.sm\:w-2\/3{width:66.666667%}}@media (min-width:768px){.md\:h-2\/5{height:40%}.md\:flex-row{flex-direction:row}.md\:text-2xl{font-size:1.5rem;line-height:2rem}.md\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:w-1\/2{width:50%}.lg\:text-3xl{font-size:1.875rem;line-height:2.25rem}.lg\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width:1280px){.xl\:text-4xl{font-size:2.25rem;line-height:2.5rem}}</style> - <script src="{{captcha_frontend_js}}" async defer></script> + {{captcha_frontend_js_tag}} </head> <body class="h-screen w-screen"> <div class="h-full w-full flex flex-col justify-center items-center dark:bg-slate-900 dark:text-white"> @@ -17,7 +17,7 @@ </svg> <h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1> <form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form"> - <div id="captcha" class="{{captcha_frontend_key}}" data-sitekey="{{captcha_site_key}}" data-callback="captchaCallback"></div> + {{captcha_widget}} </form> </div> <div class="flex-col md:flex-row text-sm flex items-center"> From 37079a41e1a9711586cb13681cc066170178ba13 Mon Sep 17 00:00:00 2001 From: Tommy Ngo <tommy@sitehost.co.nz> Date: Tue, 11 Aug 2026 16:14:30 +1200 Subject: [PATCH 2/5] feat(remediation): add OVERRIDE_REMEDIATION to force a single remediation Serves one remediation for every bounced decision, whatever type the LAPI or the appsec component returned. Setting OVERRIDE_REMEDIATION=captcha challenges every blocked visitor rather than dropping them outright, which keeps false positives recoverable. Valid values are captcha and ban, mirroring FALLBACK_REMEDIATION; empty (the default) honours the remediation as received, so existing configs are unaffected. The override is applied before the fallback, so forcing captcha while the captcha provider is misconfigured still degrades to FALLBACK_REMEDIATION rather than serving nothing. --- config_example.conf | 5 ++ lib/crowdsec.lua | 7 ++ lib/plugins/crowdsec/config.lua | 9 ++- t/24_live_override_remediation.t | 74 +++++++++++++++++++ ...de_remediation_crowdsec_nginx_bouncer.conf | 33 +++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 t/24_live_override_remediation.t create mode 100644 t/conf_t/24_live_override_remediation_crowdsec_nginx_bouncer.conf diff --git a/config_example.conf b/config_example.conf index 05c1dd8a..17cae405 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. diff --git a/lib/crowdsec.lua b/lib/crowdsec.lua index ad6914a4..2b3e27ce 100644 --- a/lib/crowdsec.lua +++ b/lib/crowdsec.lua @@ -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/config.lua b/lib/plugins/crowdsec/config.lua index 5572f116..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', '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_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,6 +18,7 @@ local default_values = { ['REDIRECT_LOCATION'] = "", ['EXCLUDE_LOCATION'] = {}, ['RET_CODE'] = 0, + ['OVERRIDE_REMEDIATION'] = "", ['CAPTCHA_PROVIDER'] = "recaptcha", ['CAPTCHA_API_ENDPOINT'] = "", ['CAPTCHA_VERIFY_ENDPOINT'] = "", @@ -132,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/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/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 From 26a37f15516e42a92197ec1431c506941edad3d4 Mon Sep 17 00:00:00 2001 From: Tommy Ngo Date: Thu, 13 Aug 2026 11:56:24 +1200 Subject: [PATCH 3/5] feat(captcha): forward client IP to cap siteverify as X-Real-IP - pass remote_ip into ValidateCap and send it as X-Real-IP, so the cap instance sees the address that solved the challenge rather than the bouncer's own, which is all a server-to-server call would otherwise show - assert the forwarded header in the stub siteverify used by test 23 --- lib/plugins/crowdsec/captcha.lua | 11 ++++++++--- t/23_live_captcha_cap.t | 6 ++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index e97aae37..4946bba8 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -148,7 +148,7 @@ 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) +function M.ValidateCap(captcha_res, remote_ip) local body = cjson.encode({ secret = M.SecretKey, response = captcha_res @@ -161,6 +161,11 @@ function M.ValidateCap(captcha_res) 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, }, }) httpc:close() @@ -184,8 +189,8 @@ end function M.Validate(captcha_res, remote_ip) if M.CaptchaProvider == "cap" then - -- cap has no remoteip field, the caller's IP is not forwarded - return M.ValidateCap(captcha_res) + -- cap has no remoteip body field, so the IP travels as an X-Real-IP header + return M.ValidateCap(captcha_res, remote_ip) end local body = { diff --git a/t/23_live_captcha_cap.t b/t/23_live_captcha_cap.t index 0187dcf0..b55c3697 100644 --- a/t/23_live_captcha_cap.t +++ b/t/23_live_captcha_cap.t @@ -118,7 +118,9 @@ server { ngx.say('{"success":false,"error":"Missing required parameters"}') return end - ngx.log(ngx.ALERT, "STUB SITEVERIFY: accepted response=" .. payload.response) + -- 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}') } } @@ -149,4 +151,4 @@ Location: /t --- grep_error_log eval qr/STUB SITEVERIFY: [^,]*/ --- grep_error_log_out -STUB SITEVERIFY: accepted response=capsitekey:redeemid:redeemsecret +STUB SITEVERIFY: accepted response=capsitekey:redeemid:redeemsecret x-real-ip=1.1.1.1 From 8b389f48868d84dbb7aa895aa60acd152e72ec91 Mon Sep 17 00:00:00 2001 From: Tommy Ngo Date: Thu, 13 Aug 2026 13:04:01 +1200 Subject: [PATCH 4/5] feat(captcha): honour SSL_VERIFY on the cap verify request A self-hosted cap instance is commonly reached over TLS it terminates itself, and resty.http verifies by default, so a self-signed certificate failed verification with "18: self-signed certificate". - thread SSL_VERIFY from the config through captcha.New into the cap siteverify request - normalise the value inside New: it is called before crowdsec.lua turns SSL_VERIFY into a boolean, and the raw "false" string is truthy in Lua - leave the hosted providers verifying unconditionally, as they present publicly trusted certificates --- config_example.conf | 2 ++ lib/crowdsec.lua | 2 +- lib/plugins/crowdsec/captcha.lua | 13 ++++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/config_example.conf b/config_example.conf index 17cae405..b0f1498c 100644 --- a/config_example.conf +++ b/config_example.conf @@ -50,6 +50,8 @@ 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= diff --git a/lib/crowdsec.lua b/lib/crowdsec.lua index 2b3e27ce..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"], runtime.conf["CAPTCHA_API_ENDPOINT"], runtime.conf["CAPTCHA_VERIFY_ENDPOINT"]) + 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 diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 4946bba8..483272d0 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -29,8 +29,9 @@ 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, api_endpoint, verify_endpoint) +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" @@ -57,6 +58,11 @@ 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 @@ -167,6 +173,11 @@ function M.ValidateCap(captcha_res, remote_ip) -- 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() if err ~= nil then From 6f18de90f5ea51965a71a37060a88edc52d4670c Mon Sep 17 00:00:00 2001 From: Tommy Ngo Date: Thu, 13 Aug 2026 13:10:55 +1200 Subject: [PATCH 5/5] fix(captcha): fail closed when cap verification does not succeed ValidateCap returned true, meaning "solved", for any outcome short of a decoded JSON body. A transport error or an error page from a proxy in front of cap therefore let the visitor through and cached the result as VALIDATED_STATE for CAPTCHA_EXPIRATION, for a token nothing had checked. Cap is self-hosted and commonly rate limited per client IP, so a visitor can provoke that outcome for themselves: spend their own bucket, submit junk, and the 429 the edge returns is read as a solve. Every path now returns false, leaving them on the captcha page to try again. - fail closed on transport errors and on responses that are not JSON - reject non-200 responses, kept separate from the decode failure so an outage or a tripped rate limit stays distinguishable in the logs from a visitor submitting a bad token - add t/25, which stubs the 429 error page an nginx edge returns when limit_req trips, and asserts the captcha page is served again rather than the redirect that the previous behaviour produced The hosted providers are left as they are: their endpoints are not something a visitor can knock over to skip the captcha. --- lib/plugins/crowdsec/captcha.lua | 21 ++++- t/25_live_captcha_cap_fail_closed.t | 123 ++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 t/25_live_captcha_cap_fail_closed.t diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 483272d0..fb3331c3 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -180,15 +180,28 @@ function M.ValidateCap(captcha_res, remote_ip) 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 true, err + 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 true, "cap returned a non-JSON response (HTTP " .. tostring(res.status) .. ")" + 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 @@ -200,7 +213,9 @@ 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 + -- 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 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