OpenResty WAF 与安全防护实战:用 Lua 构建 Web 防火墙

在 OpenResty 上用 Lua 实现 Web 应用防火墙:请求生命周期各阶段的安全介入点、滑动窗口限流与令牌桶、IP 黑名单与动态封禁、参数校验与防注入,以及 lua-resty-waf 规则引擎的接入与安全注意事项。

网关层安全的意义

WAF(Web 应用防火墙)要解决的,是把「恶意请求」挡在业务代码之前。SQL 注入、XSS、爬虫刷接口、撞库攻击,如果全部放到应用层处理,既消耗业务资源,又让攻击面散落在每个接口里。OpenResty 把 Lua 虚拟机嵌进 Nginx,让安全逻辑可以在请求进入上游之前执行——这正是网关安全的天然位置。

与 OpenResty 网关开发实战 中讲的限流、鉴权、路由不同,本文专注「安全防护」这个子域:请求生命周期的安全介入点、限流与防刷、IP 封禁、参数校验与防注入,最后接入 lua-resty-waf 规则引擎并讨论安全边界。

-- 安全防护的黄金法则:越早拦截,成本越低
-- 攻击请求在网关层 403,连上游连接都不会建立
ngx.exit(ngx.HTTP_FORBIDDEN)

一个朴素事实:WAF 不是银弹。它能挡住已知模式,但挡不住业务逻辑漏洞。网关 WAF 是纵深防御的第一道墙,不是最后一道。

请求生命周期与安全介入点

OpenResty 的请求阶段为安全逻辑提供了精确的挂载点,不同威胁适合在不同阶段拦截:

阶段指令适合拦截的威胁能否做网络 IO
rewriterewrite_by_lua_blockURL 规范化、路径穿越是
accessaccess_by_lua_block限流、黑名单、参数校验、防注入是
contentcontent_by_lua_block生成错误页、验证码是
header_filterheader_filter_by_lua_block防泄露响应头、加安全头否
body_filterbody_filter_by_lua_block响应体关键字过滤、防敏感数据出网否
loglog_by_lua_block攻击日志、指标上报是

核心实践:限流、黑名单、注入检测放 access 阶段,因为这里是请求真正进入业务逻辑前的最后一道关卡,且允许做 Redis 查询等 IO;安全响应头、敏感信息脱敏放 header/body_filter 阶段,这两个阶段虽然不能做 IO,但适合改写响应。

http {
    lua_shared_dict waf_blacklist 10m;
    lua_shared_dict waf_ratelimit 100m;

    server {
        listen 8080;
        location / {
            access_by_lua_block {
                -- 安全链:黑名单 -> 限流 -> 参数校验
                require("waf.blacklist").check()
                require("waf.ratelimit").check()
                require("waf.paramcheck").check()
            }
            proxy_pass http://backend;
        }
    }
}

限流与防刷

限流是把「单 IP / 单用户」的请求频率压到阈值之下。与网关篇介绍的现成 lua-resty-limit-req 不同,这里实现一个更灵活的滑动窗口计数,方便你按自己的维度(IP、用户 ID、UA)扩展。

-- waf/ratelimit.lua:滑动窗口限流
local shared = ngx.shared.waf_ratelimit

local WINDOW = 60          -- 窗口 60 秒
local LIMIT = 120          -- 每窗口最多 120 次

local _M = {}

function _M.check(key, limit, window)
    limit = limit or LIMIT
    window = window or WINDOW

    local now = ngx.now()
    local slot = math.floor(now / window)
    local cur_key = key .. ":" .. slot
    local prev_key = key .. ":" .. (slot - 1)

    -- 当前窗口计数
    local cur = shared:incr(cur_key, 1, 0, window + 1)
    -- 上一窗口计数(用于平滑)
    local prev = shared:get(prev_key) or 0
    -- 按窗口剩余时间加权
    local remain = (slot + 1) * window - now
    local weight = remain / window

    if cur + prev * weight > limit then
        ngx.log(ngx.WARN, "rate limited: ", key)
        ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)   -- 429
    end
end

return _M

使用方只需要传「限流键」:

-- access 阶段:按 IP + 路径维度限流
local ratelimit = require("waf.ratelimit")
ratelimit.check(ngx.var.binary_remote_addr .. ngx.var.uri, 100, 60)

若需要令牌桶语义(允许突发、平滑放行),可用 lua-resty-limit-traffic 的组合,或给滑动窗口换成令牌桶计数。限流的阈值要留足正常业务余量,误伤真实用户比放过攻击更伤口碑。

IP 黑名单与动态封禁

黑名单分两类:静态的恶意 IP 列表,以及由行为触发的动态封禁。动态封禁的思路是「计分制」:连续失败、超频访问、触发注入规则都累加分数,达到阈值就封禁一段时间。

