Lua SDK

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.

Per-request execution Thread-safe shared dicts GeoIP & threat fields ngx.shared API Rate limiting & DDoS Bot & scanner detection XSS / SQLi / CMDi Brute force protection WordPress shield Tor / VPN blocking

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.

Critical Behaviour If any script returns 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 .lua files 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) and status_code (number)

02 Directory Structure

CloFix creates the domain directory automatically when Lua is enabled in the dashboard. You can also create it manually:

filesystem
/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
shell
# 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:

nginx config
# /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
    }
}
Note Each 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

lua
function process_request(request)
    -- Your logic here
    return true, 200   -- Allow
end

Full Template

lua
-- ① 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

FieldTypeDescriptionExample
methodstringHTTP method"GET", "POST"
uristringFull URI with query string"/page?id=1"
pathstringURI path only"/page"
protocolstringHTTP version"HTTP/1.1"
remote_ipstringClient IP address"203.0.113.5"
portnumberClient source port54321
remote_addrstringIP:port combined"203.0.113.5:54321"
timestampnumberUnix timestamp1709123456
bodystringRaw request body"name=alice"
hoststringHost header"example.com"
user_agentstringUser-Agent header"Mozilla/5.0 …"
refererstringReferer header"https://…"
content_typestringContent-Type header"application/json"
schemestringProtocol scheme"https"
is_tlsbooleanHTTPS connection flagtrue
tls_versionstringTLS version string"TLS 1.3"
request_idstringUnique request ID"abc123…"
anomaly_scorenumberWAF cumulative score (higher = riskier)42

Table Fields — headers, cookies, query, form

lua
-- 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

FieldTypeDescription
client_countrystringISO country code — e.g. "US", "BD"
client_regionstringRegion / state name
client_citystringCity name
asnstringAutonomous System Number
orgstringISP / Organisation
is_torbooleantrue if Tor exit node
is_cloud_providerbooleantrue if AWS / GCP / Azure IP
is_vpnbooleantrue if VPN / proxy detected
is_botbooleantrue if WAF bot detection triggered
is_headlessbooleantrue 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

lua
local dict = ngx.shared.ddos_attack   -- name must match config
local rl   = ngx.shared.rate_limit

Methods

MethodSignatureReturnsDescription
getdict:get(key)value | nilRead value; nil if missing or expired
setdict:set(key, value, ttl?)Store value with optional TTL in seconds
incrdict:incr(key, delta)new numberAtomically increment a numeric counter
deletedict:delete(key)Remove a key immediately
flush_alldict:flush_all()Clear all keys in the dictionary
ttldict:ttl(key)secondsRemaining TTL (0 if no expiry set)

Common Patterns

lua
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.

ConstantLevelUse for
ngx.DEBUGDEBUGInternal state — remove before production
ngx.INFOINFONormal informational events
ngx.WARNWARNBlocked requests, suspicious activity
ngx.ERRERRScript errors, unexpected conditions
lua
-- 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.

return true, 200
Allow — normal pass-through
return false, 403
Block — Forbidden
return false, 429
Block — Too Many Requests
return false, 401
Block — Unauthorised
return false, 418
Block — any custom code
Important When returning 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

rate_limit.lua
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

geo_block.lua
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

bot_detection.lua
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

path_protection.lua
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

sql_injection.lua
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

anomaly_threshold.lua
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

Config required No shared dict needed. Scans query params, form fields, and raw body for XSS payloads.
xss_protection.lua
-- 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

Config required No shared dict needed. Blocks ../ tricks, null bytes, and encoded traversal sequences.
path_traversal.lua
-- 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

Config required No shared dict needed. Catches shell metacharacters and common OS command injection probes.
cmd_injection.lua
-- 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

Config required lua_shared_dict ddos_attack 10m; — tracks per-IP login failures with progressive banning.
brute_force.lua
-- 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

Config required lua_shared_dict ddos_attack 10m; — combines per-second burst protection, connection flood detection, and progressive ban escalation.
ddos_shield.lua
-- 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

