fix(waf): 收紧安全防护特征,降低常见正常请求误伤

- SSRF 仅匹配 URL 形态,避免 Chrome/x.0.0.0 误中
- 命令注入去掉裸 &&/|| 与裸 shell 名
- SQL sleep/benchmark 要求数字参数
- XSS javascript:/eval 要求更像代码的上下文
- 路径穿越去掉过宽的 c:\windows;CRLF 去掉单独 %0a/%0d
This commit is contained in:
ryan
2026-07-19 12:54:01 +08:00
parent 60d6e3e846
commit ad6621fce9
2 changed files with 165 additions and 22 deletions
+94 -22
View File
@@ -439,6 +439,72 @@ local function security_match_any(haystacks, patterns)
return false
end
-- SQL sleep/benchmark: require digit arg to avoid product names like sleep(better).
local function security_match_sql_timed(haystacks)
for _, hay in ipairs(haystacks) do
if type(hay) == "string" and hay ~= "" then
if string.find(hay, "sleep(%d", 1, true) or string.find(hay, "benchmark(%d", 1, true) then
return true
end
-- Also accept sleep( 1 ) with optional spaces: sleep( + digit
local i = 1
while true do
local s, e = string.find(hay, "sleep(", i, true)
if not s then break end
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*%d") then return true end
i = e + 1
end
i = 1
while true do
local s, e = string.find(hay, "benchmark(", i, true)
if not s then break end
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*%d") then return true end
i = e + 1
end
end
end
return false
end
-- XSS: tag/event handlers and URI schemes; skip prose like "javascript: the good parts".
local function security_match_xss(haystacks)
local tag_like = { "<script", "<iframe", "onerror=", "onload=", "onmouseover=", "document.cookie" }
for _, hay in ipairs(haystacks) do
if type(hay) == "string" and hay ~= "" then
for _, pattern in ipairs(tag_like) do
if string.find(hay, pattern, 1, true) then return true end
end
-- javascript: as URI scheme with code-like body (alert/void/'/") not prose titles.
local i = 1
while true do
local s, e = string.find(hay, "javascript:", i, true)
if not s then break end
local prev_ok = (s == 1) or string.match(string.sub(hay, s - 1, s - 1), "[=\"'(<;,]")
if prev_ok then
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*[\"'`(]")
or string.match(rest, "^%s*alert%s*%(")
or string.match(rest, "^%s*void%s*%(")
or string.match(rest, "^%s*eval%s*%(")
or string.match(rest, "^%s*window%.")
or string.match(rest, "^%s*document%.") then
return true
end
end
i = e + 1
end
if string.find(hay, "eval(", 1, true) then
local _, e = string.find(hay, "eval(", 1, true)
local rest = string.sub(hay, e + 1)
if string.match(rest, "^%s*[\"'`(]") then return true end
end
end
end
return false
end
local function security_append_decoded(list, value)
if type(value) ~= "string" or value == "" then return end
local once, twice = security_decode(value)
@@ -492,40 +558,36 @@ local function security_read_body()
end
local path_traversal_patterns = {
"../", "..\\", "..%2f", "..%5c", "%2e%2e", "%252e", "....//",
"/etc/passwd", "c:\\windows",
"../", "..\\", "..%2f", "..%5c", "%2e%2e/", "%2e%2e\\", "%252e%252e",
"....//", "/etc/passwd",
}
local file_inclusion_patterns = {
"php://", "file://", "zip://", "data://", "expect://", "/etc/passwd",
"proc/self", "%00",
}
local sql_patterns = {
"union select", " or 1=1", "' or '", "\" or \"", "sleep(", "benchmark(",
"information_schema", "xp_cmdshell", "load_file(", " into outfile",
-- Avoid bare "/*" / "*/": they match normal Accept: */* headers.
"/**/", "/*!", "*/--", "@@version",
"php://", "file://", "zip://", "data://text", "expect://", "/etc/passwd",
"/proc/self", "%00",
}
-- Prefer attack-shaped tokens; avoid bare "&&"/"||" and bare shell names.
local command_patterns = {
";wget", ";curl", "|bash", "|sh", "`id`", "$(id)", "&&", "||",
"/bin/sh", "/bin/bash", "powershell", "cmd.exe",
}
local xss_patterns = {
"<script", "javascript:", "onerror=", "onload=", "onmouseover=",
"<iframe", "document.cookie", "eval(",
";wget", ";curl", ";bash", ";sh ", "|bash", "|sh ", "|sh\t", "`id`", "$(id)",
"&&wget", "&&curl", "&&bash", "&&sh ", "||wget", "||curl", "||bash",
"/bin/sh ", "/bin/bash ", "cmd.exe /c", "powershell -", "powershell.exe",
}
-- URL-shaped only: bare "localhost"/"0.0.0.0" match Chrome UA / normal text.
local ssrf_patterns = {
"127.0.0.1", "localhost", "0.0.0.0", "169.254.", "[::1]",
"file://", "gopher://", "dict://", "metadata.google",
"http://127.0.0.1", "https://127.0.0.1", "http://localhost", "https://localhost",
"http://0.0.0.0", "https://0.0.0.0", "http://[::1]", "https://[::1]",
"://169.254.", "169.254.169.254", "metadata.google",
"file://", "gopher://", "dict://",
}
local upload_patterns = {
".php.", ".jsp.", ".asp.", ".aspx.", ".phtml", ".phar",
"application/x-php", "application/x-httpd-php",
}
local xxe_patterns = {
"<!entity", " system ", "public ", "file://",
"<!entity", " system \"", " system '", "file://",
}
-- Keep encoded CRLF; bare %0a alone is too common in benign encoded text.
local crlf_patterns = {
"%0d%0a", "%0d", "%0a", "\r\n",
"%0d%0a", "\r\n",
}
local function security_flag_enabled(value)
@@ -573,9 +635,19 @@ local function matches_security_check(config)
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 and security_match_any(qhb, sql_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
end
if command_injection and security_match_any(qhb, command_patterns) then return false end
if xss and security_match_any(qhb, xss_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
@@ -734,6 +734,77 @@ local function test_security_check_path_and_sql()
assert_equal(err, nil, "sql execute err")
assert_equal(decision and decision.kind or "nil", "block", "sql should block")
-- False-positive guards
assert_equal(
runtime.debug_security_check({ ssrf = true }),
true,
"Chrome-like path alone must not trip SSRF"
)
reset_request("sec-site", nil, "/")
ngx.req.get_headers = function()
return {
["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept = "*/*",
}
end
assert_equal(
runtime.debug_security_check({
sql_injection = true,
command_injection = true,
xss = true,
ssrf = true,
path_traversal = true,
file_inclusion = true,
}),
true,
"normal browser headers must pass security_check"
)
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { name = "sleep(better)" }
end
assert_equal(runtime.debug_security_check({ sql_injection = true }), true, "sleep(word) must not trip SQL")
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { theme = "dark||light" }
end
ngx.req.get_headers = function()
return { Cookie = "a=1&&b=2" }
end
assert_equal(runtime.debug_security_check({ command_injection = true }), true, "bare &&/|| must not trip command")
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { q = "javascript: the good parts" }
end
assert_equal(runtime.debug_security_check({ xss = true }), true, "prose javascript: must not trip XSS")
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { q = "1;wget http://evil" }
end
assert_equal(
runtime.debug_security_check({ command_injection = true }),
false,
"command injection payload should still block"
)
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { u = "http://127.0.0.1/admin" }
end
assert_equal(
runtime.debug_security_check({ ssrf = true }),
false,
"URL-shaped localhost SSRF should block"
)
reset_request("sec-site", nil, "/")
ngx.req.get_uri_args = function()
return { q = "1' and sleep(5)--" }
end
assert_equal(
runtime.debug_security_check({ sql_injection = true }),
false,
"timed SQL sleep should block"
)
runtime = load_runtime({
rule_groups = { rule(1, false, security_graph({})) },
bindings = { binding("sec-site", { 1 }) },