NginxGuard:一款基于 Lua 的高性能轻量级 Web 应用防火墙

NginxGuard:一款基于 Lua 的高性能轻量级 Web 应用防火墙

在 Nginx 请求处理管道中嵌入 Lua,用不到 1000 行核心代码实现企业级 WAF 防护——支持基于域名的差异化规则、IPv6/CIDR 黑白名单、CC 防护、云凭据探测拦截,GET 流量吞吐仅下降约 16%。

为什么需要又一个 WAF?

市面上的 WAF 方案很多:Cloudflare 等 SaaS WAF 简单但黑盒、ModSecurity 功能强大但过重、商业硬件 WAF 价格不菲。很多团队的实际诉求其实很简单:

  • 轻量:不想引入额外进程,直接在现有 Nginx 上挂 Lua 脚本
  • 可控:规则文件就是纯文本正则,随时改随时生效,不需要重启
  • 多域名:不同域名需要不同防护策略,而不是一刀切
  • 高性能:不能因为加了 WAF 就让吞吐掉一半

NginxGuard 就是围绕这几个目标设计的。

项目架构

整个项目只有 4 个 Lua 文件和一组规则文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
waf/
├── config.lua # 全局配置(开关、日志路径、规则目录等)
├── init.lua # init_by_lua_file 预加载模块
├── access.lua # access_by_lua_file 每请求检测入口
├── lib.lua # 核心库(IP获取、规则加载、域名配置、日志、输出)
├── nginx-config/
│ └── nginx.conf # nginx 配置示例
└── rule-config/
├── domain.json # 域名级规则配置
├── args.rule # URL 参数规则
├── blackip.rule # 黑名单 IP
├── cdnip.rule # CDN/可信代理 IP 列表
├── cookie.rule # Cookie 规则
├── post.rule # POST 规则
├── url.rule # URL 规则
├── useragent.rule # User-Agent 规则
├── whiteip.rule # 白名单 IP
├── whiteua.rule # 白名单 UA(搜索引擎爬虫)
├── whiteurl.rule # 白名单 URL
├── referer.rule # Referer 规则
├── fileext.rule # 文件上传扩展名规则
└── domains/ # 域名专属规则目录
└── www.example.com/
└── ...(同构的规则文件)

工作原理

NginxGuard 挂载在 Nginx 的 access_by_lua_file 阶段,每个请求进入时依次执行安全检测:

1
2
3
# nginx.conf 关键配置
init_by_lua_file "/apps/nginx/conf/waf/init.lua";
access_by_lua_file "/apps/nginx/conf/waf/access.lua";

init.lua 在 Nginx 启动时预加载模块;access.lua 在每个请求的 access 阶段执行检测逻辑;lib.lua 是核心库,提供 IP 匹配、规则加载、日志记录等能力。

检测流程

每个请求按以下顺序依次检测,命中任一项即拦截并返回 403,后续检测不再执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
1. $waf_enable 检查(location 级开关,off 则跳过全部)

2. waf_enable 检查(域名级/全局级开关)

3. 白名单 IP 放行

4. 动态黑名单(CC 自动拉黑期内)

5. 静态黑名单 IP

6. 白名单 URL 放行

7. User-Agent 攻击检测(白名单 UA 跳过此项)

8. Referer 攻击检测

9. CC 限速

10. 文件上传扩展名检测

11. URL 路径攻击检测

12. URL 参数攻击检测

13. Cookie 攻击检测

14. POST 攻击检测(表单 + JSON body)

放行

整个流程包裹在 pcall 中,任何意外错误都被捕获并记录到 ngx.log(ngx.ERR),不会导致 500 错误返回给客户端。

核心特性详解

1. 基于域名的差异化规则

这是 NginxGuard 最实用的功能之一。通过 domain.json 配置文件,可以为不同域名设置不同的防护策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"www.example.com": {
"url_check": "off",
"cc_rate": "100/60",
"rule_dir": "domains/www.example.com"
},
"api.example.com": {
"waf_enable": "off"
},
"*.test.com": {
"post_check": "off",
"cookie_check": "off"
}
}

配置优先级location 级 > 域名级 > 全局级

  • config.lua 是全局基线,所有未在 domain.json 中配置的域名都走全局配置
  • domain.json 只放需要覆盖的域名,未指定的字段自动回退到全局
  • 支持通配符域名(*.test.com),精确匹配优先于通配符

