WAF Lua Scripting Guide
Write custom Lua scripts to inspect, filter, and control every HTTP request flowing through CloFix WAF — with full access to request context, threat intelligence, and shared cross-request state.
01 Introduction
CloFix WAF executes Lua scripts on every HTTP request before it reaches your backend. Scripts have read access to a rich request object and read/write access to shared in-memory dictionaries for maintaining state across requests.
false, the WAF immediately blocks the request and
returns the specified HTTP status code. No further scripts are executed.
Key Concepts
- Scripts live in
/etc/clofix/lua_script/<domain>/— one directory per domain - All
.luafiles run in filesystem order for every request - Every script must define
function process_request(request) - Shared dictionaries persist across requests for counters and block-lists
- Scripts return two values:
allowed(boolean) andstatus_code(number)
02 Directory Structure
CloFix creates the domain directory automatically when Lua is enabled in the dashboard. You can also create it manually:
/etc/clofix/lua_script/
├── example.com/
│ ├── rate_limit.lua
│ ├── bot_detection.lua
│ └── custom_rules.lua
├── api.example.com/
│ └── api_security.lua
└── shop.example.com/
└── geo_block.lua
# Create manually if needed sudo mkdir -p /etc/clofix/lua_script/your-domain.com sudo chown -R clofix:clofix /etc/clofix/lua_script/ sudo chmod 644 /etc/clofix/lua_script/your-domain.com/*.lua
03 Domain Configuration
Enable Lua and declare shared dictionaries inside your domain .conf file,
or use the CloFix Config Manager dashboard tab:
# /etc/clofix/conf/example.com.conf domain example.com { proxy_pass http://localhost:8080; lua { enabled on; lua_shared_dict ddos_attack 10m; # 10 MB lua_shared_dict rate_limit 5m; # 5 MB lua_shared_dict bot_tracking 2m; # 2 MB } }
lua_shared_dict creates a named in-memory dictionary
accessible from all scripts via ngx.shared.<name>.
Size is in megabytes. Dictionaries persist for the lifetime of the WAF process.
04 Script Structure
Minimal Script
function process_request(request) -- Your logic here return true, 200 -- Allow end
Full Template
-- ① Configuration (easy to tune without editing logic) local CONFIG = { ENABLED = true, RATE_LIMIT = 100, BLOCK_TIME = 300, -- seconds } -- ② Helper functions local function is_whitelisted(ip) local wl = { ["192.168.1.1"] = true } return wl[ip] == true end -- ③ Required entry point — must be named exactly this function process_request(request) if not CONFIG.ENABLED then return true, 200 end local ip = request.remote_ip local path = request.path if is_whitelisted(ip) then return true, 200 end -- ... security logic ... return true, 200 end
05 Request Object Reference
All fields on the request table are read-only.
Always guard against nil using or "default".
Basic Fields
| Field | Type | Description | Example |
|---|---|---|---|
| method | string | HTTP method | "GET", "POST" |
| uri | string | Full URI with query string | "/page?id=1" |
| path | string | URI path only | "/page" |
| protocol | string | HTTP version | "HTTP/1.1" |
| remote_ip | string | Client IP address | "203.0.113.5" |
| port | number | Client source port | 54321 |
| remote_addr | string | IP:port combined | "203.0.113.5:54321" |
| timestamp | number | Unix timestamp | 1709123456 |
| body | string | Raw request body | "name=alice" |
| host | string | Host header | "example.com" |
| user_agent | string | User-Agent header | "Mozilla/5.0 …" |
| referer | string | Referer header | "https://…" |
| content_type | string | Content-Type header | "application/json" |
| scheme | string | Protocol scheme | "https" |
| is_tls | boolean | HTTPS connection flag | true |
| tls_version | string | TLS version string | "TLS 1.3" |
| request_id | string | Unique request ID | "abc123…" |
| anomaly_score | number | WAF cumulative score (higher = riskier) | 42 |
Table Fields — headers, cookies, query, form
-- Headers (keys are lowercase) local ua = request.headers["user-agent"] local auth = request.headers["authorization"] -- Cookies local sess = request.cookies["session_id"] -- Query string parameters local page = request.query["page"] local id = request.query["id"] -- POST form fields local user = request.form["username"] -- Iterate all headers for k, v in pairs(request.headers) do ngx.log(ngx.INFO, k .. ": " .. v) end
GeoIP & Threat Intelligence Fields
| Field | Type | Description |
|---|---|---|
| client_country | string | ISO country code — e.g. "US", "BD" |
| client_region | string | Region / state name |
| client_city | string | City name |
| asn | string | Autonomous System Number |
| org | string | ISP / Organisation |
| is_tor | boolean | true if Tor exit node |
| is_cloud_provider | boolean | true if AWS / GCP / Azure IP |
| is_vpn | boolean | true if VPN / proxy detected |
| is_bot | boolean | true if WAF bot detection triggered |
| is_headless | boolean | true if headless browser detected |
06 Shared Dictionaries
Shared dictionaries maintain state across requests and across all Lua scripts for a domain. They are thread-safe and persist for the lifetime of the WAF process.
Access
local dict = ngx.shared.ddos_attack -- name must match config local rl = ngx.shared.rate_limit
Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
| get | dict:get(key) | value | nil | Read value; nil if missing or expired |
| set | dict:set(key, value, ttl?) | — | Store value with optional TTL in seconds |
| incr | dict:incr(key, delta) | new number | Atomically increment a numeric counter |
| delete | dict:delete(key) | — | Remove a key immediately |
| flush_all | dict:flush_all() | — | Clear all keys in the dictionary |
| ttl | dict:ttl(key) | seconds | Remaining TTL (0 if no expiry set) |
Common Patterns
local dict = ngx.shared.ddos_attack local ip = request.remote_ip -- ① Persistent counter local total = dict:get("total") or 0 dict:set("total", total + 1) -- ② Per-IP rate limit (per minute) local key = "rate:" .. ip .. ":" .. os.date("%Y%m%d%H%M") local count = dict:incr(key, 1) dict:set(key, count, 60) -- 60s TTL if count > 100 then dict:set("block:" .. ip, os.time() + 300, 300) return false, 429 end -- ③ Check block list local blocked_until = dict:get("block:" .. ip) or 0 if blocked_until > os.time() then return false, 403 end
07 Logging
Use ngx.log()
to write to /var/log/clofix/waf.log.
| Constant | Level | Use for |
|---|---|---|
| ngx.DEBUG | DEBUG | Internal state — remove before production |
| ngx.INFO | INFO | Normal informational events |
| ngx.WARN | WARN | Blocked requests, suspicious activity |
| ngx.ERR | ERR | Script errors, unexpected conditions |
-- Basic log ngx.log(ngx.INFO, "[LUA] Request from: " .. request.remote_ip) -- Formatted with multiple fields ngx.log(ngx.WARN, string.format( "[BLOCKED] IP=%s Path=%s Score=%d", request.remote_ip, request.path, request.anomaly_score or 0 )) -- Always guard nil before concatenating local cc = request.client_country or "XX" ngx.log(ngx.INFO, "Country: " .. cc)
08 Return Values
process_request
must always return exactly two values: a boolean and an HTTP status code.
false, CloFix WAF immediately sends the HTTP response with
the given status code and stops processing further Lua scripts in the pipeline.
09 Complete Examples
Rate Limiter
local CONFIG = { REQUESTS_PER_MINUTE = 60, BLOCK_TIME = 300, } function process_request(request) local ip = request.remote_ip local dict = ngx.shared.ddos_attack local now = os.time() local blocked_until = dict:get("block:" .. ip) or 0 if blocked_until > now then return false, 429 end local key = "cnt:" .. ip .. ":" .. os.date("%Y%m%d%H%M") local count = dict:incr(key, 1) dict:set(key, count, 60) if count > CONFIG.REQUESTS_PER_MINUTE then dict:set("block:" .. ip, now + CONFIG.BLOCK_TIME, CONFIG.BLOCK_TIME) ngx.log(ngx.WARN, "Rate limit: " .. ip) return false, 429 end return true, 200 end
Geo / Country Block
local BLOCKED = { ["XX"] = true, ["YY"] = true } function process_request(request) local cc = request.client_country if cc and BLOCKED[cc] then ngx.log(ngx.WARN, "Geo block: " .. cc) return false, 403 end return true, 200 end
Bot Detection
local BAD_UA = { "python", "curl", "wget", "scrapy", "bot", "crawler", "spider", "scanner", } local function is_bad_ua(ua) if not ua or ua == "" then return true end ua = string.lower(ua) for _, pat in ipairs(BAD_UA) do if string.find(ua, pat, 1, true) then return true end end return false end function process_request(request) local ua = request.headers["user-agent"] or "" if is_bad_ua(ua) then ngx.log(ngx.WARN, "Bot: " .. ua) return false, 403 end return true, 200 end
Path-Based IP Whitelist
local PROTECTED = { ["/admin"] = true, ["/wp-admin"] = true } local ALLOWED = { ["192.168.1.100"] = true, ["10.0.0.50"] = true } function process_request(request) for path in pairs(PROTECTED) do if string.find(request.path, path, 1, true) == 1 then if not ALLOWED[request.remote_ip] then ngx.log(ngx.WARN, "Blocked " .. request.remote_ip) return false, 403 end break end end return true, 200 end
SQL Injection Detection
local SQL_PATTERNS = { "union%s+select", "select.+from", "insert%s+into", "delete%s+from", "drop%s+table", "or%s+1%s*=%s*1", } local function has_sqli(s) if not s then return false end local low = string.lower(s) for _, p in ipairs(SQL_PATTERNS) do if string.find(low, p) then return true end end return false end function process_request(request) for k, v in pairs(request.query) do if has_sqli(k) or has_sqli(v) then ngx.log(ngx.WARN, "SQLi in query: " .. k) return false, 403 end end for k, v in pairs(request.form) do if has_sqli(k) or has_sqli(v) then ngx.log(ngx.WARN, "SQLi in form: " .. k) return false, 403 end end return true, 200 end
Anomaly Score Threshold
local THRESHOLD = 50 -- tune to your environment function process_request(request) local score = request.anomaly_score or 0 if score >= THRESHOLD then ngx.log(ngx.WARN, string.format( "High anomaly %d from %s", score, request.remote_ip )) return false, 403 end return true, 200 end
10 Attack-Specific Scripts
Ready-to-deploy scripts for the most common real-world attacks. Copy each file into your
domain's Lua directory — they work independently and can be combined freely.
Required lua_shared_dict
declarations are noted at the top of each script.
XSS — Cross-Site Scripting Detection
-- XSS Detection — blocks common script injection payloads local XSS_PATTERNS = { "<%s*script", -- <script> tag "javascript%s*:", -- javascript: URI "on%a+%s*=", -- onerror=, onload=, onclick= … "<%s*iframe", -- iframe injection "<%s*img[^>]+src%s*=", -- img src= injection "<%s*svg", -- SVG-based XSS "expression%s*%(", -- CSS expression() "vbscript%s*:", -- vbscript: URI "document%.cookie", -- cookie theft "document%.write", -- DOM manipulation "window%.location", -- redirect "eval%s*%(", -- eval() execution "alert%s*%(", -- classic XSS probe "prompt%s*%(", -- classic XSS probe "confirm%s*%(", -- classic XSS probe } local function has_xss(s) if not s or s == "" then return false end local low = string.lower(s) for _, p in ipairs(XSS_PATTERNS) do if string.find(low, p) then return true end end return false end function process_request(request) local ip = request.remote_ip -- Scan query parameters for k, v in pairs(request.query) do if has_xss(k) or has_xss(v) then ngx.log(ngx.WARN, string.format("[XSS] query param %s from %s", k, ip)) return false, 403 end end -- Scan form fields for k, v in pairs(request.form) do if has_xss(k) or has_xss(v) then ngx.log(ngx.WARN, string.format("[XSS] form field %s from %s", k, ip)) return false, 403 end end -- Scan raw body (catches JSON/XML XSS payloads) if has_xss(request.body) then ngx.log(ngx.WARN, string.format("[XSS] body payload from %s", ip)) return false, 403 end return true, 200 end
Path Traversal & Directory Traversal
../ tricks, null bytes, and encoded traversal sequences.
-- Path Traversal Detection local TRAVERSAL_PATTERNS = { "%.%./", -- ../ "%.\\.%.", -- .\.. "%%2e%%2e", -- URL-encoded ../ (%2e%2e) "%%2e%%2e%%2f", -- %2e%2e%2f "%%252e%%252e", -- double-encoded "%z", -- null byte "%%00", -- URL null byte "/etc/passwd", "/etc/shadow", "/proc/self", "/windows/system32", "\\windows\\system32", "boot%.ini", "win%.ini", } local function has_traversal(s) if not s or s == "" then return false end local low = string.lower(s) for _, p in ipairs(TRAVERSAL_PATTERNS) do if string.find(low, p) then return true end end return false end function process_request(request) local ip = request.remote_ip -- Check URI/path directly if has_traversal(request.uri) then ngx.log(ngx.WARN, string.format("[TRAVERSAL] URI: %s from %s", request.uri, ip)) return false, 403 end -- Check query parameters for k, v in pairs(request.query) do if has_traversal(v) then ngx.log(ngx.WARN, string.format("[TRAVERSAL] param %s from %s", k, ip)) return false, 403 end end -- Check form fields for k, v in pairs(request.form) do if has_traversal(v) then ngx.log(ngx.WARN, string.format("[TRAVERSAL] form %s from %s", k, ip)) return false, 403 end end return true, 200 end
Command Injection Detection
-- Command Injection Detection local CMD_PATTERNS = { "|%s*%a", -- pipe to command: | cmd ";%s*%a", -- chained command: ; cmd "&&%s*%a", -- AND chain: && cmd "`[^`]+`", -- backtick execution "%$%(.-%)?", -- $(...) subshell "%$%{.-}?", -- ${VAR} expansion "cmd%.exe", "/bin/sh", "/bin/bash", "/bin/zsh", "wget%s+http", "curl%s+http", "nc%s+-", -- netcat "chmod%s+[0-9]", "base64%s+%-d", -- encoded payload decode "python%s+-c", "perl%s+-e", "ruby%s+-e", "php%s+-r", } local function has_cmdi(s) if not s or s == "" then return false end local low = string.lower(s) for _, p in ipairs(CMD_PATTERNS) do if string.find(low, p) then return true end end return false end function process_request(request) local ip = request.remote_ip for k, v in pairs(request.query) do if has_cmdi(v) then ngx.log(ngx.WARN, string.format("[CMDI] query %s=%s from %s", k, v, ip)) return false, 403 end end for k, v in pairs(request.form) do if has_cmdi(v) then ngx.log(ngx.WARN, string.format("[CMDI] form %s from %s", k, ip)) return false, 403 end end if has_cmdi(request.body) then ngx.log(ngx.WARN, string.format("[CMDI] body payload from %s", ip)) return false, 403 end return true, 200 end
Brute Force Login Protection
lua_shared_dict ddos_attack 10m; — tracks per-IP login failures with progressive banning.
-- Brute Force Login Protection -- Monitors POST to login paths; bans IP after threshold is exceeded. local CONFIG = { LOGIN_PATHS = { "/login", "/wp-login.php", "/admin/login", "/api/auth", "/signin", "/account/login" }, MAX_ATTEMPTS = 10, -- failures before ban WINDOW = 300, -- 5-minute window BAN_TIME = 1800, -- 30-minute ban } local function is_login_path(path) local low = string.lower(path) for _, p in ipairs(CONFIG.LOGIN_PATHS) do if string.find(low, p, 1, true) then return true end end return false end function process_request(request) local ip = request.remote_ip local dict = ngx.shared.ddos_attack local now = os.time() -- Check if IP is already banned local banned_until = dict:get("bf_ban:" .. ip) or 0 if banned_until > now then ngx.log(ngx.WARN, string.format("[BRUTE] banned IP %s (%ds left)", ip, banned_until - now)) return false, 403 end -- Only track POST requests to login endpoints if request.method ~= "POST" or not is_login_path(request.path) then return true, 200 end -- Count attempts within window local key = "bf_cnt:" .. ip local attempts = dict:incr(key, 1) dict:set(key, attempts, CONFIG.WINDOW) ngx.log(ngx.INFO, string.format("[BRUTE] %s → %s attempt #%d", ip, request.path, attempts)) if attempts >= CONFIG.MAX_ATTEMPTS then dict:set("bf_ban:" .. ip, now + CONFIG.BAN_TIME, CONFIG.BAN_TIME) dict:delete(key) ngx.log(ngx.WARN, string.format("[BRUTE] BANNED %s for %ds", ip, CONFIG.BAN_TIME)) return false, 429 end return true, 200 end
DDoS Shield — Multi-Layer Traffic Defence
lua_shared_dict ddos_attack 10m; — combines per-second burst protection, connection flood detection, and progressive ban escalation.
-- DDoS Shield — Multi-Layer Protection local CONFIG = { BURST_LIMIT = 30, -- max requests per second per IP SUSTAINED_LIMIT = 200, -- max requests per minute per IP FIRST_BAN = 60, -- 1st offense: 1 min ban SECOND_BAN = 600, -- 2nd offense: 10 min ban THIRD_BAN = 86400, -- 3rd offense: 24 hour ban } function process_request(request) local ip = request.remote_ip local dict = ngx.shared.ddos_attack local now = os.time() -- ① Check active ban local banned_until = dict:get("ddos_ban:" .. ip) or 0 if banned_until > now then return false, 429 end -- ② Burst check (per second) local sec_key = "ddos_s:" .. ip .. ":" .. now local sec_cnt = dict:incr(sec_key, 1) dict:set(sec_key, sec_cnt, 2) -- 2s TTL if sec_cnt > CONFIG.BURST_LIMIT then ngx.log(ngx.WARN, string.format("[DDOS] burst from %s (%d/s)", ip, sec_cnt)) escalate_ban(dict, ip, now) return false, 429 end -- ③ Sustained check (per minute) local min_key = "ddos_m:" .. ip .. ":" .. os.date("%Y%m%d%H%M") local min_cnt = dict:incr(min_key, 1) dict:set(min_key, min_cnt, 70) -- 70s TTL if min_cnt > CONFIG.SUSTAINED_LIMIT then ngx.log(ngx.WARN, string.format("[DDOS] sustained from %s (%d/min)", ip, min_cnt)) escalate_ban(dict, ip, now) return false, 429 end return true, 200 end -- Progressive ban escalation: 1st → 1min, 2nd → 10min, 3rd+ → 24h function escalate_ban(dict, ip, now) local offense_key = "ddos_off:" .. ip local offenses = (dict:get(offense_key) or 0) + 1 dict:set(offense_key, offenses, 86400) -- track for 24h local ban_duration if offenses == 1 then ban_duration = CONFIG.FIRST_BAN elseif offenses == 2 then ban_duration = CONFIG.SECOND_BAN else ban_duration = CONFIG.THIRD_BAN end dict:set("ddos_ban:" .. ip, now + ban_duration, ban_duration) ngx.log(ngx.WARN, string.format( "[DDOS] IP %s offense #%d → banned %ds", ip, offenses, ban_duration)) end
HTTP Method Filter
-- HTTP Method Filter -- Define which methods are allowed globally and per-path prefix. local GLOBAL_ALLOWED = { GET = true, POST = true, PUT = true, DELETE = true, PATCH = true, OPTIONS = true, HEAD = true, } -- Paths where only read methods are permitted (e.g. public pages) local READONLY_PATHS = { "/blog", "/docs", "/static", "/assets", } -- Paths where only API-safe methods are permitted local API_PATHS = { "/api/", "/v1/", "/v2/" } local API_ALLOWED = { GET = true, POST = true, PUT = true, DELETE = true, PATCH = true, } local READ_ALLOWED = { GET = true, HEAD = true, OPTIONS = true } local function starts_with(str, prefix) return string.find(string.lower(str), string.lower(prefix), 1, true) == 1 end function process_request(request) local method = string.upper(request.method or "") local path = request.path or "/" local ip = request.remote_ip -- Block completely unknown/dangerous methods if not GLOBAL_ALLOWED[method] then ngx.log(ngx.WARN, string.format("[METHOD] unknown method %s from %s", method, ip)) return false, 405 end -- Enforce read-only on static/content paths for _, p in ipairs(READONLY_PATHS) do if starts_with(path, p) and not READ_ALLOWED[method] then ngx.log(ngx.WARN, string.format("[METHOD] %s not allowed on %s from %s", method, path, ip)) return false, 405 end end -- Enforce API method whitelist for _, p in ipairs(API_PATHS) do if starts_with(path, p) and not API_ALLOWED[method] then ngx.log(ngx.WARN, string.format("[METHOD] %s denied on API %s from %s", method, path, ip)) return false, 405 end end return true, 200 end
Request Size Limiter
-- Request Size Limiter local CONFIG = { MAX_BODY_BYTES = 1048576, -- 1 MB default body limit MAX_URI_LENGTH = 2048, -- reject very long URIs (scanner bait) MAX_HEADER_COUNT = 50, -- reject excessive headers -- Per-path overrides (larger uploads allowed on specific paths) PATH_LIMITS = { ["/api/upload"] = 10485760, -- 10 MB ["/media/ingest"] = 52428800, -- 50 MB }, } local function get_limit(path) for p, limit in pairs(CONFIG.PATH_LIMITS) do if string.find(path, p, 1, true) == 1 then return limit end end return CONFIG.MAX_BODY_BYTES end function process_request(request) local ip = request.remote_ip local path = request.path or "/" -- Check URI length local uri_len = string.len(request.uri or "") if uri_len > CONFIG.MAX_URI_LENGTH then ngx.log(ngx.WARN, string.format("[SIZE] URI too long: %d bytes from %s", uri_len, ip)) return false, 414 -- URI Too Long end -- Check Content-Length header local cl = request.content_length or 0 local limit = get_limit(path) if cl > limit then ngx.log(ngx.WARN, string.format( "[SIZE] body too large: %d bytes (limit %d) on %s from %s", cl, limit, path, ip)) return false, 413 -- Content Too Large end -- Count actual headers local header_count = 0 for _ in pairs(request.headers) do header_count = header_count + 1 end if header_count > CONFIG.MAX_HEADER_COUNT then ngx.log(ngx.WARN, string.format("[SIZE] too many headers: %d from %s", header_count, ip)) return false, 431 -- Request Header Fields Too Large end return true, 200 end
Tor / VPN / Cloud Proxy Blocker
-- Tor / VPN / Cloud Proxy Blocker -- Uses CloFix threat intelligence fields populated by the WAF engine. local CONFIG = { BLOCK_TOR = true, -- block Tor exit nodes BLOCK_VPN = false, -- set true to block VPN/proxies (caution: false positives) BLOCK_CLOUD = false, -- set true to block AWS/GCP/Azure IPs BLOCK_HEADLESS = true, -- block headless/automated browsers -- Paths exempt from anonymiser blocking (e.g. public API) EXEMPT_PATHS = { "/api/public", "/health", "/status" }, } local function is_exempt(path) for _, p in ipairs(CONFIG.EXEMPT_PATHS) do if string.find(path, p, 1, true) == 1 then return true end end return false end function process_request(request) local ip = request.remote_ip local path = request.path or "/" if is_exempt(path) then return true, 200 end if CONFIG.BLOCK_TOR and request.is_tor then ngx.log(ngx.WARN, string.format("[ANON] Tor exit node blocked: %s", ip)) return false, 403 end if CONFIG.BLOCK_VPN and request.is_vpn then ngx.log(ngx.WARN, string.format("[ANON] VPN/proxy blocked: %s", ip)) return false, 403 end if CONFIG.BLOCK_CLOUD and request.is_cloud_provider then ngx.log(ngx.WARN, string.format("[ANON] cloud IP blocked: %s org=%s", ip, request.org or "unknown")) return false, 403 end if CONFIG.BLOCK_HEADLESS and request.is_headless then ngx.log(ngx.WARN, string.format("[ANON] headless browser blocked: %s", ip)) return false, 403 end return true, 200 end
WordPress Attack Shield
lua_shared_dict ddos_attack 10m; — blocks wp-login brute force, XML-RPC abuse, plugin probing, and author enumeration in one script.
-- WordPress Attack Shield local CONFIG = { BLOCK_XMLRPC = true, -- block all XML-RPC (common DDoS amplifier) BLOCK_AUTHOR_ENUM = true, -- block ?author=N enumeration BLOCK_PHP_ACCESS = true, -- block direct .php access outside root LOGIN_RATE_LIMIT = 5, -- max WP-login POSTs per minute LOGIN_BAN_TIME = 900, -- 15 min ban -- Sensitive WordPress paths to block from public BLOCKED_PATHS = { "/wp-config.php", "/wp-content/debug.log", "/.git", "/.env", "/readme.html", "/license.txt", "/wp-includes/js/tinymce/plugins/moxieman", }, } function process_request(request) local ip = request.remote_ip local path = string.lower(request.path or "/") local dict = ngx.shared.ddos_attack local now = os.time() -- Block XML-RPC entirely if CONFIG.BLOCK_XMLRPC and path == "/xmlrpc.php" then ngx.log(ngx.WARN, string.format("[WP] XML-RPC blocked from %s", ip)) return false, 403 end -- Block author enumeration: /?author=1 if CONFIG.BLOCK_AUTHOR_ENUM and request.query["author"] then ngx.log(ngx.WARN, string.format("[WP] author enum blocked from %s", ip)) return false, 403 end -- Block sensitive file access for _, blocked in ipairs(CONFIG.BLOCKED_PATHS) do if string.find(path, blocked, 1, true) == 1 then ngx.log(ngx.WARN, string.format("[WP] sensitive path %s from %s", path, ip)) return false, 403 end end -- Block PHP access in uploads directory if CONFIG.BLOCK_PHP_ACCESS and string.find(path, "/wp-content/uploads", 1, true) and string.find(path, "%.php") then ngx.log(ngx.WARN, string.format("[WP] PHP in uploads from %s: %s", ip, path)) return false, 403 end -- Rate limit wp-login.php POST if path == "/wp-login.php" and request.method == "POST" then local ban_key = "wp_ban:" .. ip local banned = dict:get(ban_key) or 0 if banned > now then return false, 429 end local cnt_key = "wp_cnt:" .. ip local cnt = dict:incr(cnt_key, 1) dict:set(cnt_key, cnt, 60) if cnt > CONFIG.LOGIN_RATE_LIMIT then dict:set(ban_key, now + CONFIG.LOGIN_BAN_TIME, CONFIG.LOGIN_BAN_TIME) ngx.log(ngx.WARN, string.format("[WP] login brute-force ban: %s", ip)) return false, 429 end end return true, 200 end
Pentest Scanner & Reconnaissance Detection
lua_shared_dict ddos_attack 10m; — detects automated scanners (Nmap, Nikto, sqlmap, Burp, nuclei, etc.) by user-agent, header patterns, and probe path signatures.
-- Pentest Scanner & Reconnaissance Detection local SCANNER_UA = { "nmap", "nikto", "sqlmap", "burpsuite", "burp", "nuclei", "zgrab", "masscan", "zap", "w3af", "acunetix", "nessus", "openvas", "metasploit", "wfuzz", "gobuster", "dirbuster", "dirb", "hydra", "medusa", "whatweb", "shodan", "python-requests", "go-http-client", "libwww-perl", "java/", "jakarta", } -- Paths that only scanners/reconnaissance tools probe local SCANNER_PATHS = { "/.git/", "/.svn/", "/.env", "/.htaccess", "/etc/passwd", "/etc/shadow", "/proc/", "/phpmyadmin", "/pma/", "/myadmin", "/manager/html", -- Tomcat manager "/solr/", "/jenkins", "/actuator", "/.well-known/security", "/cgi-bin/", "/cgi-sys/", "/wp-content/plugins/revolution", "/setup.php", "/install.php", "/config.php", "/backup", "/db_backup", "/dump.sql", "/.DS_Store", "/Thumbs.db", } local BAN_TIME = 3600 -- 1 hour ban for scanner detection local function is_scanner_ua(ua) if not ua or ua == "" then return false end local low = string.lower(ua) for _, pat in ipairs(SCANNER_UA) do if string.find(low, pat, 1, true) then return true end end return false end local function is_scanner_path(path) local low = string.lower(path) for _, p in ipairs(SCANNER_PATHS) do if string.find(low, p, 1, true) then return true end end return false end function process_request(request) local ip = request.remote_ip local ua = request.headers["user-agent"] or "" local path = request.path or "/" local dict = ngx.shared.ddos_attack local now = os.time() -- Check existing scanner ban first (fastest check) local banned = dict:get("scan_ban:" .. ip) or 0 if banned > now then return false, 403 end local reason = nil -- Scanner user-agent match if is_scanner_ua(ua) then reason = "ua:" .. ua:sub(1, 40) end -- Scanner probe path match if not reason and is_scanner_path(path) then reason = "path:" .. path end -- Missing User-Agent on non-GET is suspicious if not reason and ua == "" and request.method ~= "GET" and request.method ~= "HEAD" then reason = "empty-ua-" .. request.method end if reason then dict:set("scan_ban:" .. ip, now + BAN_TIME, BAN_TIME) ngx.log(ngx.WARN, string.format( "[SCANNER] %s banned %ds — %s", ip, BAN_TIME, reason)) return false, 403 end return true, 200 end
11 More Useful Scripts
Practical scripts for access control, traffic management, and site operations — ready to drop in alongside your security scripts.
Password Protect Any URL
?token= or Authorization header. Token is stored in a cookie so users only authenticate once per session.
-- Password Protect URL Paths -- Users visit /staging?token=mysecret once → cookie is set → free access. -- Change COOKIE_NAME and SECRET to something unique per site. local CONFIG = { SECRET = "change-me-to-a-strong-secret", -- shared password COOKIE_NAME = "clofix_access", COOKIE_TTL = 86400, -- 24h cookie (seconds) -- Paths that require a valid token/cookie PROTECTED_PATHS = { "/staging", "/preview", "/beta", "/internal", "/dev", "/demo", }, -- IPs always allowed regardless of token (office / dev machines) TRUSTED_IPS = { ["192.168.1.0"] = true, ["10.0.0.0"] = true, }, } local function is_protected(path) local low = string.lower(path) for _, p in ipairs(CONFIG.PROTECTED_PATHS) do if string.find(low, p, 1, true) == 1 then return true end end return false end local function get_cookie(cookies, name) return cookies[name] end local function get_query_token(query) return query["token"] end local function get_header_token(headers) -- Support: Authorization: Bearer <token> local auth = headers["authorization"] or "" local tok = string.match(auth, "^[Bb]earer%s+(.+)$") return tok end function process_request(request) local path = request.path or "/" local ip = request.remote_ip if not is_protected(path) then return true, 200 end if CONFIG.TRUSTED_IPS[ip] then return true, 200 end -- Check valid cookie first (already authenticated) local cookie_val = get_cookie(request.cookies, CONFIG.COOKIE_NAME) if cookie_val == CONFIG.SECRET then return true, 200 end -- Accept token from query string or Authorization header local token = get_query_token(request.query) or get_header_token(request.headers) if token == CONFIG.SECRET then -- Token valid — WAF sets a session cookie via response header injection ngx.log(ngx.INFO, string.format("[PROTECT] access granted to %s from %s", path, ip)) ngx.header["Set-Cookie"] = string.format( "%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Strict", CONFIG.COOKIE_NAME, CONFIG.SECRET, CONFIG.COOKIE_TTL) return true, 200 end -- No valid token — return 401 with a simple prompt ngx.log(ngx.WARN, string.format("[PROTECT] denied %s from %s", path, ip)) ngx.header["WWW-Authenticate"] = 'Bearer realm="Protected Area"' return false, 401 end
API Key Authentication
X-API-Key header or api_key query param. Supports per-key rate limits.
-- API Key Authentication -- Each key maps to a name + per-minute rate limit. local API_KEYS = { ["key-client-alpha-1234"] = { name = "client-alpha", rate = 100 }, ["key-client-beta-5678"] = { name = "client-beta", rate = 500 }, ["key-internal-9999"] = { name = "internal", rate = 0 }, -- 0 = unlimited } -- Paths that require a valid API key local PROTECTED_PREFIXES = { "/api/", "/v1/", "/v2/", "/graphql" } local function needs_auth(path) for _, p in ipairs(PROTECTED_PREFIXES) do if string.find(path, p, 1, true) == 1 then return true end end return false end function process_request(request) local path = request.path or "/" if not needs_auth(path) then return true, 200 end -- Extract key: header takes priority over query param local key = request.headers["x-api-key"] or request.headers["x-api-token"] or request.query["api_key"] if not key or key == "" then ngx.header["WWW-Authenticate"] = 'APIKey realm="API"' return false, 401 end local client = API_KEYS[key] if not client then ngx.log(ngx.WARN, string.format("[APIKEY] invalid key from %s", request.remote_ip)) return false, 403 end -- Per-key rate limiting (requires lua_shared_dict rate_limit 5m) if client.rate > 0 then local dict = ngx.shared.rate_limit local rkey = "ak:" .. key .. ":" .. os.date("%Y%m%d%H%M") local cnt = dict:incr(rkey, 1) dict:set(rkey, cnt, 70) if cnt > client.rate then ngx.log(ngx.WARN, string.format( "[APIKEY] rate exceeded for %s (%d/min)", client.name, cnt)) ngx.header["Retry-After"] = "60" return false, 429 end end ngx.log(ngx.INFO, string.format("[APIKEY] %s → %s", client.name, path)) return true, 200 end
Hotlink / Image Leeching Protection
Referer header against your allowed domain list.
-- Hotlink / Image Leeching Protection local CONFIG = { -- Your own domains — requests from these are always allowed ALLOWED_DOMAINS = { "example.com", "www.example.com", "cdn.example.com", "app.example.com", }, -- File extensions considered hotlinkable assets ASSET_EXTENSIONS = { "%.jpg", "%.jpeg", "%.png", "%.gif", "%.webp", "%.svg", "%.mp4", "%.webm", "%.pdf", "%.zip", "%.mp3", "%.woff2", }, -- Direct browser requests (no referer) are allowed ALLOW_EMPTY_REFERER = true, } local function is_asset(path) local low = string.lower(path) for _, ext in ipairs(CONFIG.ASSET_EXTENSIONS) do if string.find(low, ext .. "$") then return true end end return false end local function is_allowed_referer(referer) if not referer or referer == "" then return CONFIG.ALLOW_EMPTY_REFERER end local low = string.lower(referer) for _, d in ipairs(CONFIG.ALLOWED_DOMAINS) do if string.find(low, string.lower(d), 1, true) then return true end end return false end function process_request(request) local path = request.path or "/" local referer = request.referer or request.headers["referer"] or "" if not is_asset(path) then return true, 200 end if not is_allowed_referer(referer) then ngx.log(ngx.WARN, string.format( "[HOTLINK] blocked %s referer=%s from %s", path, referer:sub(1, 60), request.remote_ip)) return false, 403 end return true, 200 end
Dangerous File Type Upload Filter
Content-Type header. Prevents webshell uploads.
-- Dangerous File Type Upload Filter local BLOCKED_EXTENSIONS = { -- Executables & scripts (webshell risk) "%.php", "%.php3", "%.php4", "%.php5", "%.phtml", "%.asp", "%.aspx", "%.jsp", "%.jspx", "%.cgi", "%.pl", "%.py", "%.rb", "%.sh", "%.bash", "%.exe", "%.bat", "%.cmd", "%.com", "%.ps1", "%.vbs", "%.vbe", "%.wsf", "%.wsh", -- Archives with code risk "%.jar", "%.war", "%.ear", -- Config / data leakage risk "%.htaccess", "%.htpasswd", "%.env", "%.config", } local BLOCKED_CONTENT_TYPES = { "application/x-php", "application/x-executable", "application/x-shellscript", "text/x-php", "application/x-msdownload", "application/x-msdos-program", } -- Paths where file uploads are expected local UPLOAD_PATHS = { "/upload", "/uploads", "/media", "/wp-content/uploads", "/files", "/attachments", } local function is_upload_path(path) for _, p in ipairs(UPLOAD_PATHS) do if string.find(string.lower(path), p, 1, true) == 1 then return true end end return false end local function has_blocked_ext(path) local low = string.lower(path) for _, ext in ipairs(BLOCKED_EXTENSIONS) do if string.find(low, ext) then return true, ext end end return false end local function has_blocked_ct(ct) if not ct then return false end local low = string.lower(ct) for _, t in ipairs(BLOCKED_CONTENT_TYPES) do if string.find(low, t, 1, true) then return true end end return false end function process_request(request) local path = request.path or "/" local ip = request.remote_ip -- Always block dangerous extensions anywhere in the URL local blocked, ext = has_blocked_ext(path) if blocked then ngx.log(ngx.WARN, string.format( "[FILETYPE] blocked %s ext=%s from %s", path, ext, ip)) return false, 403 end -- On upload paths, also validate Content-Type if request.method == "POST" and is_upload_path(path) then if has_blocked_ct(request.content_type) then ngx.log(ngx.WARN, string.format( "[FILETYPE] blocked content-type %s from %s", request.content_type, ip)) return false, 415 end end return true, 200 end
CSRF Token Validation
Origin/Referer are also checked as fallback.
-- CSRF Token Validation -- Your app must emit a CSRF token; this script validates it on mutating requests. local CONFIG = { ALLOWED_ORIGINS = { "https://example.com", "https://www.example.com", "https://app.example.com", }, -- Name of the CSRF token header your frontend sends CSRF_HEADER = "x-csrf-token", CSRF_FORM = "_csrf", -- fallback form field name -- Paths exempt from CSRF (pure APIs using API key auth) EXEMPT_PREFIXES = { "/api/webhook", "/api/stripe", "/api/public" }, -- Methods that require CSRF check PROTECTED_METHODS = { POST = true, PUT = true, DELETE = true, PATCH = true }, } local function is_exempt(path) for _, p in ipairs(CONFIG.EXEMPT_PREFIXES) do if string.find(path, p, 1, true) == 1 then return true end end return false end local function is_allowed_origin(origin) if not origin or origin == "" then return false end for _, o in ipairs(CONFIG.ALLOWED_ORIGINS) do if origin == o then return true end end return false end function process_request(request) local method = string.upper(request.method or "GET") local path = request.path or "/" local ip = request.remote_ip if not CONFIG.PROTECTED_METHODS[method] then return true, 200 end if is_exempt(path) then return true, 200 end -- ① Origin header check (modern browsers send this) local origin = request.headers["origin"] or "" if origin ~= "" then if not is_allowed_origin(origin) then ngx.log(ngx.WARN, string.format( "[CSRF] bad origin %s from %s", origin, ip)) return false, 403 end return true, 200 -- origin matched, no need to check token end -- ② CSRF token check (header or form field) local token = request.headers[CONFIG.CSRF_HEADER] or request.form[CONFIG.CSRF_FORM] if not token or string.len(token) < 16 then ngx.log(ngx.WARN, string.format( "[CSRF] missing token on %s from %s", path, ip)) return false, 403 end -- Token present and valid length — pass to backend for full validation return true, 200 end
Honeypot Bot Trap
lua_shared_dict ddos_attack 10m; — creates invisible trap paths that only bots visit. Any IP that hits a honeypot path is permanently banned for 24 hours.
-- Honeypot Bot Trap -- Add links to trap paths in your HTML (hidden via CSS: display:none). -- Humans never click them; bots that crawl & follow links get instantly banned. local CONFIG = { BAN_TIME = 86400, -- 24h ban on first honeypot hit -- Trap paths — add hidden links to these in your pages -- e.g. <a href="/trap-link" style="display:none">Don't click</a> TRAP_PATHS = { "/trap-link", "/do-not-visit", "/honeypot", "/bot-trap", "/tracking-pixel.gif", "/hidden-login", -- Common paths bots probe that are traps on your real site "/admin/config", "/wp-json/wp/v2/users", -- WordPress user enumeration "/autodiscover/autodiscover.xml", }, -- IPs to never ban via honeypot (search engine crawlers) TRUSTED_AGENTS = { "googlebot", "bingbot", "slurp", "duckduckbot", "baiduspider", }, } local function is_trusted_bot(ua) if not ua then return false end local low = string.lower(ua) for _, a in ipairs(CONFIG.TRUSTED_AGENTS) do if string.find(low, a, 1, true) then return true end end return false end function process_request(request) local ip = request.remote_ip local path = string.lower(request.path or "/") local ua = request.user_agent or "" local dict = ngx.shared.ddos_attack local now = os.time() -- Check if already banned local banned = dict:get("hp_ban:" .. ip) or 0 if banned > now then ngx.log(ngx.WARN, string.format("[HONEYPOT] repeat offender %s", ip)) return false, 403 end -- Check if this request hits a trap path for _, trap in ipairs(CONFIG.TRAP_PATHS) do if path == string.lower(trap) then if is_trusted_bot(ua) then ngx.log(ngx.INFO, string.format( "[HONEYPOT] trusted crawler %s hit trap %s — skip", ua:sub(1,30), trap)) return false, 404 -- just 404 for crawlers end dict:set("hp_ban:" .. ip, now + CONFIG.BAN_TIME, CONFIG.BAN_TIME) ngx.log(ngx.WARN, string.format( "[HONEYPOT] BANNED %s for %ds — hit trap %s (ua=%s)", ip, CONFIG.BAN_TIME, trap, ua:sub(1,40))) return false, 404 -- return 404 not 403 to not alert attackers end end return true, 200 end
Maintenance Mode with IP Bypass
-- Maintenance Mode with IP Bypass -- Set ENABLED = true to activate. Whitelisted IPs always pass through. local CONFIG = { ENABLED = false, -- flip to true to enable maintenance mode MESSAGE = "We are currently performing maintenance. Back soon!", -- IPs and CIDR /24 prefixes that bypass maintenance mode ALLOWED_IPS = { ["203.0.113.10"] = true, -- office IP ["198.51.100.20"] = true, -- dev workstation }, ALLOWED_CIDR24 = { "192.168.1.", -- internal network prefix /24 "10.0.0.", }, -- Paths always accessible during maintenance (health checks, status) ALWAYS_ON = { "/health", "/ping", "/status", "/favicon.ico" }, } local function is_always_on(path) for _, p in ipairs(CONFIG.ALWAYS_ON) do if path == p then return true end end return false end local function is_allowed_ip(ip) if CONFIG.ALLOWED_IPS[ip] then return true end for _, prefix in ipairs(CONFIG.ALLOWED_CIDR24) do if string.find(ip, prefix, 1, true) == 1 then return true end end return false end function process_request(request) if not CONFIG.ENABLED then return true, 200 end local path = request.path or "/" local ip = request.remote_ip if is_always_on(path) then return true, 200 end if is_allowed_ip(ip) then ngx.log(ngx.INFO, string.format("[MAINT] bypass for %s", ip)) return true, 200 end ngx.header["Retry-After"] = "3600" ngx.header["Content-Type"] = "text/plain; charset=utf-8" ngx.header["Cache-Control"] = "no-store" ngx.log(ngx.INFO, string.format("[MAINT] blocked %s → %s", ip, path)) return false, 503 end
Smart Redirect Engine
rewrite rules with a single Lua table — supports exact, prefix, and regex redirects with custom status codes.
-- Smart Redirect Engine -- Three modes: exact match, prefix match, pattern match. -- Evaluated in order: exact → prefix → pattern. -- ① Exact path redirects (fastest — checked first) local EXACT = { ["/home"] = { to = "/", code = 301 }, ["/about-us"] = { to = "/about", code = 301 }, ["/contact-us"] = { to = "/contact", code = 301 }, ["/old-pricing"] = { to = "/pricing", code = 301 }, ["/blog/index.html"] = { to = "/blog", code = 301 }, ["/dl/latest"] = { to = "https://cdn.example.com/latest.zip", code = 302 }, } -- ② Prefix redirects — /old-blog/anything → /blog/anything local PREFIX = { { from = "/old-blog/", to = "/blog/", code = 301 }, { from = "/wp-content/", to = "/static/", code = 301 }, { from = "/legacy/api/", to = "/api/v2/", code = 302 }, } -- ③ Pattern redirects (Lua patterns) — capture groups supported local PATTERNS = { -- /products/123 → /shop/item/123 { from = "^/products/(%d+)$", to = "/shop/item/%1", code = 301 }, -- /news/2023/title → /blog/2023/title { from = "^/news/(%d+)/(.+)$", to = "/blog/%1/%2", code = 301 }, -- Remove trailing slash (except root) { from = "^(/[^?]+)/$", to = "%1", code = 301 }, } function process_request(request) local path = request.path or "/" -- ① Exact match local exact = EXACT[path] if exact then ngx.header["Location"] = exact.to return false, exact.code end -- ② Prefix match for _, rule in ipairs(PREFIX) do if string.find(path, rule.from, 1, true) == 1 then local suffix = path:sub(#rule.from + 1) ngx.header["Location"] = rule.to .. suffix return false, rule.code end end -- ③ Pattern match for _, rule in ipairs(PATTERNS) do local new_path = string.gsub(path, rule.from, rule.to) if new_path ~= path then ngx.header["Location"] = new_path return false, rule.code end end return true, 200 end
12 Testing
Diagnostic Script
function process_request(request) ngx.log(ngx.INFO, "=== DEBUG REQUEST ===") ngx.log(ngx.INFO, "IP: " .. tostring(request.remote_ip)) ngx.log(ngx.INFO, "Method: " .. tostring(request.method)) ngx.log(ngx.INFO, "Path: " .. tostring(request.path)) ngx.log(ngx.INFO, "UA: " .. tostring(request.user_agent)) ngx.log(ngx.INFO, "Country: " .. tostring(request.client_country)) ngx.log(ngx.INFO, "Score: " .. tostring(request.anomaly_score)) return true, 200 end
Shell Commands
# Watch Lua log output sudo tail -f /var/log/clofix/waf.log | grep -i '\[LUA\]' # Normal request curl https://your-domain.com/ # Simulate bot user-agent curl -A 'python-requests/2.28' https://your-domain.com/ # SQL injection in query curl 'https://your-domain.com/?id=1%20UNION%20SELECT%20*%20FROM%20users' # Rate limit stress test (200 concurrent) for i in $(seq 1 200); do curl -s -o /dev/null https://your-domain.com/ & done; wait # Validate script syntax before deploying luac -p /etc/clofix/lua_script/your-domain.com/script.lua
13 Troubleshooting
| Error / Symptom | Cause | Fix |
|---|---|---|
| attempt to call non-function | Missing process_request | Add: function process_request(request) … end |
| attempt to index global 'ngx' | Dict not declared in config | Add lua_shared_dict <n> <size>m; to domain config |
| nil value on request field | Field unavailable | Guard: local x = field or "default" |
| bad return value | Wrong return types | Always return boolean, number |
| stack overflow | Infinite recursion | Check for recursive function calls |
| script not loading | Syntax error in .lua file | Run: luac -p /path/to/script.lua |
| changes not taking effect | File not saved to disk | Scripts reload per-request; verify file was saved |
Nil Guard Pattern
function process_request(request) local ip = request.remote_ip or "unknown" local path = request.path or "/" local country = request.client_country or "XX" local score = request.anomaly_score or 0 -- all four are now safe to use return true, 200 end
14 Best Practices
- One script per concern — keep
rate_limit.lua,bot_detection.lua,geo_block.luaseparate for clarity and easy toggling - Guard every field — use
local x = field or defaultbefore using any request value to prevent nil-concatenation errors - Set TTLs on all shared-dict keys — prevents unbounded memory growth in long-running WAF processes
- Return early — check the cheapest condition first (e.g., block-list lookup) before heavy pattern matching
- Remove debug logs before production — delete all
ngx.log(ngx.DEBUG, ...)calls to avoid log flooding - Use
os.time()for timestamps — never roll your own counter; use the standard library - Validate with
luac -pbefore deploying any script to catch syntax errors before they reach production - Monitor
anomaly_score— combine WAF scoring with your own per-script thresholds for layered defence - CONFIG tables at the top — put all tuneable values in a local CONFIG table so operators can adjust without touching logic
- Start minimal — get a working allow-all script first, then layer in security rules one at a time