在Nginx层面用Lua编写防注入规则,本质上是在请求到达后端应用之前,利用Lua对HTTP请求的各个部分进行精细化的安全检测和过滤。这不同于在应用代码里做防御,Nginx+Lua的组合能拦截更早、效率更高,而且对应用透明。最核心的思路是:通过"ngx.req.get_uri_args()"、"ngx.req.get_post_args()"、"ngx.req.get_headers()"等API获取原始输入,然后用Lua强大的字符串和模式匹配能力进行检测,一旦命中恶意特征,立即通过"ngx.exit()"中断请求,并记录详细的安全日志。

环境准备与基础架构

要开始编写,你的Nginx必须编译了OpenResty或ngx_lua模块。在"nginx.conf"的"http"或"server"块中,你需要通过"access_by_lua_block"或"access_by_lua_file"指令来嵌入或引用Lua脚本。"access"阶段是执行防注入逻辑的最佳位置,因为它处于请求解析完成之后、内容处理之前。一个典型的结构如下:

http {
    lua_shared_dict security_log 10m;  # 用于存储统计或临时黑名单的共享内存

    server {
        listen 80;
        server_name example.com;

        location / {
            access_by_lua_block {
                -- 你的防注入规则代码放在这里
            }
            proxy_pass http://backend;
        }
    }
}

使用"lua_shared_dict"定义一块共享内存很有必要,它可以用来做频率限制、IP临时封禁或缓存某些中间状态,避免每次请求都进行重复的复杂计算。

SQL注入过滤的核心规则设计

SQL注入的检测不能只靠简单的关键字匹配,那样误报率极高。我们需要结合上下文特征。最有效的方法是检测SQL语句中的“控制命令”与“特殊字符”的组合模式。不要直接拦截单独的"select"或"union",而是要拦截那些明显构成SQL语句骨架的模式。例如,检测"union"后面紧跟"select",并且中间可能混有空白字符和注释符。

