网关层安全的意义
WAF(Web 应用防火墙)要解决的,是把「恶意请求」挡在业务代码之前。SQL 注入、XSS、爬虫刷接口、撞库攻击,如果全部放到应用层处理,既消耗业务资源,又让攻击面散落在每个接口里。OpenResty 把 Lua 虚拟机嵌进 Nginx,让安全逻辑可以在请求进入上游之前执行——这正是网关安全的天然位置。
与 OpenResty 网关开发实战 中讲的限流、鉴权、路由不同,本文专注「安全防护」这个子域:请求生命周期的安全介入点、限流与防刷、IP 封禁、参数校验与防注入,最后接入 lua-resty-waf 规则引擎并讨论安全边界。
-- 安全防护的黄金法则:越早拦截,成本越低
-- 攻击请求在网关层 403,连上游连接都不会建立
ngx.exit(ngx.HTTP_FORBIDDEN)
一个朴素事实:WAF 不是银弹。它能挡住已知模式,但挡不住业务逻辑漏洞。网关 WAF 是纵深防御的第一道墙,不是最后一道。
请求生命周期与安全介入点
OpenResty 的请求阶段为安全逻辑提供了精确的挂载点,不同威胁适合在不同阶段拦截:
| 阶段 | 指令 | 适合拦截的威胁 | 能否做网络 IO |
|---|---|---|---|
| rewrite | rewrite_by_lua_block | URL 规范化、路径穿越 | 是 |
| access | access_by_lua_block | 限流、黑名单、参数校验、防注入 | 是 |
| content | content_by_lua_block | 生成错误页、验证码 | 是 |
| header_filter | header_filter_by_lua_block | 防泄露响应头、加安全头 | 否 |
| body_filter | body_filter_by_lua_block | 响应体关键字过滤、防敏感数据出网 | 否 |
| log | log_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;
}
}
}
关键配置项:
| 配置 | 说明 |
|---|---|
mode | ACTIVE 拦截,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 全搞定」。
相关阅读
- OpenResty 网关开发实战:用 Lua 构建高性能 API 网关
- Lua 在 Web 开发中的应用场景与优势
- Redis Lua 脚本实战:原子操作与服务端逻辑
- Lua 环境与沙箱:安全执行不可信代码
- Lua 性能优化实战指南
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。