规则文件回退机制也设计得很精巧:当域名配置了独立的 rule_dir 时,NginxGuard 会先在域名目录中查找规则文件。如果文件不存在则回退到全局目录;如果文件存在但为空,则不回退,等同于该域名无此项规则。这意味着空文件 = 明确指定”无规则”,而非”使用全局规则”。

2. IP 规则与 CIDR 支持

IP 规则文件(blackip.rule / whiteip.rule / cdnip.rule)全面支持 IPv4/IPv6、CIDR、通配符和精确 IP:

1
2
3
4
5
6
7
# blackip.rule 示例
8.8.8.8 # 精确 IP
10.0.0.0/8 # IPv4 CIDR
2001:db8::/32 # IPv6 CIDR
fe80::* # IPv6 通配符
::1 # IPv6 精确
# 这是注释行,自动跳过

性能方面做了大量优化:

规模 匹配方式 查找复杂度
1195 条 IPv4 CIDR 排序数组 + 二分查找 O(log n) ≈ 11 次比较
IPv6 CIDR 4×32-bit chunk mask 匹配 O(n),通常 <20 条
通配符 预编译 regex O(n),通常 <10 条
精确 IP 小写化字符串比较 O(n),通常 <100 条

CIDR 规则在文件加载时预编译为数值区间,查找性能极高。所有 IP 规则缓存 10 秒 + mtime 检查,修改规则文件后无需 reload nginx。

3. CDN 场景下的 IP 信任链

当 WAF 部署在 CDN 后面时,需要从 X-Forwarded-For 获取真实客户端 IP,但直接信任 XFF 会让攻击者伪造该头绕过 IP 黑白名单。

NginxGuard 的解决方案是 cdnip.rule

1
2
-- config.lua
config_trust_proxy_headers = "on"
1
2
3
4
5
6
# rule-config/cdnip.rule
# Cloudflare IPv4
173.245.48.0/20
104.16.0.0/13
# 内部代理
10.0.0.0/8
条件 XFF 处理 说明
remote_addr 在 cdnip.rule 中 信任 XFF 提取真实客户端 IP
remote_addr 不在 cdnip.rule 中 不信任 XFF 使用 remote_addr(防直连伪造)
cdnip.rule 不存在 信任所有 XFF 向后兼容

IP 提取优先级:CF-Connecting-IP > X-Real-IP > X-Forwarded-For > remote_addr

4. CC 攻击防护与自动拉黑

CC 防护基于 ngx.shared.dict 实现滑动窗口限速:

1
2
3
4
-- 每个IP每60秒最多150次请求
config_cc_rate = "150/60"
-- 超限后自动拉黑600秒
config_cc_block_ttl = 600

超限后 IP 自动加入 badGuys 共享字典,600 秒内所有请求直接返回 403,到期后自动解封。这个封禁是跨 Worker 生效的(基于共享字典),不需要外部存储。

cc_rate 参数解析结果也做了 worker 级缓存,避免每次请求都解析字符串。如果配置格式错误(如 "abc"),CC 检测会静默 fail open,同时在 error.log 中记录错误日志。

5. 云凭据探测拦截

url.rule 中内置了针对云服务凭据和敏感配置文件的探测拦截规则,覆盖攻击者常用的扫描路径:

规则 拦截目标
/firebase.*\.json Firebase 配置文件
/gcp-key\.json GCP 服务账号密钥
/\.aws/credentials AWS 凭据文件
/\.gcloud/ GCP 凭据目录
/\.ssh/id_rsa SSH 私钥
/terraform\.tfstate Terraform 状态文件
/.env 环境变量配置
/.git/config Git 仓库配置
/actuator/env Spring Boot Actuator
/swagger-ui API 文档
/druid/ Druid 监控面板
/h2-console H2 数据库控制台

还覆盖了版本控制目录(.git/.svn/)、包管理文件(package.jsoncomposer.jsonPipfile)、管理后台路径(wp-admin/phpmyadmin)、Webshell 探测(shell.phpeval.php)等数百条规则。

6. 多层解码与绕过防护

攻击者经常通过编码绕过 WAF,NginxGuard 的 full_decode 函数实现了递归解码:

1
2
3
-- 第一步:递归 URL 解码(最多8层)
-- 第二步:JS unicode/hex/entity 解码
-- \u003c → < \x3c → < &#60; → < &#x3c; → <

