Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
* [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 ```
5 changes: 3 additions & 2 deletions config_example.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
94 changes: 94 additions & 0 deletions lib/plugins/crowdsec/altcha.lua
Original file line number Diff line number Diff line change
@@ -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
23 changes: 18 additions & 5 deletions lib/plugins/crowdsec/captcha.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,40 @@ 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'}

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 = ""
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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions templates/altcha.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="page-title">Vérification de sécurité</title>
</head>
<body style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; font-family: sans-serif; text-align: center; background: #f4f6f8;">

<p id="msg-instruction">Veuillez valider la vérification ci-dessous pour accéder au site.</p>

<form id="captcha-form" method="POST" action="/">
<altcha-widget id="captcha-widget" challengeurl="/altcha-challenge" name="altcha-response" auto="onload"></altcha-widget>
<br><br>
<button type="submit" id="btn-submit" style="padding: 5px 15px;">Ok</button>
</form>

<!-- Script de traduction (Exécuté AVANT le chargement du widget) -->
<script>
// Détection fiable de la langue principale
const userLangs = navigator.languages || [navigator.language || ''];
const primaryLang = (userLangs[0] || '').toLowerCase();

// Est-ce que la PREMIÈRE langue préférée commence par 'fr' ?
const isFr = primaryLang.startsWith('fr');

// Met à jour la balise HTML lang pour être propre
document.documentElement.lang = isFr ? 'fr' : 'en';

// Mise à jour des textes
document.getElementById('page-title').textContent = isFr ? 'Vérification de sécurité' : 'Security verification';
document.getElementById('msg-instruction').textContent = isFr
? 'Veuillez valider la vérification ci-dessous pour accéder au site.'
: 'Please complete the verification below to access the site.';

if (!isFr) {
const widget = document.getElementById('captcha-widget');
if (widget) {
widget.setAttribute('data-label', "I'm not a robot");
widget.setAttribute('data-verifying', "Verifying...");
widget.setAttribute('data-verified', "Verified");
widget.setAttribute('data-error', "Verification failed");
widget.setAttribute('data-expired', "Expired");
}
}
</script>

<!-- Chargement d'ALTCHA -->
<script type="module" src="/altcha.js"></script>

<!-- Auto-submit -->
<script type="module">
const widget = document.getElementById('captcha-widget');
const form = document.getElementById('captcha-form');

if (widget) {
widget.addEventListener('statechange', (event) => {
if (event.detail && event.detail.state === 'verified') {
setTimeout(() => {
form.submit();
}, 300);
}
});
}
</script>
</body>
</html>
1 change: 1 addition & 0 deletions templates/altcha.min.js

Large diffs are not rendered by default.