local function detect_sqli(val)
    if type(val) ~= "string" then return false end
    -- 转换为小写进行匹配
    local v = string.lower(val)
    -- 核心检测模式:union select 组合,容忍各种空白和注释
    if ngx.re.match(v, [[union\s+/?\*.*?\*/\s*select]]) or
       ngx.re.match(v, [[union\s+select]]) then
        return true
    end
    -- 检测常见的注入探测语句:and 1=1, or 1=1, ' or '1'='1
    if ngx.re.match(v, [[\b(and|or)\b\s+\d+\s*=\s*\d+]]) or
       ngx.re.match(v, [[['"]\s+or\s+['"]\d+['"]\s*=\s*['"]\d+['"] ]]) then
        return true
    end
    -- 检测堆叠查询:分号后跟SQL命令
    if ngx.re.match(v, [[;\s*(select|insert|update|delete|drop|alter|create|exec|execute|truncate|declare)\b]]) then
        return true
    end
    -- 检测数据库系统表访问:information_schema, master.., sysobjects
    if ngx.re.match(v, [[\b(information_schema|master\.\.|sysobjects|sys\.tables|mysql\.db)\b]]) then
        return true
    end
    -- 检测注释符滥用://, -- , #
    if ngx.re.match(v, [[/\*!?\d*]]) or ngx.re.match(v, [[\s--\s*\w+]]) then
        return true
    end
    return false
end

这段代码展示了一个分层的检测逻辑。它没有一刀切地拦截"select",而是拦截"union select"组合、经典的永真式注入、堆叠查询、系统表探测以及注释符滥用。每一层都针对一种具体的攻击手法,这样既能保证拦截效果,又能大幅降低对正常业务参数(比如文章内容里包含"select"这个词)的误伤。

跨站脚本攻击(XSS)的过滤策略

XSS过滤的难点在于攻击载荷的编码多样性。攻击者会使用HTML实体编码、URL编码、Unicode编码来绕过简单的关键字匹配。因此,在Lua层做XSS过滤,最佳实践是先对参数值进行递归解码,然后再检测危险标签和事件属性。但要注意,解码操作本身可能消耗CPU,所以需要设定解码次数上限。

local function decode_uri(s)
    if type(s) ~= "string" then return s end
    local count = 0
    local decoded = s
    -- 最多解码3层,防止循环解码攻击
    while count < 3 do
        local new_decoded = ngx.unescape_uri(decoded)
        if new_decoded == decoded then
            break
        end
        decoded = new_decoded
        count = count + 1
    end
    return decoded
end

local function detect_xss(val)
    if type(val) ~= "string" then return false end
    local decoded = decode_uri(val)
    local v = string.lower(decoded)
    -- 检测完整脚本标签
    if ngx.re.match(v, [[]*>.*?]]) then
        return true
    end
    -- 检测事件处理器属性,这是最常见的XSS载体
    if ngx.re.match(v, [[\bon\w+\s*=\s*["']?\s*\w+\s*\(.*?\)]]) then
        return true
    end
    -- 检测伪协议:javascript:, data:text/html, vbscript:
    if ngx.re.match(v, [[\b(javascript|vbscript|data)\s*:]]) then
        return true
    end
    -- 检测常见标签注入:iframe, embed, object, link, meta 等
    if ngx.re.match(v, [[<(iframe|embed|object|link|meta|style|applet|frame|frameset|ilayer|layer|bgsound|title|base)[^>]*>]]) then
        return true
    end
    -- 检测表达式注入:expression(, -moz-binding, eval(
    if ngx.re.match(v, [[\b(expression|eval|behaviour)\s*\(]]) or
       ngx.re.match(v, [[-moz-binding\s*:]]) then
        return true
    end
    return false
end

这段代码的关键在于先进行递归URL解码,因为攻击者经常将载荷编码为"%3Cscript%3E"的形式来绕过检测。解码后再用一系列正则表达式匹配已知的XSS模式。注意,我们检测的是"on事件="这种结构,而不是简单的"<script>",因为现代XSS攻击更多利用事件处理器。同时,对"javascript:"伪协议和CSS表达式的检测,覆盖了通过样式表或链接发起的攻击。

命令注入与路径遍历的防护

命令注入和路径遍历通常发生在参数被传递给系统命令或文件操作时。在Lua层,我们可以通过检测参数中是否包含系统命令分隔符、管道符、反引号以及路径遍历序列来提前拦截。

local function detect_cmdi(val)
    if type(val) ~= "string" then return false end
    local v = string.lower(val)
    -- 检测系统命令分隔符和管道符:; | & && ||
    if ngx.re.match(v, [[[;&|`$()\n\r\t]+]]) then
        -- 但需要排除一些正常情况,比如参数中可能包含单个&作为文本
        -- 所以更精确的检测是看这些符号后面是否跟着系统命令
        if ngx.re.match(v, [[[;&|`]\s*(cat|ls|id|whoami|pwd|uname|wget|curl|nc|bash|sh|python|perl|ruby|php|telnet|ssh)\b]]) then
            return true
        end
    end
    -- 检测反引号命令执行
    if ngx.re.match(v, [[`[^`]+`]]) then
        return true
    end
    -- 检测路径遍历:../, ..\, 以及编码后的形式
    if ngx.re.match(v, [[\.\./]]) or ngx.re.match(v, [[\.\.\\]]) or
       ngx.re.match(v, [[%2e%2e%2f]]) or ngx.re.match(v, [[%252e%252e%252f]]) then
        return true
    end
    -- 检测空字节注入
    if ngx.re.match(v, [[%00]]) then
        return true
    end
    return false
end

这里没有简单地拦截所有分号或管道符,因为很多正常参数(比如文本中的分号)会触发误报。更聪明的做法是检测这些分隔符后面是否紧跟着常见的系统命令名。对于路径遍历,不仅要检测明文"../",还要检测各种编码变体,包括二次编码,因为攻击者会利用Nginx或应用层的解码差异来绕过。

构建完整的请求过滤流程

有了上述三个核心检测函数,我们需要将它们整合到一个完整的请求过滤流程中。这个流程应该遍历GET参数、POST参数以及特定的HTTP头(如Cookie、User-Agent、Referer),因为注入攻击可能发生在请求的任何部分。

access_by_lua_block {
    local function log_and_reject(reason, detail)
        local client_ip = ngx.var.remote_addr
        local request_uri = ngx.var.request_uri
        local log_msg = string.format("WAF Blocked - IP: %s, URI: %s, Reason: %s, Detail: %s",
                                      client_ip, request_uri, reason, detail)
        ngx.log(ngx.WARN, log_msg)
        -- 可以在这里将IP加入共享内存的临时黑名单
        local blacklist = ngx.shared.security_log
        local count, err = blacklist:incr(client_ip, 1, 0, 300) -- 5分钟过期
        if count and count > 10 then
            ngx.log(ngx.ERR, "IP " .. client_ip .. " exceeded threshold, blocking for longer period.")
            blacklist:set(client_ip, count, 3600) -- 超过阈值,封禁1小时
        end
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end

    -- 定义需要检测的参数和头部
    local args = ngx.req.get_uri_args()
    local headers_to_check = {"cookie", "user-agent", "referer", "x-forwarded-for", "x-real-ip"}

    -- 遍历GET参数
    for key, val in pairs(args) do
        if type(val) == "table" then
            for _, v in ipairs(val) do
                if detect_sqli(v) then log_and_reject("SQLi", "GET param: " .. key .. " = " .. v) end
                if detect_xss(v) then log_and_reject("XSS", "GET param: " .. key .. " = " .. v) end
                if detect_cmdi(v) then log_and_reject("CMDi", "GET param: " .. key .. " = " .. v) end
            end
        else
            if detect_sqli(val) then log_and_reject("SQLi", "GET param: " .. key .. " = " .. val) end
            if detect_xss(val) then log_and_reject("XSS", "GET param: " .. key .. " = " .. val) end
            if detect_cmdi(val) then log_and_reject("CMDi", "GET param: " .. key .. " = " .. val) end
        end
    end

    -- 遍历POST参数,仅当请求方法为POST且Content-Type为application/x-www-form-urlencoded或multipart时
    if ngx.req.get_method() == "POST" then
        local content_type = ngx.var.content_type or ""
        if string.find(content_type, "application/x-www-form-urlencoded") or
           string.find(content_type, "multipart/form-data") then
            ngx.req.read_body()
            local post_args, err = ngx.req.get_post_args()
            if post_args then
                for key, val in pairs(post_args) do
                    if type(val) == "table" then
                        for _, v in ipairs(val) do
                            if detect_sqli(v) then log_and_reject("SQLi", "POST param: " .. key .. " = " .. v) end
                            if detect_xss(v) then log_and_reject("XSS", "POST param: " .. key .. " = " .. v) end
                            if detect_cmdi(v) then log_and_reject("CMDi", "POST param: " .. key .. " = " .. v) end
                        end
                    else
                        if detect_sqli(val) then log_and_reject("SQLi", "POST param: " .. key .. " = " .. val) end
                        if detect_xss(val) then log_and_reject("XSS", "POST param: " .. key .. " = " .. val) end
                        if detect_cmdi(val) then log_and_reject("CMDi", "POST param: " .. key .. " = " .. val) end
                    end
                end
            end
        end
    end

    -- 遍历敏感HTTP头
    for _, header_name in ipairs(headers_to_check) do
        local header_val = ngx.req.get_headers()[header_name]
        if header_val then
            if type(header_val) == "table" then
                for _, v in ipairs(header_val) do
                    if detect_sqli(v) then log_and_reject("SQLi", "Header: " .. header_name .. " = " .. v) end
                    if detect_xss(v) then log_and_reject("XSS", "Header: " .. header_name .. " = " .. v) end
                    if detect_cmdi(v) then log_and_reject("CMDi", "Header: " .. header_name .. " = " .. v) end
                end
            else
                if detect_sqli(header_val) then log_and_reject("SQLi", "Header: " .. header_name .. " = " .. header_val) end
                if detect_xss(header_val) then log_and_reject("XSS", "Header: " .. header_name .. " = " .. header_val) end
                if detect_cmdi(header_val) then log_and_reject("CMDi", "Header: " .. header_name .. " = " .. header_val) end
            end
        end
    end
}

这个完整流程展示了一个生产级别的WAF雏形。它有几个关键设计:首先,"log_and_reject"函数不仅记录日志,还利用共享内存实现了一个简单的IP频率计数器。当某个IP在5分钟内触发规则超过10次,就将其封禁1小时。这是一种动态的、自适应的防护策略。其次,在处理POST参数时,必须显式调用"ngx.req.read_body()"来读取请求体,并且只对常见的表单类型进行解析,避免对JSON或XML等非表单格式进行不必要的参数遍历,这能防止Lua代码因解析大文件上传而消耗过多内存。最后,对HTTP头的检测覆盖了Cookie、User-Agent、Referer等常见注入载体,因为很多攻击会伪装在这些字段中。

性能优化与误报处理

在Nginx层面运行Lua代码,性能是必须考虑的因素。上述正则匹配如果对每个参数都执行,在高并发下会带来明显的CPU开销。优化策略包括:使用"ngx.re.match"时尽量指定""jo""选项(JIT编译和缓存),LuaJIT对正则的JIT优化效果显著。另外,可以对参数值长度进行预判,如果长度超过某个阈值(比如1024字节),就跳过详细检测,或者只进行简单的关键字匹配,因为正常业务参数很少会那么长。对于误报处理,最有效的办法是引入白名单机制。在检测逻辑之前,先检查请求URI或参数名是否在白名单中。白名单可以存储在共享内存或Redis中,动态更新。

-- 简单的URI白名单示例
local white_list_uris = {
    "/api/healthcheck",
    "/admin/config/update"  -- 管理员已知的安全接口
}
local function is_whitelisted()
    for _, uri in ipairs(white_list_uris) do
        if ngx.var.uri == uri then
            return true
        end
    end
    return false
end

if not is_whitelisted() then
    -- 执行过滤逻辑
end

更高级的做法是,在检测到潜在攻击但不确定时,不直接返回403,而是返回一个验证码页面或插入一个JavaScript挑战(类似Cookie挑战)。这在Lua中可以通过"ngx.location.capture"转发到内部的验证服务来实现,但复杂度较高。对于大多数场景,记录详细日志并定期分析误报,迭代优化规则,是最务实的做法。

日志记录与监控集成

防注入规则的价值不仅在于拦截,还在于提供可审计的安全日志。上面的"log_and_reject"函数使用了"ngx.log",但生产环境建议将安全日志单独输出到文件或发送到集中式日志系统。你可以通过"lua-resty-logger-socket"库将日志以JSON格式发送到远端的日志收集器,便于后续分析和告警。日志中应包含:时间戳、客户端IP、请求URI、命中的规则类型、完整的攻击载荷以及请求ID。有了这些数据,你就能构建攻击态势的实时监控仪表盘,及时发现新的攻击手法并调整规则。

规则集的持续更新与防护深度

没有任何一套静态规则能永远有效。攻击者在不断进化,你的Lua规则也需要跟着迭代。建议将规则配置外部化,比如放到Redis或数据库中,Lua脚本启动时加载,并定期刷新。这样可以在不重启Nginx的情况下更新规则。同时,要理解Nginx层的WAF是纵深防御的一环,它不能替代应用层的输入验证和参数化查询。它的优势在于低成本地拦截大量自动化扫描和已知攻击模式,为后端应用减轻压力。将Lua WAF与RASP(运行时应用自我保护)或应用内安全中间件结合,才能构建更坚固的防御体系。