From 41cdea071f19c6b6574274418495739996ba9d3c Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 19 Jan 2026 10:41:02 +0000 Subject: [PATCH 1/4] feat(template): add dynamic variables and conditionals to ban templates - Add if/else conditional support: {{#if var}}...{{else}}...{{/if}} - Add negation support: {{#if !var}}...{{/if}} - Add dynamic variables: client_ip, request_id, timestamp, etc. - Add template documentation in templates/README.md Co-Authored-By: Claude Opus 4.5 --- lib/plugins/crowdsec/ban.lua | 65 ++++++++++- lib/plugins/crowdsec/template.lua | 104 +++++++++++++++++- t/18_template_expansion.t | 81 ++++++++++++++ ..._template_test_crowdsec_nginx_bouncer.conf | 19 ++++ t/conf_t/ban_template_test | 6 + templates/README.md | 102 +++++++++++++++++ 6 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 t/18_template_expansion.t create mode 100644 t/conf_t/18_template_test_crowdsec_nginx_bouncer.conf create mode 100644 t/conf_t/ban_template_test create mode 100644 templates/README.md diff --git a/lib/plugins/crowdsec/ban.lua b/lib/plugins/crowdsec/ban.lua index c631d18e..e35e136d 100644 --- a/lib/plugins/crowdsec/ban.lua +++ b/lib/plugins/crowdsec/ban.lua @@ -1,4 +1,5 @@ local utils = require "plugins.crowdsec.utils" +local template = require "plugins.crowdsec.template" local M = {_TYPE='module', _NAME='ban.funcs', _VERSION='1.0-0'} @@ -41,10 +42,65 @@ function M.new(template_path, redirect_location, ret_code) end +-- Generate a unique request ID +local function generate_request_id() + -- Use ngx.var.request_id if available (OpenResty 1.11.2+) + if ngx.var.request_id then + return ngx.var.request_id + end + -- Fallback: generate a simple unique ID + local random_part = string.format("%08x", math.random(0, 0xFFFFFFFF)) + local time_part = string.format("%08x", ngx.now() * 1000) + return time_part .. "-" .. random_part +end + + +-- Gather template variables from the current request context +local function get_template_vars(extra_vars) + local vars = { + -- Request identification + request_id = generate_request_id(), + + -- 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 (e.g., from CrowdSec decision) + if extra_vars then + for k, v in pairs(extra_vars) do + vars[k] = v + end + end + + return vars +end + function M.apply(...) local args = {...} local ret_code = args[1] + local extra_vars = args[2] -- Optional table of additional template variables ngx.log(ngx.DEBUG, "args:" .. tostring(args[1])) @@ -64,11 +120,16 @@ function M.apply(...) ngx.header.content_type = "text/html" ngx.header.cache_control = "no-cache" ngx.status = status - ngx.say(M.template_str) + + -- Compile template with request-specific variables + local template_vars = get_template_vars(extra_vars) + local compiled = template.compile(M.template_str, template_vars) + + ngx.say(compiled) ngx.exit(status) return end - + ngx.exit(status) return diff --git a/lib/plugins/crowdsec/template.lua b/lib/plugins/crowdsec/template.lua index 535abb28..66be5be7 100644 --- a/lib/plugins/crowdsec/template.lua +++ b/lib/plugins/crowdsec/template.lua @@ -1,13 +1,113 @@ local template = {} +-- Helper function to check if a value is truthy +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 + +-- Process if/else conditionals +-- Syntax: {{#if variable}}content{{else}}other content{{/if}} +-- Supports negation: {{#if !variable}}content{{/if}} +-- The else block is optional +local function process_conditionals(template_str, args) + -- Pattern to match {{#if var}}...{{else}}...{{/if}} or {{#if var}}...{{/if}} + -- Using a loop to handle nested conditionals from innermost out + local max_iterations = 100 -- Safety limit to prevent infinite loops + local iteration = 0 + + while iteration < max_iterations do + iteration = iteration + 1 + + -- Find the innermost if block (one that doesn't contain another #if) + -- Match {{#if ...}}...{{/if}} where the content doesn't contain {{#if + local found = false + + template_str = template_str:gsub( + "{{#if%s+([^}]+)}}(.-){{\\/if}}", + function(condition, content) + -- Check if this block contains a nested #if - if so, skip it for now + if content:match("{{#if") then + return "{{#if " .. condition .. "}}" .. content .. "{{/if}}" + end + + found = true + local negated = false + local var_name = condition:match("^%s*(.-)%s*$") -- trim whitespace + + -- Check for negation + if var_name:sub(1, 1) == "!" then + negated = true + var_name = var_name:sub(2):match("^%s*(.-)%s*$") -- trim again + end + + -- Split content by {{else}} + local if_content, else_content = content:match("^(.-){{else}}(.*)$") + if not if_content then + if_content = content + else_content = "" + end + + -- Evaluate condition + local value = args[var_name] + local condition_met = is_truthy(value) + + if negated then + condition_met = not condition_met + end + + if condition_met then + return if_content + else + return else_content + end + end + ) + + -- If no substitution was made, we're done + if not found then + break + end + end + + return template_str +end + +-- Escape special characters in a value for use in gsub replacement +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 +local function escape_pattern(str) + return str:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + function template.compile(template_str, args) + if args == nil then + args = {} + end + + -- First process conditionals + template_str = process_conditionals(template_str, args) + -- Then do variable substitution for k, v in pairs(args) do local var = "{{" .. k .. "}}" - template_str = template_str:gsub(var, v) + local escaped_var = escape_pattern(var) + local escaped_value = escape_replacement(v) + template_str = template_str:gsub(escaped_var, escaped_value) end return template_str 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 | From cf65758e7eae120293f339b42be27b3195e7a4c6 Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 19 Jan 2026 10:52:45 +0000 Subject: [PATCH 2/4] refactor(template): precompile templates at init time - Parse template structure once at startup - Only variable substitution happens at request time - Improves performance for ban page rendering Co-Authored-By: Claude Opus 4.5 --- lib/plugins/crowdsec/ban.lua | 27 ++-- lib/plugins/crowdsec/template.lua | 236 +++++++++++++++++++++--------- 2 files changed, 182 insertions(+), 81 deletions(-) diff --git a/lib/plugins/crowdsec/ban.lua b/lib/plugins/crowdsec/ban.lua index e35e136d..7fe6ec3b 100644 --- a/lib/plugins/crowdsec/ban.lua +++ b/lib/plugins/crowdsec/ban.lua @@ -4,7 +4,7 @@ local template = require "plugins.crowdsec.template" 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 @@ -12,7 +12,7 @@ M.ret_code = ngx.HTTP_FORBIDDEN function M.new(template_path, redirect_location, ret_code) M.redirect_location = redirect_location - 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 @@ -26,11 +26,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 @@ -111,21 +116,21 @@ function M.apply(...) 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 - -- Compile template with request-specific variables + -- Render precompiled template with request-specific variables local template_vars = get_template_vars(extra_vars) - local compiled = template.compile(M.template_str, template_vars) + local rendered = template.render(M.compiled_template, template_vars) - ngx.say(compiled) + ngx.say(rendered) ngx.exit(status) return end diff --git a/lib/plugins/crowdsec/template.lua b/lib/plugins/crowdsec/template.lua index 66be5be7..52998f31 100644 --- a/lib/plugins/crowdsec/template.lua +++ b/lib/plugins/crowdsec/template.lua @@ -10,104 +10,200 @@ local function is_truthy(value) return true end --- Process if/else conditionals --- Syntax: {{#if variable}}content{{else}}other content{{/if}} --- Supports negation: {{#if !variable}}content{{/if}} --- The else block is optional -local function process_conditionals(template_str, args) - -- Pattern to match {{#if var}}...{{else}}...{{/if}} or {{#if var}}...{{/if}} - -- Using a loop to handle nested conditionals from innermost out - local max_iterations = 100 -- Safety limit to prevent infinite loops - local iteration = 0 - - while iteration < max_iterations do - iteration = iteration + 1 - - -- Find the innermost if block (one that doesn't contain another #if) - -- Match {{#if ...}}...{{/if}} where the content doesn't contain {{#if - local found = false - - template_str = template_str:gsub( - "{{#if%s+([^}]+)}}(.-){{\\/if}}", - function(condition, content) - -- Check if this block contains a nested #if - if so, skip it for now - if content:match("{{#if") then - return "{{#if " .. condition .. "}}" .. content .. "{{/if}}" - end +-- Escape special characters in a value for use in gsub replacement +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 +local function escape_pattern(str) + return str:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end - found = true - local negated = false - local var_name = condition:match("^%s*(.-)%s*$") -- trim whitespace +-- Parse template into segments for fast rendering +-- Returns a list of segments: {type="text|var|cond", ...} +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 - -- Check for negation - if var_name:sub(1, 1) == "!" then - negated = true - var_name = var_name:sub(2):match("^%s*(.-)%s*$") -- trim again - 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 - -- Split content by {{else}} - local if_content, else_content = content:match("^(.-){{else}}(.*)$") - if not if_content then - if_content = content - else_content = "" + -- Check if this is a conditional + local cond_match = template_str:match("^{{#if%s+([^}]+)}}", start_pos) + if cond_match then + -- Find matching {{/if}} + local cond_start = start_pos + 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 - -- Evaluate condition - local value = args[var_name] - local condition_met = is_truthy(value) - - if negated then - condition_met = not condition_met + 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 condition_met then - return if_content + 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 - return else_content + 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 - -- If no substitution was made, we're done - if not found then - break + return segments +end + +-- Render parsed segments with given variables +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 template_str + return table.concat(result) end --- Escape special characters in a value for use in gsub replacement -local function escape_replacement(str) - if type(str) ~= "string" then - str = tostring(str) +-- Precompile a template string into a parsed structure +-- Call this once at init time +function template.precompile(template_str) + if template_str == nil or template_str == "" then + return nil end - -- Escape % which is special in Lua replacement strings - return str:gsub("%%", "%%%%") + return { + segments = parse_template(template_str), + raw = template_str + } end --- Escape special characters in pattern for literal matching -local function escape_pattern(str) - return str:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +-- Render a precompiled template with variables +-- Call this at request time for fast rendering +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) function template.compile(template_str, args) if args == nil then args = {} end - -- First process conditionals - template_str = process_conditionals(template_str, args) - - -- Then do variable substitution - for k, v in pairs(args) do - local var = "{{" .. k .. "}}" - local escaped_var = escape_pattern(var) - local escaped_value = escape_replacement(v) - template_str = template_str:gsub(escaped_var, escaped_value) + 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 From 123eb15976eecc84c69906e78637308ce18de449 Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 19 Jan 2026 11:01:38 +0000 Subject: [PATCH 3/4] refactor(template): DRY request vars into shared helper - Move get_request_vars() to template module - Use nginx native request_id instead of custom generator - Both ban and captcha now use shared helper Co-Authored-By: Claude Opus 4.5 --- lib/plugins/crowdsec/ban.lua | 57 +------------------------------ lib/plugins/crowdsec/captcha.lua | 33 +++++++++++++----- lib/plugins/crowdsec/template.lua | 42 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 65 deletions(-) diff --git a/lib/plugins/crowdsec/ban.lua b/lib/plugins/crowdsec/ban.lua index 7fe6ec3b..6ea05c4b 100644 --- a/lib/plugins/crowdsec/ban.lua +++ b/lib/plugins/crowdsec/ban.lua @@ -47,61 +47,6 @@ function M.new(template_path, redirect_location, ret_code) end --- Generate a unique request ID -local function generate_request_id() - -- Use ngx.var.request_id if available (OpenResty 1.11.2+) - if ngx.var.request_id then - return ngx.var.request_id - end - -- Fallback: generate a simple unique ID - local random_part = string.format("%08x", math.random(0, 0xFFFFFFFF)) - local time_part = string.format("%08x", ngx.now() * 1000) - return time_part .. "-" .. random_part -end - - --- Gather template variables from the current request context -local function get_template_vars(extra_vars) - local vars = { - -- Request identification - request_id = generate_request_id(), - - -- 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 (e.g., from CrowdSec decision) - if extra_vars then - for k, v in pairs(extra_vars) do - vars[k] = v - end - end - - return vars -end - - function M.apply(...) local args = {...} local ret_code = args[1] @@ -127,7 +72,7 @@ function M.apply(...) ngx.status = status -- Render precompiled template with request-specific variables - local template_vars = get_template_vars(extra_vars) + local template_vars = template.get_request_vars(extra_vars) local rendered = template.render(M.compiled_template, template_vars) ngx.say(rendered) diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 81d54346..63ecba29 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -22,7 +22,8 @@ captcha_frontend_key["turnstile"] = "cf-turnstile" M.SecretKey = "" M.SiteKey = "" -M.Template = "" +M.compiled_template = nil +M.static_vars = {} M.ret_code = ngx.HTTP_OK function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) @@ -66,21 +67,35 @@ 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 + 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 @@ -124,7 +139,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 52998f31..5d69ec4f 100644 --- a/lib/plugins/crowdsec/template.lua +++ b/lib/plugins/crowdsec/template.lua @@ -1,5 +1,47 @@ local template = {} +-- Gather template variables from the current request context +-- Can be extended with extra_vars table +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 + -- Helper function to check if a value is truthy local function is_truthy(value) if value == nil then return false end From 6aa3d0ffaa43849df7c88777bad3f6959946f66e Mon Sep 17 00:00:00 2001 From: Laurence Date: Mon, 19 Jan 2026 11:10:08 +0000 Subject: [PATCH 4/4] docs: add LuaLS type annotations - Add @class, @field, @param, @return annotations - Helps with LSP autocomplete and type checking - Documents function signatures and data structures Co-Authored-By: Claude Opus 4.5 --- lib/plugins/crowdsec/ban.lua | 36 +++++++-------- lib/plugins/crowdsec/captcha.lua | 73 +++++++++++++++++++++++-------- lib/plugins/crowdsec/template.lua | 72 +++++++++++++++++++++++------- 3 files changed, 128 insertions(+), 53 deletions(-) diff --git a/lib/plugins/crowdsec/ban.lua b/lib/plugins/crowdsec/ban.lua index 6ea05c4b..bf5de5e9 100644 --- a/lib/plugins/crowdsec/ban.lua +++ b/lib/plugins/crowdsec/ban.lua @@ -1,16 +1,25 @@ 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.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 "" local ret_code_ok = false if ret_code ~= nil and ret_code ~= 0 and ret_code ~= "" then @@ -46,20 +55,13 @@ 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)) -function M.apply(...) - local args = {...} - local ret_code = args[1] - local extra_vars = args[2] -- Optional table of additional template variables - - 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 + local status = ret_code or M.ret_code ngx.log(ngx.DEBUG, "BAN: status=" .. status .. ", redirect_location=" .. M.redirect_location) if M.redirect_location ~= "" then @@ -81,8 +83,6 @@ function M.apply(...) end ngx.exit(status) - - return end return M diff --git a/lib/plugins/crowdsec/captcha.lua b/lib/plugins/crowdsec/captcha.lua index 63ecba29..cbd9ea07 100644 --- a/lib/plugins/crowdsec/captcha.lua +++ b/lib/plugins/crowdsec/captcha.lua @@ -3,29 +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.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 @@ -51,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 @@ -83,7 +108,7 @@ function M.New(siteKey, secretKey, TemplateFilePath, captcha_provider, ret_code) 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" @@ -99,16 +124,28 @@ function M.apply() 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, diff --git a/lib/plugins/crowdsec/template.lua b/lib/plugins/crowdsec/template.lua index 5d69ec4f..218e6a35 100644 --- a/lib/plugins/crowdsec/template.lua +++ b/lib/plugins/crowdsec/template.lua @@ -1,7 +1,27 @@ +---@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 = {} --- Gather template variables from the current request context --- Can be extended with extra_vars table +---@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) @@ -42,7 +62,9 @@ function template.get_request_vars(extra_vars) return vars end --- Helper function to check if a value is truthy +--- 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 @@ -52,7 +74,9 @@ local function is_truthy(value) return true end --- Escape special characters in a value for use in gsub replacement +--- 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) @@ -61,13 +85,16 @@ local function escape_replacement(str) return str:gsub("%%", "%%%%") end --- Escape special characters in pattern for literal matching +--- 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 --- Returns a list of segments: {type="text|var|cond", ...} +--- Parse template into segments for fast rendering +---@param template_str string +---@return Segment[] local function parse_template(template_str) local segments = {} local pos = 1 @@ -90,11 +117,11 @@ local function parse_template(template_str) table.insert(segments, {type = "text", content = template_str:sub(pos, start_pos - 1)}) end - -- Check if this is a conditional - local cond_match = template_str:match("^{{#if%s+([^}]+)}}", start_pos) + -- 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_start = start_pos local cond_open_end = template_str:find("}}", start_pos, true) + 2 local depth = 1 local search_pos = cond_open_end @@ -175,7 +202,10 @@ local function parse_template(template_str) return segments end --- Render parsed segments with given variables +--- Render parsed segments with given variables +---@param segments Segment[] +---@param args table +---@return string local function render_segments(segments, args) local result = {} @@ -209,8 +239,10 @@ local function render_segments(segments, args) return table.concat(result) end --- Precompile a template string into a parsed structure --- Call this once at init time +--- 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 @@ -221,8 +253,11 @@ function template.precompile(template_str) } end --- Render a precompiled template with variables --- Call this at request time for fast rendering +--- 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 "" @@ -233,8 +268,11 @@ function template.render(compiled, args) return render_segments(compiled.segments, args) end --- Original compile function for backward compatibility --- Parses and renders in one step (less efficient for repeated use) +--- 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 = {}