关键优化是 快速特征检测has_encode_markers):只有当输入中包含 %\u\x&# 等编码标记时才触发解码,正常流量零额外开销。

所有检测项(URL、参数、Cookie、POST)都采用”先匹配原始输入 → 未命中且有编码标记 → 解码后再匹配”的双重检测策略。

性能优化

NginxGuard 在性能方面做了大量工程优化,以下是关键措施:

规则引擎优化

双引擎匹配是核心优化之一。规则文件加载时,自动将纯字符串规则和正则规则分离:

  • 纯字符串规则:用 string.find(plain 模式)匹配,比正则快约 10 倍
  • 正则规则:合并为 (?:rule1|rule2|rule3|...) 单一正则,一次匹配代替 N 次循环
1
2
3
4
5
6
7
-- 规则加载时自动分流
if r:find("[()%.%[%]%*%+%?%^%$|\\]") then
table.insert(regex_rules, r) -- 正则规则
else
table.insert(fast_rules, r) -- 纯字符串规则
fast_hash[r] = true -- hash set for O(1) lookup
end

正常流量匹配从 O(N) 降至 O(1),对 300 条规则的 url.rule,原来需要 300 次正则匹配,现在只需 1 次合并正则 + 少量字符串查找。

缓存层次

层级 存储位置 缓存内容 失效策略
Worker 级 Lua 模块变量 规则文件 + 合并正则 + 预编译 glob TTL 10s + mtime 检查
Worker 级 Lua 模块变量 domain.json 解析结果 TTL 10s + mtime 检查
Worker 级 Lua 模块变量 cc_rate 解析结果 配置变更时重解析
请求级 ngx.ctx client_ip / domain / config / headers 每请求自动清除

FFI stat 获取文件修改时间

规则热加载的关键是检测文件 mtime 变化。NginxGuard 使用 LuaJIT FFI 直接调用 libc 的 stat() 函数,无需编译任何 C 模块:

1
2
3
4
5
6
7
8
ffi.cdef[[
struct waf_stat_t {
long long _pad_to_mtime[11];
long long st_mtime;
long long _rest[7];
};
int stat(const char *path, struct waf_stat_t *buf);
]]

支持现代 glibc(stat())和旧版 glibc(__xstat()),兼容 x86_64 和 aarch64。当 FFI 不可用时回退为文件大小 + 60 秒 TTL。

请求级短路优化

  • GET/HEAD/OPTIONS 跳过 body 检测bodyless=on 时跳过 POST 和文件上传检测
  • 无参数 URL 短路next(REQ_ARGS) == nil 时直接返回
  • 最小输入长度检查#input < 2 直接返回,避免对短参数做正则
  • UA bloom-filter 预检查:先检查 7 个 bot 标记词,99% 正常流量跳过 50 次 string.find
  • 内网 IP 快速短路:127.x/10.x/172.x/192.x 跳过完整 CIDR 匹配
  • Cookie 检测后移:Cookie 检测移到 URL/Args 之后,攻击请求在 URL 阶段即短路返回

配置一次加载

所有 20 个配置项在请求入口一次性加载到 ngx.ctx._cfg,避免每检测项重复调用 get_effective_config

1
2
3
4
5
6
7
local CONFIG_KEYS = {
"waf_enable", "white_ip_check", "black_ip_check", ...
}
-- 请求入口一次性加载
for _, k in ipairs(CONFIG_KEYS) do
ctx._cfg[k] = get_effective_config(k)
end

规则匹配缓存

对小体积请求体/参数,按 规则文件 + mtime + flags + input 做 worker 级缓存,重复请求直接复用匹配结果:

1
2
3
4
5
if entry.cache_key and #input <= MATCH_CACHE_INPUT_MAX then
cache_key = entry.cache_key .. "|" .. (flags or "") .. "|" .. input
local cached = worker_match_cache[cache_key]
if cached ~= nil then return cached or nil end
end

压测数据

测试环境

项目 配置
CPU 4 核 x86_64
内存 24 GB
OS Linux 6.18 (RHEL 9)
Nginx 1.31.3 + LuaJIT2 (OpenResty 分支)
测试工具 ApacheBench (ab),200 并发 50000 请求

GET 请求

场景 req/s CPU RSS P99 吞吐下降
基线(WAF 全关) 27,516 273% 174 MB 43ms
+ WAF(无 CC/POST) 22,912 304% 179 MB 34ms 16.7%
+ WAF + CC 23,287 304% 179 MB 30ms 15.4%
+ WAF 全开(生产) 21,240 268% 179 MB 35ms 22.8%

