diff --git a/lib/plugins/crowdsec/ban.lua b/lib/plugins/crowdsec/ban.lua index c631d18e..bf5de5e9 100644 --- a/lib/plugins/crowdsec/ban.lua +++ b/lib/plugins/crowdsec/ban.lua @@ -1,17 +1,27 @@ local utils = require "plugins.crowdsec.utils" - - +local template = require "plugins.crowdsec.template" + +---@class BanModule +---@field compiled_template CompiledTemplate? +---@field redirect_location string +---@field ret_code number +---@field new fun(template_path: string?, redirect_location: string?, ret_code: number?): string? +---@field apply fun(ret_code?: number, extra_vars?: table) local M = {_TYPE='module', _NAME='ban.funcs', _VERSION='1.0-0'} -M.template_str = "" +M.compiled_template = nil M.redirect_location = "" M.ret_code = ngx.HTTP_FORBIDDEN - +--- Initialize the ban module +---@param template_path string? Path to the ban template file +---@param redirect_location string? URL to redirect to instead of showing template +---@param ret_code number? HTTP status code to return +---@return string? error Error message if initialization failed function M.new(template_path, redirect_location, ret_code) - M.redirect_location = redirect_location + M.redirect_location = redirect_location or "" - ret_code_ok = false + 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 if k == ret_code then @@ -25,11 +35,16 @@ function M.new(template_path, redirect_location, ret_code) end end - template_file_ok = false + local template_file_ok = false if (template_path ~= nil and template_path ~= "" and utils.file_exist(template_path) == true) then - M.template_str = utils.read_file(template_path) - if M.template_str ~= nil then - template_file_ok = true + local template_str = utils.read_file(template_path) + if template_str ~= nil then + -- Precompile template at init time for faster rendering + M.compiled_template = template.precompile(template_str) + if M.compiled_template ~= nil then + template_file_ok = true + ngx.log(ngx.DEBUG, "Ban template precompiled successfully") + end end end @@ -40,38 +55,34 @@ function M.new(template_path, redirect_location, ret_code) return nil end +--- Apply the ban remediation +---@param ret_code? number Optional HTTP status code override +---@param extra_vars? table Optional additional template variables +function M.apply(ret_code, extra_vars) + ngx.log(ngx.DEBUG, "args:" .. tostring(ret_code)) + local status = ret_code or M.ret_code -function M.apply(...) - local args = {...} - local ret_code = args[1] - - ngx.log(ngx.DEBUG, "args:" .. tostring(args[1])) - - local status = 0 - if ret_code ~= nil then - status = ret_code - else - status = M.ret_code - end - - ngx.log(ngx.DEBUG, "BAN: status=" .. status .. ", redirect_location=" .. M.redirect_location .. ", template_str=" .. M.template_str) + ngx.log(ngx.DEBUG, "BAN: status=" .. status .. ", redirect_location=" .. M.redirect_location) if M.redirect_location ~= "" then ngx.redirect(M.redirect_location) return end - if M.template_str ~= "" then + if M.compiled_template ~= nil then ngx.header.content_type = "text/html" ngx.header.cache_control = "no-cache" ngx.status = status - ngx.say(M.template_str) + + -- Render precompiled template with request-specific variables + local template_vars = template.get_request_vars(extra_vars) + local rendered = template.render(M.compiled_template, template_vars) + + ngx.say(rendered) ngx.exit(status) return end - - ngx.exit(status) - return + ngx.exit(status) end return M diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 81d54346..cbd9ea07 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -3,28 +3,54 @@ local cjson = require "cjson" local template = require "plugins.crowdsec.template" local utils = require "plugins.crowdsec.utils" +---@class CaptchaModule +---@field SecretKey string +---@field SiteKey string +---@field CaptchaProvider string +---@field compiled_template CompiledTemplate? +---@field static_vars table +---@field ret_code number +---@field New fun(siteKey: string?, secretKey: string?, TemplateFilePath: string?, captcha_provider: string?, ret_code: number?): string? +---@field apply fun() +---@field GetCaptchaBackendKey fun(): string +---@field Validate fun(captcha_res: string, remote_ip: string): boolean, string? 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" - -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" - -local captcha_frontend_key = {} -captcha_frontend_key["recaptcha"] = "g-recaptcha" -captcha_frontend_key["hcaptcha"] = "h-captcha" -captcha_frontend_key["turnstile"] = "cf-turnstile" +---@type table +local captcha_backend_url = { + ["recaptcha"] = "https://www.recaptcha.net/recaptcha/api/siteverify", + ["hcaptcha"] = "https://hcaptcha.com/siteverify", + ["turnstile"] = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +} + +---@type table +local captcha_frontend_js = { + ["recaptcha"] = "https://www.recaptcha.net/recaptcha/api.js", + ["hcaptcha"] = "https://js.hcaptcha.com/1/api.js", + ["turnstile"] = "https://challenges.cloudflare.com/turnstile/v0/api.js" +} + +---@type table +local captcha_frontend_key = { + ["recaptcha"] = "g-recaptcha", + ["hcaptcha"] = "h-captcha", + ["turnstile"] = "cf-turnstile" +} M.SecretKey = "" M.SiteKey = "" -M.Template = "" +M.CaptchaProvider = "" +M.compiled_template = nil +M.static_vars = {} M.ret_code = ngx.HTTP_OK +--- Initialize the captcha module +---@param siteKey string? Public site key from captcha provider +---@param secretKey string? Secret key from captcha provider +---@param TemplateFilePath string? Path to the captcha template file +---@param captcha_provider string? Provider name (recaptcha, hcaptcha, turnstile) +---@param ret_code number? HTTP status code to return +---@return string? error Error message if initialization failed function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) if siteKey == nil or siteKey == "" then @@ -50,7 +76,7 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) return "Template file " .. TemplateFilePath .. "not found." end - M.CaptchaProvider = captcha_provider + M.CaptchaProvider = captcha_provider or "recaptcha" local ret_code_ok = false if ret_code ~= nil and ret_code ~= 0 and ret_code ~= "" then @@ -66,34 +92,60 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) end end - local template_data = {} - 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) - M.Template = view + -- Store static captcha variables + M.static_vars = { + captcha_site_key = M.SiteKey, + captcha_frontend_js = captcha_frontend_js[M.CaptchaProvider], + captcha_frontend_key = captcha_frontend_key[M.CaptchaProvider] + } + + -- Precompile template at init time + M.compiled_template = template.precompile(captcha_template) + if M.compiled_template ~= nil then + ngx.log(ngx.DEBUG, "Captcha template precompiled successfully") + end return nil end +--- Apply the captcha remediation (show captcha page) function M.apply() ngx.header.content_type = "text/html" ngx.header.cache_control = "no-cache" ngx.status = M.ret_code - ngx.say(M.Template) + + if M.compiled_template ~= nil then + -- Use shared helper, pass static captcha vars as extras + local template_vars = template.get_request_vars(M.static_vars) + local rendered = template.render(M.compiled_template, template_vars) + ngx.say(rendered) + end + ngx.exit(M.ret_code) end +--- Get the form field name for the captcha response +---@return string key The form field name function M.GetCaptchaBackendKey() return captcha_frontend_key[M.CaptchaProvider] .. "-response" end -function table_to_encoded_url(args) +--- Convert a table to URL-encoded form data +---@param args table +---@return string encoded URL-encoded string +local function table_to_encoded_url(args) local params = {} - for k, v in pairs(args) do table.insert(params, k .. '=' .. v) end + for k, v in pairs(args) do + table.insert(params, k .. '=' .. v) + end return table.concat(params, "&") end +--- Validate a captcha response with the provider +---@param captcha_res string The captcha response token from the form +---@param remote_ip string The client's IP address +---@return boolean success Whether the captcha was valid +---@return string? error Error message if validation failed function M.Validate(captcha_res, remote_ip) local body = { secret = M.SecretKey, @@ -124,7 +176,7 @@ function M.Validate(captcha_res, remote_ip) ngx.log(ngx.ERR, "reCaptcha secret key is invalid") return true, nil end - end + end end return result.success, nil diff --git a/lib/plugins/crowdsec/template.lua b/lib/plugins/crowdsec/template.lua index 535abb28..218e6a35 100644 --- a/lib/plugins/crowdsec/template.lua +++ b/lib/plugins/crowdsec/template.lua @@ -1,13 +1,289 @@ +---@class Template +---@field get_request_vars fun(extra_vars?: table): table +---@field precompile fun(template_str: string): CompiledTemplate? +---@field render fun(compiled: CompiledTemplate, args?: table): string +---@field compile fun(template_str: string, args?: table): string local template = {} +---@class CompiledTemplate +---@field segments Segment[] +---@field raw string + +---@class Segment +---@field type "text"|"var"|"cond" +---@field content? string -- for type="text" +---@field name? string -- for type="var" +---@field var? string -- for type="cond" +---@field negated? boolean -- for type="cond" +---@field if_branch? Segment[] -- for type="cond" +---@field else_branch? Segment[] -- for type="cond" + +--- Gather template variables from the current request context +--- Can be extended with extra_vars table +---@param extra_vars? table Additional variables to merge +---@return table vars All template variables +function template.get_request_vars(extra_vars) + local vars = { + -- Request identification (requires nginx request_id module) + request_id = ngx.var.request_id or "", + + -- Client information + client_ip = ngx.var.remote_addr or "", + client_port = ngx.var.remote_port or "", + + -- Request details + request_uri = ngx.var.request_uri or "", + request_method = ngx.var.request_method or "", + host = ngx.var.host or "", + server_name = ngx.var.server_name or "", + scheme = ngx.var.scheme or "", + + -- User agent and headers + user_agent = ngx.var.http_user_agent or "", + referer = ngx.var.http_referer or "", + + -- Timing + timestamp = os.date("%Y-%m-%d %H:%M:%S"), + timestamp_iso = os.date("!%Y-%m-%dT%H:%M:%SZ"), + timestamp_unix = tostring(os.time()), + + -- Server info + server_addr = ngx.var.server_addr or "", + server_port = ngx.var.server_port or "", + } + + -- Merge in any extra variables passed + if extra_vars then + for k, v in pairs(extra_vars) do + vars[k] = v + end + end + + return vars +end + +--- Check if a value is truthy +---@param value any +---@return boolean +local function is_truthy(value) + if value == nil then return false end + if type(value) == "boolean" then return value end + if type(value) == "string" then return value ~= "" end + if type(value) == "number" then return value ~= 0 end + if type(value) == "table" then return next(value) ~= nil end + return true +end + +--- Escape special characters in a value for use in gsub replacement +---@param str any +---@return string +local function escape_replacement(str) + if type(str) ~= "string" then + str = tostring(str) + end + -- Escape % which is special in Lua replacement strings + return str:gsub("%%", "%%%%") +end + +--- Escape special characters in pattern for literal matching +---@param str string +---@return string +local function escape_pattern(str) + return str:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + +--- Parse template into segments for fast rendering +---@param template_str string +---@return Segment[] +local function parse_template(template_str) + local segments = {} + local pos = 1 + local len = #template_str + + while pos <= len do + -- Look for next {{ + local start_pos = template_str:find("{{", pos, true) + + if not start_pos then + -- No more placeholders, add remaining text + if pos <= len then + table.insert(segments, {type = "text", content = template_str:sub(pos)}) + end + break + end + + -- Add text before the placeholder + if start_pos > pos then + table.insert(segments, {type = "text", content = template_str:sub(pos, start_pos - 1)}) + end + + -- Check if this is a conditional by looking at substring from start_pos + local substring = template_str:sub(start_pos) + local cond_match = substring:match("^{{#if%s+([^}]+)}}") + if cond_match then + -- Find matching {{/if}} + local cond_open_end = template_str:find("}}", start_pos, true) + 2 + local depth = 1 + local search_pos = cond_open_end + + while depth > 0 and search_pos <= len do + local next_if = template_str:find("{{#if", search_pos, true) + local next_endif = template_str:find("{{/if}}", search_pos, true) + + if not next_endif then + -- Malformed template, treat rest as text + break + end + + if next_if and next_if < next_endif then + depth = depth + 1 + search_pos = next_if + 5 + else + depth = depth - 1 + if depth == 0 then + -- Found matching endif + local inner_content = template_str:sub(cond_open_end, next_endif - 1) + local var_name = cond_match:match("^%s*(.-)%s*$") + local negated = false + + if var_name:sub(1, 1) == "!" then + negated = true + var_name = var_name:sub(2):match("^%s*(.-)%s*$") + end + + -- Split by {{else}} + local if_content, else_content = inner_content:match("^(.-){{else}}(.*)$") + if not if_content then + if_content = inner_content + else_content = "" + end + + -- Recursively parse the if and else branches + table.insert(segments, { + type = "cond", + var = var_name, + negated = negated, + if_branch = parse_template(if_content), + else_branch = parse_template(else_content) + }) + + pos = next_endif + 7 -- skip past {{/if}} + break + end + search_pos = next_endif + 7 + end + end + + if depth > 0 then + -- Malformed, add as text + table.insert(segments, {type = "text", content = template_str:sub(start_pos, cond_open_end - 1)}) + pos = cond_open_end + end + else + -- Regular variable {{var}} + local end_pos = template_str:find("}}", start_pos, true) + if end_pos then + local var_content = template_str:sub(start_pos + 2, end_pos - 1) + -- Skip special markers like {{else}}, {{/if}} + if var_content:match("^/") or var_content == "else" then + table.insert(segments, {type = "text", content = template_str:sub(start_pos, end_pos + 1)}) + else + table.insert(segments, {type = "var", name = var_content}) + end + pos = end_pos + 2 + else + -- No closing }}, add as text + table.insert(segments, {type = "text", content = template_str:sub(start_pos)}) + break + end + end + end + + return segments +end + +--- Render parsed segments with given variables +---@param segments Segment[] +---@param args table +---@return string +local function render_segments(segments, args) + local result = {} + + for _, segment in ipairs(segments) do + if segment.type == "text" then + table.insert(result, segment.content) + elseif segment.type == "var" then + local value = args[segment.name] + if value ~= nil then + table.insert(result, tostring(value)) + else + -- Keep placeholder if variable not provided + table.insert(result, "{{" .. segment.name .. "}}") + end + elseif segment.type == "cond" then + local value = args[segment.var] + local condition_met = is_truthy(value) + + if segment.negated then + condition_met = not condition_met + end + + if condition_met then + table.insert(result, render_segments(segment.if_branch, args)) + else + table.insert(result, render_segments(segment.else_branch, args)) + end + end + end + + return table.concat(result) +end + +--- Precompile a template string into a parsed structure +--- Call this once at init time +---@param template_str string +---@return CompiledTemplate? +function template.precompile(template_str) + if template_str == nil or template_str == "" then + return nil + end + return { + segments = parse_template(template_str), + raw = template_str + } +end + +--- Render a precompiled template with variables +--- Call this at request time for fast rendering +---@param compiled CompiledTemplate +---@param args? table +---@return string +function template.render(compiled, args) + if compiled == nil then + return "" + end + if args == nil then + args = {} + end + return render_segments(compiled.segments, args) +end + +--- Original compile function for backward compatibility +--- Parses and renders in one step (less efficient for repeated use) +---@param template_str string +---@param args? table +---@return string function template.compile(template_str, args) + if args == nil then + args = {} + end - for k, v in pairs(args) do - local var = "{{" .. k .. "}}" - template_str = template_str:gsub(var, v) + local compiled = template.precompile(template_str) + if compiled == nil then + return template_str or "" end - return template_str + return template.render(compiled, args) end -return template \ No newline at end of file +return template diff --git a/t/18_template_expansion.t b/t/18_template_expansion.t new file mode 100644 index 00000000..7fb967df --- /dev/null +++ b/t/18_template_expansion.t @@ -0,0 +1,81 @@ +use Test::Nginx::Socket 'no_plan'; + +run_tests(); + +__DATA__ + +=== TEST 18: Test template variable expansion on ban + +--- 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/18_template_test_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) + if ngx.var.unix == "1" then + ngx.log(ngx.DEBUG, "[Crowdsec] Unix socket request ignoring...") + else + cs.Allow(ngx.var.remote_addr) + end +} + +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.print('null') + 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.say("Hello, world") + } +} + +--- more_headers +X-Forwarded-For: 1.1.1.1 +--- request +GET /t +--- response_body_like +Banned +IP: 1\.1\.1\.1 +ID: [a-f0-9-]+ +Time: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} +No custom message +Details hidden + +--- error_code: 403 diff --git a/t/conf_t/18_template_test_crowdsec_nginx_bouncer.conf b/t/conf_t/18_template_test_crowdsec_nginx_bouncer.conf new file mode 100644 index 00000000..ec312280 --- /dev/null +++ b/t/conf_t/18_template_test_crowdsec_nginx_bouncer.conf @@ -0,0 +1,19 @@ +APPSEC_URL=http://127.0.0.1:7422 +ENABLED=true +API_URL=http://127.0.0.1:8081 +API_KEY=LFrdL+aiecMTSxpGE9vLkx5sGMwdIpgVovpVMfXp3J0 +CACHE_EXPIRATION=1 +BOUNCING_ON_TYPE=all +FALLBACK_REMEDIATION=ban +REQUEST_TIMEOUT=3000 +UPDATE_FREQUENCY=10 +MODE=live +EXCLUDE_LOCATION=/v1/decisions +BAN_TEMPLATE_PATH=./t/conf_t/ban_template_test +REDIRECT_LOCATION= +RET_CODE= +CAPTCHA_PROVIDER= +SECRET_KEY= +SITE_KEY= +CAPTCHA_TEMPLATE_PATH=/var/lib/crowdsec/lua/templates/captcha.html +CAPTCHA_EXPIRATION=3600 diff --git a/t/conf_t/ban_template_test b/t/conf_t/ban_template_test new file mode 100644 index 00000000..b1e8974e --- /dev/null +++ b/t/conf_t/ban_template_test @@ -0,0 +1,6 @@ +Banned +IP: {{client_ip}} +ID: {{request_id}} +{{#if timestamp}}Time: {{timestamp}}{{/if}} +{{#if custom_message}}{{custom_message}}{{else}}No custom message{{/if}} +{{#if !show_details}}Details hidden{{/if}} diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 00000000..73551c88 --- /dev/null +++ b/templates/README.md @@ -0,0 +1,102 @@ +# Template System + +The ban and captcha templates support dynamic variable substitution and conditional logic. + +## Available Variables + +The following variables are automatically available in ban templates: + +| Variable | Description | +|----------|-------------| +| `{{client_ip}}` | Client's IP address | +| `{{client_port}}` | Client's port | +| `{{request_id}}` | Unique request identifier | +| `{{request_uri}}` | Requested URI path | +| `{{request_method}}` | HTTP method (GET, POST, etc.) | +| `{{host}}` | Host header value | +| `{{server_name}}` | Server name | +| `{{scheme}}` | Request scheme (http/https) | +| `{{user_agent}}` | Client's User-Agent header | +| `{{referer}}` | Referer header | +| `{{timestamp}}` | Human-readable timestamp (YYYY-MM-DD HH:MM:SS) | +| `{{timestamp_iso}}` | ISO 8601 timestamp | +| `{{timestamp_unix}}` | Unix timestamp | +| `{{server_addr}}` | Server IP address | +| `{{server_port}}` | Server port | + +## Conditional Logic + +Templates support if/else conditionals: + +```html +{{#if variable}} + Content shown when variable is truthy +{{else}} + Content shown when variable is falsy +{{/if}} +``` + +The `{{else}}` block is optional: + +```html +{{#if client_ip}} + Your IP: {{client_ip}} +{{/if}} +``` + +### Negation + +Use `!` to negate a condition: + +```html +{{#if !user_agent}} + No User-Agent provided +{{/if}} +``` + +### Truthy Values + +A value is considered truthy if it is: +- A non-empty string +- A non-zero number +- A boolean `true` +- A non-empty table + +## Example Ban Template + +```html + + + + Access Denied + + +

Access Forbidden

+

Your request has been blocked.

+ +
+

Request ID: {{request_id}}

+ {{#if client_ip}} +

Your IP: {{client_ip}}

+ {{/if}} +

Time: {{timestamp}}

+
+ + {{#if custom_message}} +
{{custom_message}}
+ {{else}} +

Please contact the administrator if you believe this is an error.

+ {{/if}} + + +``` + +## Captcha Template Variables + +The captcha template uses these additional variables: + +| Variable | Description | +|----------|-------------| +| `{{captcha_site_key}}` | Public site key for captcha provider | +| `{{captcha_frontend_js}}` | JavaScript URL for captcha provider | +| `{{captcha_frontend_key}}` | CSS class for captcha container |