Config required No shared dict needed. Restricts allowed HTTP methods per path — essential for APIs and admin panels.
method_filter.lua
-- 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

Config required No shared dict needed. Rejects oversized requests that could indicate DoS or file upload bypass attempts.
request_size.lua
-- 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

Config required No shared dict needed. Uses WAF's built-in threat intelligence fields — no external API calls required.
anonymiser_block.lua
-- 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

Config required lua_shared_dict ddos_attack 10m; — blocks wp-login brute force, XML-RPC abuse, plugin probing, and author enumeration in one script.
wordpress_shield.lua
-- 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

Config required lua_shared_dict ddos_attack 10m; — detects automated scanners (Nmap, Nikto, sqlmap, Burp, nuclei, etc.) by user-agent, header patterns, and probe path signatures.
scanner_detect.lua
-- 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

Config required No shared dict needed. Protect any path with a shared secret — pass via query token ?token= or Authorization header. Token is stored in a cookie so users only authenticate once per session.
password_protect.lua
-- 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

Config required No shared dict needed. Enforce API key on any path prefix — accepts key via X-API-Key header or api_key query param. Supports per-key rate limits.
api_key_auth.lua
-- 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
Config required No shared dict needed. Prevents other websites from embedding your images, videos, and downloads — checks the Referer header against your allowed domain list.
hotlink_protect.lua
-- 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

Config required No shared dict needed. Blocks uploads of executable, script, and shell file types by checking both the file extension in the URL and the Content-Type header. Prevents webshell uploads.
file_type_filter.lua
-- 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

Config required No shared dict needed. Validates a CSRF token on all state-changing requests (POST/PUT/DELETE/PATCH). Accepts token from request header or form body — same-origin requests via Origin/Referer are also checked as fallback.
csrf_protect.lua
-- 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

Config required 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.lua
-- 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

Config required No shared dict needed. Puts your entire site in maintenance mode — only your whitelisted IPs and CIDRs can access it. Everyone else gets a 503.
maintenance_mode.lua
-- 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

Config required No shared dict needed. Replaces hundreds of nginx rewrite rules with a single Lua table — supports exact, prefix, and regex redirects with custom status codes.
redirects.lua
-- 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

debug_request.lua — remove before production
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

shell
# 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 / SymptomCauseFix
attempt to call non-functionMissing process_requestAdd: function process_request(request) … end
attempt to index global 'ngx'Dict not declared in configAdd lua_shared_dict <n> <size>m; to domain config
nil value on request fieldField unavailableGuard: local x = field or "default"
bad return valueWrong return typesAlways return boolean, number
stack overflowInfinite recursionCheck for recursive function calls
script not loadingSyntax error in .lua fileRun: luac -p /path/to/script.lua
changes not taking effectFile not saved to diskScripts reload per-request; verify file was saved

Nil Guard Pattern

lua
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

  1. One script per concern — keep rate_limit.lua, bot_detection.lua, geo_block.lua separate for clarity and easy toggling
  2. Guard every field — use local x = field or default before using any request value to prevent nil-concatenation errors
  3. Set TTLs on all shared-dict keys — prevents unbounded memory growth in long-running WAF processes
  4. Return early — check the cheapest condition first (e.g., block-list lookup) before heavy pattern matching
  5. Remove debug logs before production — delete all ngx.log(ngx.DEBUG, ...) calls to avoid log flooding
  6. Use os.time() for timestamps — never roll your own counter; use the standard library
  7. Validate with luac -p before deploying any script to catch syntax errors before they reach production
  8. Monitor anomaly_score — combine WAF scoring with your own per-script thresholds for layered defence
  9. CONFIG tables at the top — put all tuneable values in a local CONFIG table so operators can adjust without touching logic
  10. Start minimal — get a working allow-all script first, then layer in security rules one at a time
Need Help? For CloFix WAF support, configuration questions, or to report Lua scripting issues, visit the Support section or contact us.