-- waf/blacklist.lua:计分制动态封禁
local shared = ngx.shared.waf_blacklist
local BLOCK_TIME = 3600        -- 封禁 1 小时
local SCORE_LIMIT = 5          -- 累计 5 分触发封禁

local _M = {}

function _M.ban(ip, reason)
    shared:set("block:" .. ip, true, BLOCK_TIME)
    ngx.log(ngx.ERR, "ban ip: ", ip, " reason: ", reason)
end

function _M.check(ip)
    -- 先查静态黑名单,再查动态封禁
    if shared:get("static:" .. ip) then
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end
    if shared:get("block:" .. ip) then
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end
end

function _M.score(ip, add)
    -- 每次违规累加分数,达到阈值自动封禁
    local key = "score:" .. ip
    local s = shared:incr(key, add, 0, BLOCK_TIME)
    if s >= SCORE_LIMIT then
        _M.ban(ip, "score reached " .. s)
        shared:delete(key)
    end
    return s
end

return _M
-- 使用:登录接口连续失败 5 次即封禁
local blacklist = require("waf.blacklist")
local ip = ngx.var.binary_remote_addr

blacklist.check(ip)

local pwd_ok = verify_password(ngx.var.arg_user, ngx.var.arg_pass)
if not pwd_ok then
    blacklist.score(ip, 1)      -- 记一次失败
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

动态封禁要注意三点:一是用共享字典跨 worker 计数,否则每个 Nginx worker 各记各的,攻击者换连接就能绕开;二是封禁要有过期时间,永久封禁会把误伤永久化;三是IP 可能被共享(NAT、代理),封禁粒度要能按业务调整(IP、用户、设备指纹组合)。

参数校验与防注入

注入攻击的本质是「把恶意输入当成了代码」。防注入在网关层能做两件事:一是校验参数合法性(类型、长度、枚举),二是识别注入特征模式(SQL 关键字、XSS 脚本标记)。

-- waf/paramcheck.lua:参数校验 + 注入特征检测
local ngx_re = require("ngx.re")

local _M = {}

-- 注入特征:覆盖常见 SQLi 与 XSS 向量
local SUSPICIOUS = {
    "['\"]%s*(or|and)%s+%d",           -- ' or 1=1
    "%s+union%s+select",               -- union select
    "insert%s+into",                    -- insert into
    "drop%s+table",                     -- drop table
    "<script[^>]*>",                    -- <script>
    "javascript%s*:",
    "onerror%s*=",
}

local function is_suspicious(value)
    if type(value) ~= "string" then return false end
    for _, pat in ipairs(SUSPICIOUS) do
        if ngx_re.find(value, pat, "isj") then
            return true
        end
    end
    return false
end

function _M.check()
    local args = ngx.req.get_uri_args()
    for k, v in pairs(args) do
        -- 过滤危险键名
        if k:find("__proto__", 1, true) or k:find("constructor", 1, true) then
            ngx.log(ngx.ERR, "proto pollution attempt: ", k)
            ngx.exit(ngx.HTTP_FORBIDDEN)
        end
        -- 遍历单个值与 table 值
        if type(v) == "table" then
            for _, item in ipairs(v) do
                if is_suspicious(item) then
                    ngx.exit(ngx.HTTP_FORBIDDEN)
                end
            end
        elseif is_suspicious(v) then
            ngx.exit(ngx.HTTP_FORBIDDEN)
        end
    end

    -- 请求体(表单/JSON)的校验
    local body = ngx.req.read_body()
    local data = ngx.req.get_body_data()
    if data and is_suspicious(data) then
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end
end

return _M

更严格的做法是白名单校验:对每个接口声明参数的类型与范围,不符合直接拒绝。比如「用户 ID 必须是 1-8 位数字」,用一个 type 描述表驱动:

local PARAM_RULES = {
    ["/api/user"] = {
        id   = { kind = "int", min = 1, max = 99999999 },
        name = { kind = "string", maxlen = 32 },
    },
}

function _M.validate(uri, args)
    local rules = PARAM_RULES[uri]
    if not rules then return true end
    for key, rule in pairs(rules) do
        local v = args[key]
        if v == nil then
            if rule.required then return false end
        else
            if rule.kind == "int" and not v:match("^%d+$") then return false end
            if rule.kind == "string" and #v > (rule.maxlen or 64) then return false end
        end
    end
    return true
end

注意:特征匹配会误伤,is_suspicious 里的模式命中不代表一定是攻击(比如用户名就叫 drop_table)。网关层做第一道粗过滤,业务层仍要使用参数化查询——注入的根治在数据库访问层,不在 WAF。关于 Redis 脚本与 Lua 里做数据访问的原子性,可参考 Redis Lua 脚本实战。

接入 lua-resty-waf