POST 请求

场景 req/s CPU RSS P99 吞吐下降
form POST 基线 31,243 187% 174 MB 27ms
form POST + WAF 21,634 286% 183 MB 29ms 30.7%
JSON POST 基线 32,188 192% 174 MB 39ms
JSON POST + WAF 20,547 292% 182 MB 66ms 36.2%

WAF 关闭时内存 174MB,全开后 179-183MB,仅增加 5-9MB。所有场景 P99 延迟控制在 66ms 以内。

日志系统

同步写入

攻击日志采用同步写入策略,确保在 ngx.exit(403) 前已落盘:

1
2
3
4
local file = io.open(LOG_NAME, "a")
file:write(LOG_LINE .. "\n")
file:flush() -- 强制刷盘
file:close()

只有检测到攻击时才触发日志写入,正常流量零日志开销。

JSON 格式

每条日志为一行 JSON:

1
2
3
4
5
6
7
8
9
10
11
{
"@timestamp": "2026-08-21T08:30:00Z",
"client_ip": "1.2.3.4",
"local_time": "2026-08-21 16:30:00",
"server_name": "www.example.com",
"user_agent": "Mozilla/5.0...",
"attack_method": "Deny_URL_Args",
"req_url": "/?id=1+union+select+1",
"req_data": "-",
"rule_tag": "(?i:union).*select.*from"
}

截断保护与日志轮转

单字段最大 4096 字节,超出追加 ...[truncated]。日志文件超过 100MB 自动轮转为 *.old,轮转检查每 60 秒执行一次,不影响写入性能。

测试覆盖

项目包含完整的测试体系,16 个测试脚本覆盖各种攻击场景:

测试脚本 覆盖范围
waf_full_test.sh 全量规则测试:URL/参数/UA/Cookie/POST/文件上传/IP/XFF
waf_regression_test.sh 回归测试:bodyless/无效cc_rate/日志截断/PUT方法/大body
waf_scenario_test.sh 场景测试:多域名/白名单/动态拉黑
waf_coverage_test.sh 覆盖率测试
test_all_rules.sh 逐条规则验证
benchmark.sh 性能基准测试

测试覆盖的攻击类型包括:SQL 注入、XSS、路径遍历、命令注入、NoSQL 注入、SSTI 模板注入、PHP 代码注入、Log4j、SSRF、XXE、Webshell 探测等。

快速开始

安装依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 安装 LuaJIT2
git clone https://github.com/openresty/luajit2.git
cd luajit2 && make -j$(nproc) && make install
ln -sf /usr/local/lib/libluajit-5.1.so.2 /lib64/libluajit-5.1.so.2

# 安装 lua-cjson
git clone https://github.com/openresty/lua-cjson.git
cd lua-cjson
export LUAJIT_LIB=/usr/local/lib
export LUAJIT_INC=/usr/local/include/luajit-2.1
make -j$(nproc) && make install

# 编译 Nginx + lua-nginx-module
git clone --branch v0.10.31 https://github.com/openresty/lua-nginx-module.git
git clone https://github.com/simplresty/ngx_devel_kit.git
wget https://nginx.org/download/nginx-1.31.3.tar.gz
tar -xvf nginx-1.31.3.tar.gz && cd nginx-1.31.3

export LUAJIT_LIB=/usr/local/lib
export LUAJIT_INC=/usr/local/include/luajit-2.1

./configure \
--add-module=../ngx_devel_kit \
--add-module=../lua-nginx-module \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--with-threads \
--with-file-aio
make -j$(nproc) && make install

配置 Nginx

1
2
3
4
5
6
7
8
9
10
http {
lua_package_path "/apps/nginx/conf/waf/?.lua";
lua_shared_dict limit 10m;
lua_shared_dict badGuys 10m;
lua_code_cache on;
lua_need_request_body off;

init_by_lua_file "/apps/nginx/conf/waf/init.lua";
access_by_lua_file "/apps/nginx/conf/waf/access.lua";
}

修改配置

编辑 config.lua 中的日志路径和规则目录:

1
2
config_log_dir = "/apps/nginx/log/"
config_rule_dir = "/apps/nginx/conf/waf/rule-config"

Location 级别开关

如果某些路径需要关闭 WAF(如 gRPC 长连接、WebSocket),只需一行:

