diff --git a/README.md b/README.md index 79821ff2..4bb61446 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,21 @@ This library is used by different bouncers : * [Nginx Bouncer](https://docs.crowdsec.net/docs/next/bouncers/nginx) * [OpenResty Bouncer](https://docs.crowdsec.net/docs/next/bouncers/openresty) -* [Ingress Nginx Bouncer](https://docs.crowdsec.net/docs/next/bouncers/ingress-nginx) \ No newline at end of file +* [Ingress Nginx Bouncer](https://docs.crowdsec.net/docs/next/bouncers/ingress-nginx) + +### ALTCHA Assets Setup +Vendor Dependency +This provider requires the ALTCHA JavaScript widget file. + +```bash +wget https://raw.githubusercontent.com/altcha-org/altcha/refs/heads/main/dist/main/altcha.min.js -O templates/altcha.min.js``` + + +Configuration needed : +CAPTCHA_PROVIDER=altcha +CAPTCHA_TEMPLATE_PATH=/var/lib/crowdsec/lua/templates/altcha.html +CAPTCHA_EXPIRATION=432000 + +Then you need to generate a random key et set SECRET_KEY var +Example : +```openssl rand -hex 32 ``` \ No newline at end of file diff --git a/config_example.conf b/config_example.conf index ec83c401..b560a7f8 100644 --- a/config_example.conf +++ b/config_example.conf @@ -30,12 +30,13 @@ 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, altcha CAPTCHA_PROVIDER= -# Captcha Secret Key +# Captcha Secret Key / or altcha long random string SECRET_KEY= # Captcha Site key SITE_KEY= +# If altcha selected setup to /var/lib/crowdsec/lua/templates/altcha.html CAPTCHA_TEMPLATE_PATH=/var/lib/crowdsec/lua/templates/captcha.html CAPTCHA_EXPIRATION=3600 diff --git a/lib/plugins/crowdsec/altcha.lua b/lib/plugins/crowdsec/altcha.lua new file mode 100644 index 00000000..374db73e --- /dev/null +++ b/lib/plugins/crowdsec/altcha.lua @@ -0,0 +1,94 @@ +local ffi = require("ffi") + +ffi.cdef[[ + typedef struct engine_st ENGINE; + typedef struct evp_md_st EVP_MD; + typedef struct evp_md_ctx_st EVP_MD_CTX; + + const EVP_MD *EVP_sha256(void); + unsigned char *HMAC(const EVP_MD *evp_md, const void *key, int key_len, + const unsigned char *d, size_t n, unsigned char *md, + unsigned int *md_len); + unsigned char *SHA256(const unsigned char *d, size_t n, unsigned char *md); +]] + +local M = {} + +-- Helper conversion Binaire -> Hexadécimal +local function to_hex(raw_bytes, len) + local hex = {} + for i = 0, len - 1 do + table.insert(hex, string.format("%02x", raw_bytes[i])) + end + return table.concat(hex) +end + +-- SHA256 FFI direct OpenSSL +local function sha256_hex(data) + local md = ffi.new("unsigned char[32]") + ffi.C.SHA256(data, #data, md) + return to_hex(md, 32) +end + +-- HMAC-SHA256 FFI direct OpenSSL +local function hmac_sha256(key, data) + local md = ffi.new("unsigned char[32]") + local md_len = ffi.new("unsigned int[1]") + local evp_sha256 = ffi.C.EVP_sha256() + + ffi.C.HMAC(evp_sha256, key, #key, data, #data, md, md_len) + return to_hex(md, 32) +end + +-- 1. Création du challenge +function M.create_challenge(secret, max_number) + max_number = max_number or 50000 + local raw_salt = ngx.encode_base64(ngx.time() .. math.random(1000, 9999)) + local salt = sha256_hex(raw_salt):sub(1, 16) + + local number = math.random(0, max_number) + local challenge_str = salt .. tostring(number) + local challenge = sha256_hex(challenge_str) + + local signature = hmac_sha256(secret, challenge) + + return { + algorithm = "SHA-256", + challenge = challenge, + salt = salt, + signature = signature, + maxnumber = max_number + } +end + +-- 2. Vérification du payload +function M.verify_payload(secret, payload_base64) + if not payload_base64 or payload_base64 == "" then + return false, "payload missing" + end + + local decoded = ngx.decode_base64(payload_base64) + if not decoded then + return false, "invalid base64" + end + + local cjson = require("cjson") + local ok, data = pcall(cjson.decode, decoded) + if not ok or type(data) ~= "table" then + return false, "invalid json payload" + end + + local expected_signature = hmac_sha256(secret, data.challenge) + if expected_signature ~= data.signature then + return false, "invalid signature" + end + + local computed_hash = sha256_hex(data.salt .. tostring(data.number)) + if computed_hash ~= data.challenge then + return false, "invalid pow solution" + end + + return true, nil +end + +return M diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 81d54346..ca1bd296 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -2,6 +2,7 @@ local http = require "resty.http" local cjson = require "cjson" local template = require "plugins.crowdsec.template" local utils = require "plugins.crowdsec.utils" +local altcha = require "plugins.crowdsec.altcha" local M = {_TYPE='module', _NAME='recaptcha.funcs', _VERSION='1.0-0'} @@ -9,16 +10,19 @@ 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" +captcha_backend_url["altcha"] = "" 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" +captcha_frontend_js["altcha"] = "" local captcha_frontend_key = {} captcha_frontend_key["recaptcha"] = "g-recaptcha" captcha_frontend_key["hcaptcha"] = "h-captcha" captcha_frontend_key["turnstile"] = "cf-turnstile" +captcha_frontend_key["altcha"] = "altcha" M.SecretKey = "" M.SiteKey = "" @@ -26,12 +30,12 @@ M.Template = "" M.ret_code = ngx.HTTP_OK function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) + M.CaptchaProvider = captcha_provider - if siteKey == nil or siteKey == "" then + if (siteKey == nil or siteKey == "") and M.CaptchaProvider ~= "altcha" then return "no recaptcha site key provided, can't use recaptcha" end M.SiteKey = siteKey - if secretKey == nil or secretKey == "" then return "no recaptcha secret key provided, can't use recaptcha" end @@ -50,8 +54,6 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) return "Template file " .. TemplateFilePath .. "not found." end - M.CaptchaProvider = captcha_provider - 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,7 +69,7 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) end local template_data = {} - template_data["captcha_site_key"] = M.SiteKey + 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] local view = template.compile(captcha_template, template_data) @@ -95,6 +97,17 @@ function table_to_encoded_url(args) end function M.Validate(captcha_res, remote_ip) + if M.CaptchaProvider == "altcha" then + local unescaped_res = ngx.unescape_uri(captcha_res) + local ok, err = altcha.verify_payload(M.SecretKey, unescaped_res) + if not ok then + ngx.log(ngx.ERR, "ALTCHA validation failed : ", err) + return false, err + end + return true, nil + end + + local body = { secret = M.SecretKey, response = captcha_res, diff --git a/templates/altcha.html b/templates/altcha.html new file mode 100644 index 00000000..c22266e7 --- /dev/null +++ b/templates/altcha.html @@ -0,0 +1,67 @@ + + +
+ + +Veuillez valider la vérification ci-dessous pour accéder au site.
+ + + + + + + + + + + + + diff --git a/templates/altcha.min.js b/templates/altcha.min.js new file mode 100644 index 00000000..a63b8157 --- /dev/null +++ b/templates/altcha.min.js @@ -0,0 +1 @@ +const e=!1;var t=Array.isArray,n=Array.prototype.indexOf,r=Array.prototype.includes,a=Array.from,o=Object.keys,i=Object.defineProperty,l=Object.getOwnPropertyDescriptor,s=Object.getOwnPropertyDescriptors,c=Object.prototype,u=Array.prototype,f=Object.getPrototypeOf,d=Object.isExtensible;const h=()=>{};function p(){var e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}const v=1<<24,g=16,m=32,b=64,y=512,w=1024,x=2048,k=4096,_=8192,$=16384,S=32768,C=1<<25,E=65536,T=1<<17,A=1<<19,I=65536,P=1<<21,O=1<<23,R=Symbol("$state"),L=Symbol("legacy props"),M=Symbol(""),D=Symbol("attributes"),U=Symbol("class"),V=Symbol("style"),N=Symbol("text"),z=Symbol("form reset"),j=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"},B=!!globalThis.document?.contentType&&globalThis.document.contentType.includes("xml");function F(e){return e===this.v}function H(e,t){return e!=e?t==t:e!==t||null!==e&&"object"==typeof e||"function"==typeof e}function K(e){return!H(e,this.v)}const q={},Y=Symbol("uninitialized"),G="http://www.w3.org/1999/xhtml";let W=null;function J(e){W=e}function Z(e,t=!1,n){W={p:W,i:!1,c:null,e:null,s:e,x:null,r:jt,l:null}}function X(e){var t=W,n=t.e;if(null!==n)for(var r of(t.e=null,n))gn(r);return void 0!==e&&(t.x=e),t.i=!0,W=t.p,e??{}}let Q=[];function ee(){var e=Q;Q=[],function(e){for(var t=0;t