自己写规则费时费力,社区方案 lua-resty-waf 提供了一套完整的规则引擎:内置 OWASP 规则集,支持 SQLi、XSS、LFI、RFI 等分类,可用 config 灵活开关。

# 安装依赖后配置 lua-resty-waf
http {
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";

    server {
        listen 8080;
        location / {
            access_by_lua_block {
                local waf = require("resty.waf")
                local waf_instance = waf:new()

                waf_instance:set_option("mode", "ACTIVE")     -- ACTIVE 拦截 / SIMULATE 只记录
                waf_instance:set_option("debug", false)
                waf_instance:set_option("score_threshold", 5)
                waf_instance:set_option("storage", "none")    -- 或用 "cookies"

                waf_instance:exec()
            }
            proxy_pass http://backend;
        }
    }
}

关键配置项:

配置说明
modeACTIVE 拦截,SIMULATE 只记日志不拦截,上线前先用 SIMULATE 观察误伤
score_threshold累计风险分数达到阈值才拦截,降低单条规则误杀
storage风险计数存储位置,跨请求累计需要 cookie 或外部存储
debug开启后输出每步决策日志,方便排查

lua-resty-waf 支持自定义规则,规则文件按 category/rule_id 组织:

{
    "rules": {
        "custom": {
            "block_healthz": {
                "op": "contains",
                "input": "PATH",
                "pattern": "/debug/healthz",
                "action": "DENY"
            }
        }
    }
}

接入现成 WAF 的收益是「开箱即用的规则库」,代价是规则库的误报需要持续调优。生产上强烈建议先 SIMULATE 模式跑一段时间,把日志里的正常流量误报清理干净,再切 ACTIVE。

安全注意事项

在网关层做安全防护,需注意以下要点:

  • 纵深防御:WAF 挡已知攻击,参数化查询根治注入,业务层仍要校验权限,缺一不可。
  • 先观察再拦截:任何新规则先跑 SIMULATE,误伤真实用户比放过攻击代价更高。
  • 共享字典是单点:lua_shared_dict 有内存上限,黑名单与计数要设过期,写满后新 key 会被淘汰。
  • IP 不等于人:NAT、代理、爬虫池会让 IP 维度误伤,结合用户 ID、UA、Cookie 多维度计分。
  • 特征匹配有误差:正则规则能漏也能误,网关粗筛 + 业务细验,别把安全全押在正则上。
  • 攻击日志要外置:log_by_lua 里把攻击事件打到独立日志或消息队列,别让安全日志淹没在访问日志里。
  • 保护 WAF 自身:安全模块要容错,用 pcall 包裹,WAF 挂掉不应拖垮整个网关。
  • 不要信任客户端:请求头、Cookie、Referer 都能伪造,校验参数只信「服务端能验证的东西」。

常见问题(FAQ)

lua-resty-waf 和 ModSecurity 怎么选?

ModSecurity 是传统 WAF 标准,规则生态最全但配置重、性能开销大;lua-resty-waf 轻量、与 OpenResty 一体化、可用 Lua 深度定制,适合自研网关。如果已有成熟的 ModSecurity 运维体系可沿用,否则在 OpenResty 生态里 lua-resty-waf 更顺手。

正则匹配会拖慢网关吗?

会。每请求跑十几条正则是有成本的,ngx.re.find 用的是 PCRE,比纯 Lua 快,但仍是开销。优化方向:优先校验参数类型与长度(白名单,几乎零成本),特征匹配放在其后;只对「外部输入」匹配,不对自己生成的数据重复扫描;规则数量控制在必要范围。

黑名单数据量大怎么办?

共享字典存不下海量 IP 时,分层处理:高频命中的热 IP 放 lua_shared_dict,全量名单放 Redis 或外部数据库,网关只查热点 + 定期同步。Redis 的脚本与过期机制见 Redis Lua 脚本实战。封禁记录务必带过期时间,避免死数据撑爆内存。

WAF 误伤正常流量怎么办?

先切成 SIMULATE 模式看日志,找出误报规则,调整规则粒度或阈值。给 WAF 加「可信白名单」:内网 IP、健康检查、已认证用户跳过特征匹配。误报处理要快,否则一次误伤就可能损失用户信任。

防注入只靠 WAF 够吗?

不够。WAF 的正则只能挡住「已知特征」,绕过的变体、编码组合、业务逻辑漏洞它都看不见。注入的根治手段永远是参数化查询(SQL 预编译、Redis 原子脚本),WAF 只是第一道减速带。把安全预期定在「多层防御」,而不是「一道 WAF 全搞定」。

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「lua」更多文章

  1. Lua 剖析与调试工具链:从 luaprofiler 到火焰图
  2. Lua 设计模式落地:用 table 与元表实现经典模式
  3. Lua 与 Rust、Go 的互操作实践:mlua 与 gopher-lua