1
2
3
4
location /ws {
set $waf_enable off;
proxy_pass http://backend;
}

规则文件示例

URL 参数规则(args.rule / post.rule / cookie.rule)

三份文件内容相同,覆盖 OWASP Top 10 攻击向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# SQL 注入
(?i:union)[\s/\*!0-9-]*(?:(?:all|distinct)[\s/\*!0-9-]*)?(?i:select)
(?i:select).*from.*information_schema
sleep\((\s*)(\d*)(\s*)\)
benchmark\([^,]*,\s*[^)]*\)
(?i:load_file\()

# XSS
\<(iframe|script|body|img|layer|div|meta|style|base|object|input|svg)
(onmouseover|onerror|onload)\=
javascript\:
document\.cookie

# 命令注入
(?:define|eval|shell_exec|system|passthru|exec)\(
(gopher|doc|php|glob|file|phar)\:\/

# SSTI 模板注入
\{\{[^}]{0,100}(__class__|__subclasses__|__init__|__globals__)\}\}

# Log4j
log4j|jndi:|\$\{.*jndi
\$\{.*lower
\$\{.*env

# NoSQL 注入
\$where[\(:"']
\$regex[\(:"']
\$gt[\(:"']

# SSRF
169\.254\.169\.254
metadata\.google\.internal

User-Agent 规则(useragent.rule)

拦截常见安全扫描工具:

1
2
3
(sqlmap|nikto|nmap|dirb|gobuster|ffuf|nuclei|httpx|feroxbuster|masscan|wpscan)
(Acunetix|Nessus|OpenVAS|BurpSuite|AppScan|Metasploit|CobaltStrike)
(Python-urllib|Python-requests|Go-http-client|scrapy)

白名单 UA(whiteua.rule)

搜索引擎爬虫放行(仅跳过 UA 黑名单检测,其他安全检测仍执行):

1
2
3
4
5
6
Googlebot
Baiduspider
bingbot
YandexBot
Applebot
...

安全设计:白名单 UA 仅跳过 useragent.rule 检测,不会跳过 URL/POST/CC/Cookie 等其他检测,防止攻击者伪造搜索引擎 UA 绕过全部防护。

设计思考

为什么不使用 OpenResty?

NginxGuard 只依赖 lua-nginx-module + LuaJIT + lua-cjson,不需要完整的 OpenResty 发行版。对于已经在用原版 Nginx 的团队,只需加两个动态模块即可获得 WAF 能力,迁移成本极低。

为什么用同步日志?

WAF 日志的核心需求是”不丢”。如果用异步批量写入,在 ngx.exit(403) 后请求立即终止,尚未刷盘的日志可能丢失。同步写入 + flush() 确保每条攻击日志在返回 403 前已落盘。由于只在攻击时触发,正常流量不受影响。

为什么规则文件用纯文本而非 JSON/YAML?

纯文本规则文件的优势在于:

  • 每行一条正则,极致简洁
  • 支持 # 注释,方便分组和说明
  • vim / sed 直接编辑,无需格式化工具
  • 文件修改后通过 mtime 检查自动热加载,无需重启

fail open 设计

整个检测流程包裹在 pcall 中。即使某条规则有语法错误、某个函数抛出异常,请求也不会返回 500,而是被捕获并记录到 error.log。在安全性和可用性之间,NginxGuard 选择了可用性优先(fail open),避免 WAF 自身故障导致全站不可用。

总结

NginxGuard 是一个”够用就好”的 WAF:

  • 轻量:4 个 Lua 文件,核心代码不到 1000 行,不引入额外进程
  • 高性能:GET 吞吐下降约 16%,内存仅增加 5-9MB
  • 多域名:基于 domain.json 的差异化配置,支持通配符和规则回退
  • 热加载:修改规则文件后 10 秒内自动生效,无需 reload nginx
  • 全面防护:覆盖 SQLi/XSS/SSTI/SSRF/Log4j/NoSQL/命令注入/Webshell 探测等
  • IPv6 友好:全链路支持 IPv4/IPv6/CIDR/通配符
  • CDN 适配:通过 cdnip.rule 构建 XFF 信任链,防止 IP 伪造

如果你的团队已经在用 Nginx,需要一个可控、可审计、不拖慢业务的 WAF 方案,NginxGuard 值得一试。


项目地址:GitHub - qist/nginxguard