From 46ce7de513bec543f4d338a509d1c98a02da6069 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 14:16:34 +0800 Subject: [PATCH] =?UTF-8?q?perf(waf):=20=E6=94=B6=E7=AA=84=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=98=B2=E6=8A=A4=E6=89=AB=E6=8F=8F=E9=9D=A2=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20UA=20=E7=83=AD=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 注入类检测仅扫 Query/Cookie/Referer/有限 Body,避免全 Header 匹配拖垮边缘 CPU; 按开关采集输入、GET 跳过 read_body,UA 仅 lower 一次并用 set 匹配白名单。 --- docs/changelog/index.md | 1 + ...26-07-19-waf-security-check-node-design.md | 24 +-- internal/apps/agent/nginx/waf_runtime.lua | 144 ++++++++++-------- .../apps/agent/nginx/waf_runtime_spec.lua | 89 +++++++++++ 4 files changed, 182 insertions(+), 76 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index e2e6b7f5..7bfff01c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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 diff --git a/docs/superpowers/specs/2026-07-19-waf-security-check-node-design.md b/docs/superpowers/specs/2026-07-19-waf-security-check-node-design.md index 985327d6..2175fc57 100644 --- a/docs/superpowers/specs/2026-07-19-waf-security-check-node-design.md +++ b/docs/superpowers/specs/2026-07-19-waf-security-check-node-design.md @@ -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 整图)。 diff --git a/internal/apps/agent/nginx/waf_runtime.lua b/internal/apps/agent/nginx/waf_runtime.lua index cee05b80..09e8fabe 100644 --- a/internal/apps/agent/nginx/waf_runtime.lua +++ b/internal/apps/agent/nginx/waf_runtime.lua @@ -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 "") diff --git a/internal/apps/agent/nginx/waf_runtime_spec.lua b/internal/apps/agent/nginx/waf_runtime_spec.lua index 4a4518f4..14aad60d 100644 --- a/internal/apps/agent/nginx/waf_runtime_spec.lua +++ b/internal/apps/agent/nginx/waf_runtime_spec.lua @@ -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()