mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
perf(waf): 收窄安全防护扫描面并优化 UA 热路径
注入类检测仅扫 Query/Cookie/Referer/有限 Body,避免全 Header 匹配拖垮边缘 CPU; 按开关采集输入、GET 跳过 read_body,UA 仅 lower 一次并用 set 匹配白名单。
This commit is contained in:
@@ -31,6 +31,7 @@ sidebar: false
|
||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
|
||||
- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。
|
||||
- 优化边缘 WAF「安全防护」与「UA 检查」热路径:SQL/命令/XSS 等仅扫描 Query、Cookie、Referer 与有限 Body,避免对全部请求头做特征匹配;路径检测不再重复扫描完整 `request_uri`;无请求体时跳过 Body 读取;UA 分类仅小写一次并加速白名单匹配,显著降低开启基础防护时的 CPU 占用。
|
||||
|
||||
## [v3.4.0] - 2026-07-19
|
||||
|
||||
|
||||
@@ -52,15 +52,15 @@
|
||||
|
||||
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|
||||
|------|------|---------|--------------|
|
||||
| `sql_injection` | false | SQL 注入 | Query、Body、Header、Cookie |
|
||||
| `path_traversal` | **true** | 路径穿越防护 | Path、Query、Body |
|
||||
| `command_injection` | false | 命令注入 | Query、Body、Header |
|
||||
| `xss` | false | XSS | Query、Body、Header |
|
||||
| `ssrf` | false | SSRF | Query、Body 中 URL 形态参数 |
|
||||
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path、Query、Body |
|
||||
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
|
||||
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
|
||||
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
|
||||
| `xss` | false | XSS | Query、Cookie、Referer、Body |
|
||||
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
|
||||
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
|
||||
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
|
||||
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
|
||||
| `crlf_injection` | false | CRLF 注入 | Header、Query、Body |
|
||||
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
|
||||
|
||||
全部关闭时:节点恒 **true**(空操作),合法。
|
||||
|
||||
@@ -80,11 +80,11 @@ return true
|
||||
|
||||
| 来源 | 方式 |
|
||||
|------|------|
|
||||
| Path | `ngx.var.uri`,URL 解码(含常见双重编码路径变体) |
|
||||
| Query | `ngx.var.args` / `get_uri_args` 键与值 |
|
||||
| Header | 请求头名+值(可排除 `Host` 等噪声,实现时固定白名单或全量) |
|
||||
| Cookie | `Cookie` 头或 cookie 表 |
|
||||
| Body | 仅当某已启用规则需要 Body,且 `Content-Length` 存在且 ≤ **65536**;否则跳过 Body 相关规则 |
|
||||
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
|
||||
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
|
||||
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
|
||||
| Cookie | `ngx.var.http_cookie` |
|
||||
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
|
||||
|
||||
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
|
||||
|
||||
|
||||
@@ -298,12 +298,14 @@ local function ua_trim(value)
|
||||
return (string.gsub(value or "", "^%s*(.-)%s*$", "%1"))
|
||||
end
|
||||
|
||||
local function ua_label_in(items, value)
|
||||
if type(items) ~= "table" or not value then return false end
|
||||
local function ua_label_set(items)
|
||||
if type(items) ~= "table" then return nil, false end
|
||||
local set, count = {}, 0
|
||||
for _, item in ipairs(items) do
|
||||
if tostring(item) == value then return true end
|
||||
set[tostring(item)] = true
|
||||
count = count + 1
|
||||
end
|
||||
return false
|
||||
return set, count > 0
|
||||
end
|
||||
|
||||
local function match_ua_rules(ua_lower, rules, fallback)
|
||||
@@ -363,12 +365,12 @@ local os_rules = {
|
||||
{ label = "Bot", contains = { "bot", "spider", "crawler" } },
|
||||
}
|
||||
|
||||
local function parse_browser_name(ua)
|
||||
return match_ua_rules(string.lower(ua or ""), browser_rules, "Other")
|
||||
local function parse_browser_name_lower(ua_lower)
|
||||
return match_ua_rules(ua_lower, browser_rules, "Other")
|
||||
end
|
||||
|
||||
local function parse_os_name(ua)
|
||||
return match_ua_rules(string.lower(ua or ""), os_rules, "Other")
|
||||
local function parse_os_name_lower(ua_lower)
|
||||
return match_ua_rules(ua_lower, os_rules, "Other")
|
||||
end
|
||||
|
||||
local function ua_matches_custom_patterns(ua, patterns)
|
||||
@@ -387,8 +389,9 @@ local function matches_ua_check(config)
|
||||
config = config or {}
|
||||
local ua = ua_trim(ngx.var.http_user_agent or "")
|
||||
if config.require_ua and ua == "" then return false end
|
||||
local browser = parse_browser_name(ua)
|
||||
local os_name = parse_os_name(ua)
|
||||
local ua_lower = string.lower(ua)
|
||||
local browser = parse_browser_name_lower(ua_lower)
|
||||
local os_name = parse_os_name_lower(ua_lower)
|
||||
if config.block_common_bots and (browser == "Bot" or os_name == "Bot") then return false end
|
||||
-- Abnormal excludes search-engine / crawler Bot labels; use block_common_bots for those.
|
||||
if config.block_abnormal_ua and (browser == "Other" or browser == "Unknown") then
|
||||
@@ -397,13 +400,11 @@ local function matches_ua_check(config)
|
||||
if config.block_custom_ua and ua_matches_custom_patterns(ua, config.custom_ua_patterns) then
|
||||
return false
|
||||
end
|
||||
local browsers = array_or_empty(config.browsers)
|
||||
local operating_systems = array_or_empty(config.operating_systems)
|
||||
local has_browsers = #browsers > 0
|
||||
local has_os = #operating_systems > 0
|
||||
local browser_set, has_browsers = ua_label_set(config.browsers)
|
||||
local os_set, has_os = ua_label_set(config.operating_systems)
|
||||
if not has_browsers and not has_os then return true end
|
||||
local browser_ok = ua_label_in(browsers, browser)
|
||||
local os_ok = ua_label_in(operating_systems, os_name)
|
||||
local browser_ok = has_browsers and browser_set[browser] == true
|
||||
local os_ok = has_os and os_set[os_name] == true
|
||||
if has_browsers and not has_os then return browser_ok end
|
||||
if has_os and not has_browsers then return os_ok end
|
||||
local mode = config.match_mode
|
||||
@@ -529,20 +530,16 @@ local function security_collect_args(list)
|
||||
end
|
||||
end
|
||||
|
||||
local function security_collect_headers(list)
|
||||
if not ngx.req or not ngx.req.get_headers then return end
|
||||
local headers = ngx.req.get_headers(100)
|
||||
if type(headers) ~= "table" then return end
|
||||
for name, value in pairs(headers) do
|
||||
local lower_name = string.lower(tostring(name))
|
||||
if lower_name ~= "host" and lower_name ~= "connection" and lower_name ~= "content-length" then
|
||||
security_append_decoded(list, tostring(name))
|
||||
if type(value) == "table" then
|
||||
for _, item in ipairs(value) do security_append_decoded(list, tostring(item)) end
|
||||
else
|
||||
security_append_decoded(list, tostring(value))
|
||||
end
|
||||
end
|
||||
-- Only Cookie / Referer for injection surfaces. Generic browser headers (UA, Accept, …)
|
||||
-- are high-volume and high false-positive / CPU cost if scanned for SQL/cmd/XSS.
|
||||
local function security_collect_sensitive_headers(list)
|
||||
local cookie = ngx.var.http_cookie
|
||||
if type(cookie) == "string" and cookie ~= "" then
|
||||
security_append_decoded(list, cookie)
|
||||
end
|
||||
local referer = ngx.var.http_referer
|
||||
if type(referer) == "string" and referer ~= "" then
|
||||
security_append_decoded(list, referer)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -589,11 +586,20 @@ local xxe_patterns = {
|
||||
local crlf_patterns = {
|
||||
"%0d%0a", "\r\n",
|
||||
}
|
||||
local sql_static_patterns = {
|
||||
"union select", " or 1=1", "' or '", "\" or \"",
|
||||
"information_schema", "xp_cmdshell", "load_file(", " into outfile",
|
||||
"/**/", "/*!", "*/--", "@@version",
|
||||
}
|
||||
|
||||
local function security_flag_enabled(value)
|
||||
return value == true or value == 1 or value == "true" or value == "1"
|
||||
end
|
||||
|
||||
local function security_append_list(dst, src)
|
||||
for _, item in ipairs(src) do dst[#dst + 1] = item end
|
||||
end
|
||||
|
||||
local function matches_security_check(config)
|
||||
config = config or {}
|
||||
local sql_injection = security_flag_enabled(config.sql_injection)
|
||||
@@ -610,46 +616,56 @@ local function matches_security_check(config)
|
||||
return true
|
||||
end
|
||||
|
||||
local path_inputs, query_inputs, header_inputs, body_inputs = {}, {}, {}, {}
|
||||
security_append_decoded(path_inputs, ngx.var.uri or "")
|
||||
security_append_decoded(path_inputs, ngx.var.request_uri or "")
|
||||
security_collect_args(query_inputs)
|
||||
security_collect_headers(header_inputs)
|
||||
security_append_decoded(header_inputs, ngx.var.http_cookie or "")
|
||||
-- Collect only what enabled checks need (P1). Path uses uri only (not request_uri)
|
||||
-- to avoid re-scanning query; query is collected separately when needed (P0).
|
||||
local need_path = path_traversal or file_inclusion
|
||||
local need_query = path_traversal or file_inclusion or sql_injection or command_injection
|
||||
or xss or ssrf or crlf_injection
|
||||
local need_sensitive_headers = sql_injection or command_injection or xss or ssrf or crlf_injection
|
||||
local need_body = malicious_upload or xxe
|
||||
or ((sql_injection or path_traversal or command_injection or xss or ssrf
|
||||
or file_inclusion or crlf_injection)
|
||||
and (tonumber(ngx.var.content_length or "") or 0) > 0)
|
||||
|
||||
local path_inputs, query_inputs, header_inputs, body_inputs = {}, {}, {}, {}
|
||||
if need_path then
|
||||
security_append_decoded(path_inputs, ngx.var.uri or "")
|
||||
end
|
||||
if need_query then
|
||||
security_collect_args(query_inputs)
|
||||
end
|
||||
if need_sensitive_headers then
|
||||
security_collect_sensitive_headers(header_inputs)
|
||||
end
|
||||
|
||||
local need_body = sql_injection or path_traversal or command_injection or xss or ssrf
|
||||
or file_inclusion or malicious_upload or xxe or crlf_injection
|
||||
local body
|
||||
if need_body then body = security_read_body() end
|
||||
if body then security_append_decoded(body_inputs, body) end
|
||||
|
||||
local pq = {}
|
||||
for _, item in ipairs(path_inputs) do pq[#pq + 1] = item end
|
||||
for _, item in ipairs(query_inputs) do pq[#pq + 1] = item end
|
||||
for _, item in ipairs(body_inputs) do pq[#pq + 1] = item end
|
||||
|
||||
local qhb = {}
|
||||
for _, item in ipairs(query_inputs) do qhb[#qhb + 1] = item end
|
||||
for _, item in ipairs(header_inputs) do qhb[#qhb + 1] = item end
|
||||
for _, item in ipairs(body_inputs) do qhb[#qhb + 1] = item end
|
||||
|
||||
if path_traversal and security_match_any(pq, path_traversal_patterns) then return false end
|
||||
if file_inclusion and security_match_any(pq, file_inclusion_patterns) then return false end
|
||||
if sql_injection then
|
||||
-- Exclude sleep(/benchmark( bare tokens; use timed helper + other patterns.
|
||||
local sql_static = {
|
||||
"union select", " or 1=1", "' or '", "\" or \"",
|
||||
"information_schema", "xp_cmdshell", "load_file(", " into outfile",
|
||||
"/**/", "/*!", "*/--", "@@version",
|
||||
}
|
||||
if security_match_any(qhb, sql_static) or security_match_sql_timed(qhb) then
|
||||
return false
|
||||
end
|
||||
if path_traversal or file_inclusion then
|
||||
local pq = {}
|
||||
security_append_list(pq, path_inputs)
|
||||
security_append_list(pq, query_inputs)
|
||||
security_append_list(pq, body_inputs)
|
||||
if path_traversal and security_match_any(pq, path_traversal_patterns) then return false end
|
||||
if file_inclusion and security_match_any(pq, file_inclusion_patterns) then return false end
|
||||
end
|
||||
|
||||
if sql_injection or command_injection or xss or ssrf or crlf_injection then
|
||||
local qhb = {}
|
||||
security_append_list(qhb, query_inputs)
|
||||
security_append_list(qhb, header_inputs)
|
||||
security_append_list(qhb, body_inputs)
|
||||
if sql_injection then
|
||||
if security_match_any(qhb, sql_static_patterns) or security_match_sql_timed(qhb) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
if command_injection and security_match_any(qhb, command_patterns) then return false end
|
||||
if xss and security_match_xss(qhb) then return false end
|
||||
if ssrf and security_match_any(qhb, ssrf_patterns) then return false end
|
||||
if crlf_injection and security_match_any(qhb, crlf_patterns) then return false end
|
||||
end
|
||||
if command_injection and security_match_any(qhb, command_patterns) then return false end
|
||||
if xss and security_match_xss(qhb) then return false end
|
||||
if ssrf and security_match_any(qhb, ssrf_patterns) then return false end
|
||||
if crlf_injection and security_match_any(qhb, crlf_patterns) then return false end
|
||||
|
||||
if malicious_upload and body then
|
||||
local content_type = string.lower(ngx.var.content_type or "")
|
||||
|
||||
@@ -813,6 +813,95 @@ local function test_security_check_path_and_sql()
|
||||
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
|
||||
assert_equal(err, nil, "off execute err")
|
||||
assert_equal(decision and decision.kind or "nil", "allow", "all protections off should allow")
|
||||
|
||||
-- P0: do not treat generic browser headers (UA/Accept) as SQL/cmd injection surface.
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_headers = function()
|
||||
return {
|
||||
["User-Agent"] = "Mozilla/5.0 union select 1 from information_schema.tables",
|
||||
Accept = "*/*",
|
||||
["Accept-Language"] = "en;q=0.9",
|
||||
}
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({
|
||||
sql_injection = true,
|
||||
command_injection = true,
|
||||
xss = true,
|
||||
ssrf = true,
|
||||
}),
|
||||
true,
|
||||
"SQL-like tokens only in generic headers must not block"
|
||||
)
|
||||
|
||||
-- Cookie / Referer remain in-scope for injection / SSRF shaped checks.
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.http_cookie = "q=1' union select 1--"
|
||||
ngx.req.get_headers = function()
|
||||
return { Cookie = "q=1' union select 1--" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ sql_injection = true }),
|
||||
false,
|
||||
"SQL in Cookie must still block"
|
||||
)
|
||||
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.http_referer = "http://127.0.0.1/admin"
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ ssrf = true }),
|
||||
false,
|
||||
"URL-shaped SSRF in Referer must still block"
|
||||
)
|
||||
|
||||
-- Path checks use uri only; query-only traversal still caught via args.
|
||||
reset_request("sec-site", nil, "/ok")
|
||||
ngx.var.request_uri = "/ok?x=../../etc/passwd"
|
||||
ngx.var.args = "x=../../etc/passwd"
|
||||
ngx.req.get_uri_args = function()
|
||||
return { x = "../../etc/passwd" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ path_traversal = true }),
|
||||
false,
|
||||
"path traversal in query must still block without scanning full request_uri alone"
|
||||
)
|
||||
|
||||
-- GET / zero body: never call read_body.
|
||||
local read_body_calls = 0
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.content_length = "0"
|
||||
ngx.req.read_body = function()
|
||||
read_body_calls = read_body_calls + 1
|
||||
end
|
||||
ngx.req.get_body_data = function() return nil end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({
|
||||
sql_injection = true,
|
||||
path_traversal = true,
|
||||
command_injection = true,
|
||||
file_inclusion = true,
|
||||
}),
|
||||
true,
|
||||
"clean GET must pass full default-like security set"
|
||||
)
|
||||
assert_equal(read_body_calls, 0, "zero content-length must not read_body")
|
||||
|
||||
-- Only enabled collectors: path-only config must ignore SQL-like query.
|
||||
reset_request("sec-site", nil, "/safe")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "1' union select 1--" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ path_traversal = true, file_inclusion = true }),
|
||||
true,
|
||||
"SQL payload must not affect path-only checks"
|
||||
)
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ sql_injection = true }),
|
||||
false,
|
||||
"SQL payload must block when SQL is enabled"
|
||||
)
|
||||
end
|
||||
|
||||
test_ua_check_require_block_and_whitelist()
|
||||
|
||||
Reference in New Issue
Block a user