diff --git a/lib/crowdsec.lua b/lib/crowdsec.lua index 28e474f..9447bee 100644 --- a/lib/crowdsec.lua +++ b/lib/crowdsec.lua @@ -735,45 +735,28 @@ function csmod.Allow(ip) ngx.log(ngx.ERR, "[Crowdsec] bouncer error: " .. err) end - -- if the ip is now allowed, try to delete its captcha state in cache + -- if the ip is now allowed, try to delete its captcha state in cache. + -- only a state created for a LAPI decision is dropped here: an appsec captcha + -- does not depend on the IP having a decision, so deleting it would destroy the + -- pending verification (or the validated grace period) on every single request + -- and make the captcha impossible to solve. if ok == true then - ngx.shared.crowdsec_cache:delete("captcha_" .. ip) - end - end - -- check with appSec if the remediation component doesn't have decisions for the IP - -- OR - -- that user configured the remediation component to always check on the appSec (even if there is a decision for the IP) - if is_appsec_enabled() and (ok == true or is_always_send_to_appsec()) then - local appsecOk, appsecRemediation, status_code, appsec_resp, err = csmod.AppSecCheck(ip) - if err ~= nil then - ngx.log(ngx.ERR, "AppSec check: " .. err) - end - if appsecOk == false then - ok = false - remediationSource = flag.APPSEC_SOURCE - remediation = appsecRemediation - ret_code = status_code - appsec_response = appsec_resp + local _, cached_flags = ngx.shared.crowdsec_cache:get("captcha_" .. ip) + local cached_source = flag.GetFlags(cached_flags) + if cached_source ~= flag.APPSEC_SOURCE then + ngx.shared.crowdsec_cache:delete("captcha_" .. ip) + end end end local captcha_ok = runtime.cache:get("captcha_ok") - if runtime.fallback ~= "" then - -- if we can't use captcha, fallback - if remediation == "captcha" and captcha_ok == false then - remediation = runtime.fallback - end - - -- if remediation is not supported, fallback - if remediation ~= "captcha" and remediation ~= "ban" and remediation ~= "challenge" then - remediation = runtime.fallback - end - end - + -- if captcha can be used (configuration is valid), we check whether this request is + -- the submission of a captcha we are waiting for. this is done before querying the + -- appsec: the form is posted back to the URI that triggered the captcha, so letting + -- the appsec judge it again would just return the same remediation and we would + -- never get a chance to validate the answer. if captcha_ok then - -- if captcha can be used (configuration is valid) - -- we check if the IP needs to validate its captcha before checking it against CrowdSec local API local previous_uri, flags = ngx.shared.crowdsec_cache:get("captcha_" .. ip) local source, state_id, err = flag.GetFlags(flags) @@ -802,27 +785,24 @@ function csmod.Allow(ip) end if valid == true then - -- if the captcha is valid and has been applied by the application security component - -- then we delete the state from the cache because from the bouncing part, if the user solves the captcha - -- we will not propose a captcha until the 'CAPTCHA_EXPIRATION'. - -- But for the Application Security component, we serve the captcha each time the user triggers it. - if source == flag.APPSEC_SOURCE then - ngx.shared.crowdsec_cache:delete("captcha_" .. ip) - else - local succ, err, forcible = ngx.shared.crowdsec_cache:set( - "captcha_" .. ip, - previous_uri, - runtime.conf["CAPTCHA_EXPIRATION"], - bit.bor(flag.VALIDATED_STATE, source) - ) - - if not succ then - ngx.log(ngx.ERR, "failed to add key about captcha for ip '" .. ip .. "' in cache: " .. err) - end + -- the captcha has been solved: remember it for 'CAPTCHA_EXPIRATION' so we do + -- not ask for another one right away. this applies to both sources: an appsec + -- captcha is triggered by the request itself, so without this grace period the + -- redirect below would hit the very rule that asked for the captcha and we + -- would serve it again, forever. + local succ, err, forcible = ngx.shared.crowdsec_cache:set( + "captcha_" .. ip, + previous_uri, + runtime.conf["CAPTCHA_EXPIRATION"], + bit.bor(flag.VALIDATED_STATE, source) + ) + + if not succ then + ngx.log(ngx.ERR, "failed to add key about captcha for ip '" .. ip .. "' in cache: " .. err) + end - if forcible then - ngx.log(ngx.ERR, "Lua shared dict (crowdsec cache) is full, please increase dict size in config") - end + if forcible then + ngx.log(ngx.ERR, "Lua shared dict (crowdsec cache) is full, please increase dict size in config") end -- captcha is valid, we redirect the IP to its previous URI using the GET method @@ -835,6 +815,36 @@ function csmod.Allow(ip) end end end + + -- check with appSec if the remediation component doesn't have decisions for the IP + -- OR + -- that user configured the remediation component to always check on the appSec (even if there is a decision for the IP) + if is_appsec_enabled() and (ok == true or is_always_send_to_appsec()) then + local appsecOk, appsecRemediation, status_code, appsec_resp, err = csmod.AppSecCheck(ip) + if err ~= nil then + ngx.log(ngx.ERR, "AppSec check: " .. err) + end + if appsecOk == false then + ok = false + remediationSource = flag.APPSEC_SOURCE + remediation = appsecRemediation + ret_code = status_code + appsec_response = appsec_resp + end + end + + if runtime.fallback ~= "" then + -- if we can't use captcha, fallback + if remediation == "captcha" and captcha_ok == false then + remediation = runtime.fallback + end + + -- if remediation is not supported, fallback + if remediation ~= "captcha" and remediation ~= "ban" and remediation ~= "challenge" then + remediation = runtime.fallback + end + end + if not ok then if remediation == "ban" then ngx.log(ngx.ALERT, "[Crowdsec] denied '" .. ip .. "' with '"..remediation.."' (by " .. flag.Flags[remediationSource] .. ")") @@ -856,8 +866,11 @@ function csmod.Allow(ip) if remediation == "captcha" and captcha_ok and ngx.var.uri ~= "/favicon.ico" then local previous_uri, flags = ngx.shared.crowdsec_cache:get("captcha_"..ip) local source, state_id, err = flag.GetFlags(flags) - -- we check if the IP is already in cache for captcha and not yet validated - if previous_uri == nil or state_id ~= flag.VALIDATED_STATE or remediationSource == flag.APPSEC_SOURCE then + -- we check if the IP is already in cache for captcha and not yet validated. + -- a captcha solved for a LAPI decision does not grant a free pass on the appsec + -- (and the other way around), so a validated state only counts for the source + -- that asked for it. + if previous_uri == nil or state_id ~= flag.VALIDATED_STATE or source ~= remediationSource then local uri = ngx.var.uri -- in case its not a GET request, we prefer to fallback on referer if ngx.req.get_method() ~= "GET" then diff --git a/t/23_appsec_captcha.t b/t/23_appsec_captcha.t new file mode 100644 index 0000000..941a384 --- /dev/null +++ b/t/23_appsec_captcha.t @@ -0,0 +1,426 @@ +use Test::Nginx::Socket 'no_plan'; + +run_tests(); + +__DATA__ + +=== TEST 1: an AppSec 'captcha' can be solved: the answer is validated and the IP gets a grace period + +--- 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_appsec_captcha_crowdsec_nginx_bouncer.conf", "crowdsec-nginx-bouncer/v1.0.8") + if ok == nil then + ngx.log(ngx.ERR, "[Crowdsec] " .. err) + error() + end + -- stub the captcha provider: the test must not reach out to a real one + local captcha = require "plugins.crowdsec.captcha" + captcha.Validate = function(captcha_res, remote_ip) + return captcha_res == "valid-token", nil + end + ngx.log(ngx.ALERT, "[Crowdsec] Initialisation done") +} + +server { + listen 8081; + + location = /v1/decisions { + content_by_lua_block { + -- no local decision for the IP, so the request reaches AppSec + ngx.print('null') + } + } +} + +server { + listen 7422; + + location / { + content_by_lua_block { + local uri = ngx.req.get_headers()["x-crowdsec-appsec-uri"] or "" + ngx.status = 403 + if string.find(uri, "^/banned") ~= nil then + ngx.print([[{"action":"ban","http_status":403}]]) + else + -- the rule keeps matching on /t: this is what used to loop forever + ngx.print([[{"action":"captcha","http_status":403}]]) + end + } + } +} + + +--- config + + +location = /t { + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + access_by_lua_block { + local cs = require "crowdsec" + cs.Allow(ngx.var.remote_addr) + } + content_by_lua_block { + ngx.say("Hello, world") + } +} + +location = /banned { + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + access_by_lua_block { + local cs = require "crowdsec" + cs.Allow(ngx.var.remote_addr) + } + content_by_lua_block { + ngx.say("Hello, world") + } +} + +location = /peek { + content_by_lua_block { + local flag = require "plugins.crowdsec.flag" + local uri, flags = ngx.shared.crowdsec_cache:get("captcha_" .. ngx.var.arg_ip) + local source, state = flag.GetFlags(flags) + ngx.say(tostring(uri) .. "|" .. flag.Flags[source] .. "|" .. flag.Flags[state]) + } +} + +--- more_headers +X-Forwarded-For: 1.1.1.20 +Content-Type: application/x-www-form-urlencoded + +--- pipelined_requests eval +[ + "GET /t", + "GET /peek?ip=1.1.1.20", + "POST /t\ng-recaptcha-response=valid-token", + "GET /peek?ip=1.1.1.20", + "GET /t", + "GET /banned", +] + +--- error_code eval +[200, 200, 302, 200, 200, 403] + +--- response_body_like eval +[ + "CrowdSec Captcha", + "^/t\\|appsec\\|to_verify\$", + "302 Found", + "^/t\\|appsec\\|validated\$", + "^Hello, world\$", + "403 Forbidden", +] + + +=== TEST 2: a wrong answer to an AppSec 'captcha' serves the captcha again + +--- 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_appsec_captcha_crowdsec_nginx_bouncer.conf", "crowdsec-nginx-bouncer/v1.0.8") + if ok == nil then + ngx.log(ngx.ERR, "[Crowdsec] " .. err) + error() + end + local captcha = require "plugins.crowdsec.captcha" + captcha.Validate = function(captcha_res, remote_ip) + return captcha_res == "valid-token", nil + end + ngx.log(ngx.ALERT, "[Crowdsec] Initialisation done") +} + +server { + listen 8081; + + location = /v1/decisions { + content_by_lua_block { + ngx.print('null') + } + } +} + +server { + listen 7422; + + location / { + content_by_lua_block { + ngx.status = 403 + ngx.print([[{"action":"captcha","http_status":403}]]) + } + } +} + + +--- config + + +location = /t { + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + access_by_lua_block { + local cs = require "crowdsec" + cs.Allow(ngx.var.remote_addr) + } + content_by_lua_block { + ngx.say("Hello, world") + } +} + +location = /peek { + content_by_lua_block { + local flag = require "plugins.crowdsec.flag" + local uri, flags = ngx.shared.crowdsec_cache:get("captcha_" .. ngx.var.arg_ip) + local source, state = flag.GetFlags(flags) + ngx.say(tostring(uri) .. "|" .. flag.Flags[source] .. "|" .. flag.Flags[state]) + } +} + +--- more_headers +X-Forwarded-For: 1.1.1.21 +Content-Type: application/x-www-form-urlencoded + +--- pipelined_requests eval +[ + "GET /t", + "POST /t\ng-recaptcha-response=nope", + "GET /peek?ip=1.1.1.21", +] + +--- error_code eval +[200, 200, 200] + +--- response_body_like eval +[ + "CrowdSec Captcha", + "CrowdSec Captcha", + "^/t\\|appsec\\|to_verify\$", +] + + +=== TEST 3: a captcha solved for the AppSec does not satisfy a LAPI captcha decision + +--- 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_appsec_captcha_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") +} + +server { + listen 8081; + + location = /v1/decisions { + content_by_lua_block { + ngx.print('[{"duration":"1h00m00s","id":1,"origin":"cscli","scenario":"manual","scope":"Ip","type":"captcha","value":"1.1.1.22"}]') + } + } +} + + +--- config + + +location = /t { + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + access_by_lua_block { + local cs = require "crowdsec" + cs.Allow(ngx.var.remote_addr) + } + content_by_lua_block { + ngx.say("Hello, world") + } +} + +location = /seed { + content_by_lua_block { + local flag = require "plugins.crowdsec.flag" + local bit + if _VERSION == "Lua 5.1" then bit = require "bit" else bit = require "bit32" end + local source = flag.BOUNCER_SOURCE + if ngx.var.arg_source == "appsec" then + source = flag.APPSEC_SOURCE + end + ngx.shared.crowdsec_cache:set("captcha_" .. ngx.var.arg_ip, "/t", 3600, bit.bor(flag.VALIDATED_STATE, source)) + ngx.say("seeded") + } +} + +--- more_headers +X-Forwarded-For: 1.1.1.22 + +--- pipelined_requests eval +[ + "GET /seed?ip=1.1.1.22&source=appsec", + "GET /t", + "GET /seed?ip=1.1.1.22&source=bouncer", + "GET /t", +] + +--- error_code eval +[200, 200, 200, 200] + +--- response_body_like eval +[ + "^seeded\$", + "CrowdSec Captcha", + "^seeded\$", + "^Hello, world\$", +] + + +=== TEST 4: a captcha state coming from a LAPI decision is still dropped once the IP is allowed + +--- 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_appsec_captcha_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") +} + +server { + listen 8081; + + location = /v1/decisions { + content_by_lua_block { + ngx.print('null') + } + } +} + +server { + listen 7422; + + location / { + content_by_lua_block { + -- AppSec is happy with the request + ngx.status = 200 + ngx.print('') + } + } +} + + +--- config + + +location = /t { + set_real_ip_from 127.0.0.1; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + access_by_lua_block { + local cs = require "crowdsec" + cs.Allow(ngx.var.remote_addr) + } + content_by_lua_block { + ngx.say("Hello, world") + } +} + +location = /seed { + content_by_lua_block { + local flag = require "plugins.crowdsec.flag" + local bit + if _VERSION == "Lua 5.1" then bit = require "bit" else bit = require "bit32" end + local source = flag.BOUNCER_SOURCE + if ngx.var.arg_source == "appsec" then + source = flag.APPSEC_SOURCE + end + ngx.shared.crowdsec_cache:set("captcha_" .. ngx.var.arg_ip, "/t", 3600, bit.bor(flag.VALIDATED_STATE, source)) + ngx.say("seeded") + } +} + +location = /peek { + content_by_lua_block { + local flag = require "plugins.crowdsec.flag" + local uri, flags = ngx.shared.crowdsec_cache:get("captcha_" .. ngx.var.arg_ip) + local source, state = flag.GetFlags(flags) + ngx.say(tostring(uri) .. "|" .. flag.Flags[source] .. "|" .. flag.Flags[state]) + } +} + +--- more_headers +X-Forwarded-For: 1.1.1.23 + +--- pipelined_requests eval +[ + "GET /seed?ip=1.1.1.23&source=bouncer", + "GET /t", + "GET /peek?ip=1.1.1.23", + "GET /seed?ip=1.1.1.23&source=appsec", + "GET /t", + "GET /peek?ip=1.1.1.23", +] + +--- error_code eval +[200, 200, 200, 200, 200, 200] + +--- response_body_like eval +[ + "^seeded\$", + "^Hello, world\$", + "^nil\\|\\|\$", + "^seeded\$", + "^Hello, world\$", + "^/t\\|appsec\\|validated\$", +] diff --git a/t/conf_t/23_appsec_captcha_crowdsec_nginx_bouncer.conf b/t/conf_t/23_appsec_captcha_crowdsec_nginx_bouncer.conf new file mode 100644 index 0000000..2edc69f --- /dev/null +++ b/t/conf_t/23_appsec_captcha_crowdsec_nginx_bouncer.conf @@ -0,0 +1,20 @@ +APPSEC_URL=http://127.0.0.1:7422 +ENABLED=true +API_URL=http://127.0.0.1:8081 +API_KEY=test_key +CACHE_EXPIRATION=1 +BOUNCING_ON_TYPE=all +FALLBACK_REMEDIATION=ban +REQUEST_TIMEOUT=3000 +UPDATE_FREQUENCY=10 +MODE=live +EXCLUDE_LOCATION= +BAN_TEMPLATE_PATH=./ban +REDIRECT_LOCATION= +RET_CODE= +CAPTCHA_PROVIDER=recaptcha +SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe +SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +CAPTCHA_TEMPLATE_PATH=templates/captcha.html +CAPTCHA_EXPIRATION=3600 +APPSEC_FAILURE_ACTION=passthrough