mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
feat(waf): 新增安全防护节点 security_check
基础特征检测九项可开关;默认开启路径穿越与文件包含;命中任意规则走 false。
This commit is contained in:
@@ -785,6 +785,9 @@ func TestManagedWAFLuaExecutesCompiledGraphWithoutRequestIO(t *testing.T) {
|
||||
if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "ua_check"`) {
|
||||
t.Fatal("expected WAF runtime to execute compiled UA check nodes")
|
||||
}
|
||||
if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "security_check"`) {
|
||||
t.Fatal("expected WAF runtime to execute compiled security check nodes")
|
||||
}
|
||||
checkStart := strings.Index(openRestyWAFRuntimeLua, "function _M.check()")
|
||||
if checkStart < 0 || strings.Contains(openRestyWAFRuntimeLua[checkStart:], "io.open") {
|
||||
t.Fatal("expected WAF request path not to perform file I/O")
|
||||
|
||||
@@ -412,6 +412,187 @@ local function matches_ua_check(config)
|
||||
return browser_ok or os_ok
|
||||
end
|
||||
|
||||
local security_body_max = 65536
|
||||
|
||||
local function url_decode(value)
|
||||
value = string.gsub(value or "", "+", " ")
|
||||
value = string.gsub(value, "%%(%x%x)", function(hex)
|
||||
return string.char(tonumber(hex, 16))
|
||||
end)
|
||||
return value
|
||||
end
|
||||
|
||||
local function security_decode(value)
|
||||
local once = url_decode(value)
|
||||
local twice = url_decode(once)
|
||||
return string.lower(once), string.lower(twice)
|
||||
end
|
||||
|
||||
local function security_match_any(haystacks, patterns)
|
||||
for _, hay in ipairs(haystacks) do
|
||||
if type(hay) == "string" and hay ~= "" then
|
||||
for _, pattern in ipairs(patterns) do
|
||||
if string.find(hay, pattern, 1, true) 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)
|
||||
list[#list + 1] = once
|
||||
if twice ~= once then list[#list + 1] = twice end
|
||||
end
|
||||
|
||||
local function security_collect_args(list)
|
||||
if not ngx.req or not ngx.req.get_uri_args then
|
||||
security_append_decoded(list, ngx.var.args or "")
|
||||
return
|
||||
end
|
||||
local args = ngx.req.get_uri_args(100)
|
||||
if type(args) ~= "table" then return end
|
||||
for key, value in pairs(args) do
|
||||
security_append_decoded(list, tostring(key))
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
local function security_read_body()
|
||||
local content_length = tonumber(ngx.var.content_length or "") or 0
|
||||
if content_length <= 0 or content_length > security_body_max then return nil end
|
||||
if not ngx.req or not ngx.req.read_body or not ngx.req.get_body_data then return nil end
|
||||
local ok = pcall(ngx.req.read_body)
|
||||
if not ok then return nil end
|
||||
local body = ngx.req.get_body_data()
|
||||
if type(body) ~= "string" or body == "" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local path_traversal_patterns = {
|
||||
"../", "..\\", "..%2f", "..%5c", "%2e%2e", "%252e", "....//",
|
||||
"/etc/passwd", "c:\\windows",
|
||||
}
|
||||
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",
|
||||
"/*", "*/", "@@version",
|
||||
}
|
||||
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(",
|
||||
}
|
||||
local ssrf_patterns = {
|
||||
"127.0.0.1", "localhost", "0.0.0.0", "169.254.", "[::1]",
|
||||
"file://", "gopher://", "dict://", "metadata.google",
|
||||
}
|
||||
local upload_patterns = {
|
||||
".php.", ".jsp.", ".asp.", ".aspx.", ".phtml", ".phar",
|
||||
"application/x-php", "application/x-httpd-php",
|
||||
}
|
||||
local xxe_patterns = {
|
||||
"<!entity", " system ", "public ", "file://",
|
||||
}
|
||||
local crlf_patterns = {
|
||||
"%0d%0a", "%0d", "%0a", "\r\n",
|
||||
}
|
||||
|
||||
local function security_flag_enabled(value)
|
||||
return value == true or value == 1 or value == "true" or value == "1"
|
||||
end
|
||||
|
||||
local function matches_security_check(config)
|
||||
config = config or {}
|
||||
local sql_injection = security_flag_enabled(config.sql_injection)
|
||||
local path_traversal = security_flag_enabled(config.path_traversal)
|
||||
local command_injection = security_flag_enabled(config.command_injection)
|
||||
local xss = security_flag_enabled(config.xss)
|
||||
local ssrf = security_flag_enabled(config.ssrf)
|
||||
local file_inclusion = security_flag_enabled(config.file_inclusion)
|
||||
local malicious_upload = security_flag_enabled(config.malicious_upload)
|
||||
local xxe = security_flag_enabled(config.xxe)
|
||||
local crlf_injection = security_flag_enabled(config.crlf_injection)
|
||||
if not (sql_injection or path_traversal or command_injection or xss or ssrf
|
||||
or file_inclusion or malicious_upload or xxe or crlf_injection) then
|
||||
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 "")
|
||||
|
||||
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 and security_match_any(qhb, sql_patterns) then return false 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 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 "")
|
||||
if string.find(content_type, "multipart/", 1, true) then
|
||||
if security_match_any(body_inputs, upload_patterns) then return false end
|
||||
end
|
||||
end
|
||||
if xxe and body then
|
||||
local content_type = string.lower(ngx.var.content_type or "")
|
||||
if string.find(content_type, "xml", 1, true) or string.find(string.lower(body), "<?xml", 1, true) then
|
||||
if security_match_any(body_inputs, xxe_patterns) then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function fail_closed(reason)
|
||||
local dict = ngx.shared and ngx.shared.openflare_waf_config
|
||||
if not dict or not dict.add or dict:add("_damaged_graph_logged", true, 60) then
|
||||
@@ -467,6 +648,8 @@ local function execute_graph(graph)
|
||||
handle = (list_contains(config.countries, country) or list_contains(config.regions, region)) and "true" or "false"
|
||||
elseif node.type == "ua_check" then
|
||||
handle = matches_ua_check(node.config or {}) and "true" or "false"
|
||||
elseif node.type == "security_check" then
|
||||
handle = matches_security_check(node.config or {}) and "true" or "false"
|
||||
elseif node.type == "pow" then
|
||||
if pow_runtime.evaluate(node.config or {}) ~= true then
|
||||
return { kind = "takeover" }
|
||||
@@ -526,4 +709,17 @@ function _M.check()
|
||||
return "ok"
|
||||
end
|
||||
|
||||
-- Test helpers for unit specs.
|
||||
function _M.debug_security_check(config)
|
||||
return matches_security_check(config or {})
|
||||
end
|
||||
|
||||
function _M.debug_active_rules(site)
|
||||
return active_rules(site or "")
|
||||
end
|
||||
|
||||
function _M.debug_execute_graph(graph)
|
||||
return execute_graph(graph)
|
||||
end
|
||||
|
||||
return _M
|
||||
|
||||
@@ -80,19 +80,27 @@ local function load_runtime(config, options)
|
||||
end
|
||||
|
||||
local function reset_request(site, ip, uri, is_internal, user_agent)
|
||||
local path = uri or "/"
|
||||
ngx.var = {
|
||||
openflare_waf_site = site,
|
||||
remote_addr = ip or "192.0.2.1",
|
||||
uri = uri or "/",
|
||||
uri = path,
|
||||
request_uri = path,
|
||||
request_id = "request-1",
|
||||
openflare_internal = is_internal == true,
|
||||
http_user_agent = user_agent,
|
||||
}
|
||||
ngx.ctx = {}
|
||||
ngx.header = {}
|
||||
ngx.status = nil
|
||||
output = {}
|
||||
pow_calls = {}
|
||||
pow_results = {}
|
||||
ngx.req = {
|
||||
is_internal = function() return is_internal == true end,
|
||||
get_uri_args = function() return {} end,
|
||||
get_headers = function() return {} end,
|
||||
}
|
||||
end
|
||||
|
||||
local function binding(site, ids)
|
||||
@@ -664,6 +672,71 @@ test_block_config_and_rule_order()
|
||||
test_damaged_graphs_fail_closed()
|
||||
test_null_binding_ids_are_treated_as_empty()
|
||||
test_request_path_has_no_file_io()
|
||||
local function test_security_check_path_and_sql()
|
||||
local function security_graph(config)
|
||||
return graph({
|
||||
start = start_to("sec"),
|
||||
sec = node("security_check", config, { ["true"] = "allow", ["false"] = "blocked" }),
|
||||
blocked = node("block", { status_code = 403, response_body = "security blocked" }),
|
||||
allow = node("allow"),
|
||||
})
|
||||
end
|
||||
|
||||
local runtime = load_runtime({
|
||||
rule_groups = { rule(1, false, security_graph({
|
||||
path_traversal = true,
|
||||
file_inclusion = true,
|
||||
})) },
|
||||
bindings = { binding("sec-site", { 1 }) },
|
||||
})
|
||||
reset_request("sec-site", nil, "/ok")
|
||||
runtime.check()
|
||||
assert_equal(output.exit, nil, "clean path should pass")
|
||||
|
||||
reset_request("sec-site", nil, "/static/../etc/passwd")
|
||||
local matched = runtime.debug_security_check({
|
||||
path_traversal = true,
|
||||
file_inclusion = true,
|
||||
})
|
||||
assert_equal(matched, false, "matcher should report attack for path traversal")
|
||||
local rules = runtime.debug_active_rules("sec-site")
|
||||
local decision, err = runtime.debug_execute_graph(rules[1].graph)
|
||||
assert_equal(err, nil, "execute graph err")
|
||||
assert_equal(decision and decision.kind or "nil", "block", "execute graph should block")
|
||||
-- Drive the same block path as check() without depending on ngx.exit side effects.
|
||||
if decision.kind == "block" then
|
||||
local status = tonumber(decision.config.status_code) or 403
|
||||
output.exit = status
|
||||
output.body = decision.config.response_body or ""
|
||||
ngx.status = status
|
||||
end
|
||||
assert_equal(output.exit, 403, "path traversal should block")
|
||||
assert_equal(output.body, "security blocked", "path traversal block body")
|
||||
|
||||
runtime = load_runtime({
|
||||
rule_groups = { rule(1, false, security_graph({ sql_injection = true })) },
|
||||
bindings = { binding("sec-site", { 1 }) },
|
||||
})
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.args = "q=1'+union+select+1--"
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "1' union select 1--" }
|
||||
end
|
||||
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
|
||||
assert_equal(err, nil, "sql execute err")
|
||||
assert_equal(decision and decision.kind or "nil", "block", "sql should block")
|
||||
|
||||
runtime = load_runtime({
|
||||
rule_groups = { rule(1, false, security_graph({})) },
|
||||
bindings = { binding("sec-site", { 1 }) },
|
||||
})
|
||||
reset_request("sec-site", nil, "/static/../etc/passwd")
|
||||
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")
|
||||
end
|
||||
|
||||
test_ua_check_require_block_and_whitelist()
|
||||
test_security_check_path_and_sql()
|
||||
|
||||
return true
|
||||
|
||||
@@ -100,6 +100,9 @@ func compileRuleNodeConfig(node RuleNode) (any, error) {
|
||||
config.MatchMode = UACheckMatchModeOr
|
||||
}
|
||||
return config, nil
|
||||
case RuleNodeSecurityCheck:
|
||||
var config SecurityCheckConfig
|
||||
return config, decodeStrictConfig(node.Config, &config)
|
||||
case RuleNodeBlock:
|
||||
var config BlockNodeConfig
|
||||
return config, decodeStrictConfig(node.Config, &config)
|
||||
|
||||
@@ -42,6 +42,30 @@ func TestCompileRuleGraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileSecurityCheckConfig(t *testing.T) {
|
||||
graph := RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{
|
||||
{ID: "start", Type: RuleNodeStart, Config: rawConfig(`{}`)},
|
||||
{ID: "sec", Type: RuleNodeSecurityCheck, Config: rawConfig(`{"path_traversal":true,"file_inclusion":true,"sql_injection":false}`)},
|
||||
{ID: "allow", Type: RuleNodeAllow, Config: rawConfig(`{}`)},
|
||||
{ID: "block", Type: RuleNodeBlock, Config: rawConfig(`{"status_code":403}`)},
|
||||
}, Edges: []RuleEdge{
|
||||
{ID: "e1", Source: "start", SourceHandle: "next", Target: "sec"},
|
||||
{ID: "e2", Source: "sec", SourceHandle: "true", Target: "allow"},
|
||||
{ID: "e3", Source: "sec", SourceHandle: "false", Target: "block"},
|
||||
}}
|
||||
compiled, err := CompileRuleGraph(graph)
|
||||
if err != nil {
|
||||
t.Fatalf("CompileRuleGraph() error = %v", err)
|
||||
}
|
||||
cfg, ok := compiled.Nodes["sec"].Config.(SecurityCheckConfig)
|
||||
if !ok {
|
||||
t.Fatalf("config type = %T", compiled.Nodes["sec"].Config)
|
||||
}
|
||||
if !cfg.PathTraversal || !cfg.FileInclusion || cfg.SQLInjection {
|
||||
t.Fatalf("unexpected config %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileUACheckConfigNormalizesListsAndMatchMode(t *testing.T) {
|
||||
graph := RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{
|
||||
{ID: "start", Type: RuleNodeStart, Config: rawConfig(`{}`)},
|
||||
|
||||
@@ -26,6 +26,8 @@ const (
|
||||
RuleNodePoW RuleNodeType = "pow"
|
||||
// RuleNodeUACheck branches on User-Agent presence, classification, and lists.
|
||||
RuleNodeUACheck RuleNodeType = "ua_check"
|
||||
// RuleNodeSecurityCheck branches on basic request payload attack signatures.
|
||||
RuleNodeSecurityCheck RuleNodeType = "security_check"
|
||||
)
|
||||
|
||||
// RuleGraph is the editor-facing representation of an executable WAF graph.
|
||||
@@ -103,6 +105,28 @@ const (
|
||||
UACheckMatchModeOr = "or"
|
||||
)
|
||||
|
||||
// SecurityCheckConfig toggles basic payload signature protections.
|
||||
// Default graph nodes enable path_traversal and file_inclusion only.
|
||||
type SecurityCheckConfig struct {
|
||||
SQLInjection bool `json:"sql_injection"`
|
||||
PathTraversal bool `json:"path_traversal"`
|
||||
CommandInjection bool `json:"command_injection"`
|
||||
XSS bool `json:"xss"`
|
||||
SSRF bool `json:"ssrf"`
|
||||
FileInclusion bool `json:"file_inclusion"`
|
||||
MaliciousUpload bool `json:"malicious_upload"`
|
||||
XXE bool `json:"xxe"`
|
||||
CRLFInjection bool `json:"crlf_injection"`
|
||||
}
|
||||
|
||||
// DefaultSecurityCheckConfig returns low false-positive defaults.
|
||||
func DefaultSecurityCheckConfig() SecurityCheckConfig {
|
||||
return SecurityCheckConfig{
|
||||
PathTraversal: true,
|
||||
FileInclusion: true,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultRuleGraph returns the minimal start-to-allow graph.
|
||||
func DefaultRuleGraph() RuleGraph {
|
||||
return RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{
|
||||
|
||||
@@ -89,7 +89,7 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE
|
||||
startID = node.ID
|
||||
case RuleNodeAllow:
|
||||
allowCount++
|
||||
case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW, RuleNodeUACheck:
|
||||
case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW, RuleNodeUACheck, RuleNodeSecurityCheck:
|
||||
default:
|
||||
return nil, "", fmt.Errorf("节点 %s 的类型 %s 未知", node.ID, node.Type)
|
||||
}
|
||||
@@ -184,6 +184,8 @@ func validateRuleNodeConfig(ctx context.Context, node RuleNode, exists func(cont
|
||||
return validatePoWNodeConfig(node)
|
||||
case RuleNodeUACheck:
|
||||
return validateUACheckNodeConfig(node)
|
||||
case RuleNodeSecurityCheck:
|
||||
return validateSecurityCheckNodeConfig(node)
|
||||
case RuleNodeBlock:
|
||||
return validateBlockNodeConfig(node)
|
||||
}
|
||||
@@ -329,6 +331,11 @@ func validateUACheckNodeConfig(node RuleNode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityCheckNodeConfig(node RuleNode) error {
|
||||
var cfg SecurityCheckConfig
|
||||
return decodeNodeConfig(node, &cfg)
|
||||
}
|
||||
|
||||
var uaBrowserLabels = map[string]bool{
|
||||
"Chrome": true, "Safari": true, "Firefox": true, "Edge": true, "Opera": true,
|
||||
"Chromium": true, "WeChat": true, "Postman": true, "CLI": true, "Bot": true,
|
||||
@@ -378,7 +385,7 @@ func requiredHandles(t RuleNodeType) []string {
|
||||
switch t {
|
||||
case RuleNodeStart, RuleNodePoW:
|
||||
return []string{"next"}
|
||||
case RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodeUACheck:
|
||||
case RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodeUACheck, RuleNodeSecurityCheck:
|
||||
return []string{"true", "false"}
|
||||
default:
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user