feat(waf): 新增安全防护节点 security_check

基础特征检测九项可开关;默认开启路径穿越与文件包含;命中任意规则走 false。
This commit is contained in:
ryan
2026-07-19 12:33:13 +08:00
parent db89f68547
commit b75f985815
18 changed files with 486 additions and 11 deletions
+1
View File
@@ -24,6 +24,7 @@ sidebar: false
### 新增
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
- WAF 规则编排新增「安全防护」节点:可开关路径穿越、文件包含、SQL 注入、XSS、命令注入、SSRF、恶意上传、XXE 与 CRLF 等基础特征检测;默认仅开启路径穿越与文件包含。
### 改进
+4 -3
View File
@@ -16,9 +16,10 @@
| IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID |
| 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 |
| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA(不含爬虫)/自定义正则 |
| 安全防护 | 可创建多个 | 一个或多个 | `true`、`false` | 基础特征检测(路径穿越/文件包含默认开;SQL/XSS/命令注入/SSRF/上传/XXE/CRLF 可开关);命中任一已启用规则为 false |
| PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL |
IP 匹配、地域匹配与 UA 检查不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
IP 匹配、地域匹配、UA 检查与安全防护不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。安全防护在请求 Path/Query/Header/Cookie/Body(有限)上做特征匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
不在第一阶段实现循环、脚本节点、任意表达式节点、子图调用和跨规则跳转。
@@ -41,7 +42,7 @@ IP 匹配、地域匹配与 UA 检查不区分黑名单或白名单。`true` 只
* 图是有向无环图,禁止自环和任意循环。
* 恰好存在一个开始节点和一个通过节点;阻止节点可以存在多个。
* 开始节点无入边且恰好有一个 `next` 出口;通过和阻止节点无出口。
* IP 匹配、地域匹配与 UA 检查的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
* IP 匹配、地域匹配、UA 检查与安全防护的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
* 除终止节点外不得存在悬空出口;每个非开始节点至少有一条入边。
* 所有节点都必须从开始节点可达,且从每个可执行节点出发都能抵达通过或阻止。
* 边的源端口必须属于源节点类型;同一源端口不得连接多个目标。
@@ -87,7 +88,7 @@ React Flow 编辑页采用全宽画布和固定右侧属性栏:
* 顶部提供返回、规则名称、启用状态、校验状态和保存操作。
* 画布使用紧凑高度和较小的首次适配缩放,支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。
* “添加处理单元”提供 IP 匹配、地域匹配、UA 检查、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
* “添加处理单元”提供 IP 匹配、地域匹配、UA 检查、安全防护、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
* 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。
* 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。
* 地域匹配属性使用完整国家与 ISO 3166-2 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。
@@ -79,6 +79,7 @@ export function isConnectionAllowed(
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
ua_check: ['true', 'false'],
security_check: ['true', 'false'],
pow: ['next'],
};
return (
@@ -31,6 +31,7 @@ const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
ua_check: ['true', 'false'],
security_check: ['true', 'false'],
pow: ['next'],
};
@@ -49,6 +49,7 @@ describe('parseAddableNodeType', () => {
it('accepts addable types and rejects others', () => {
expect(parseAddableNodeType('ip_match')).toBe('ip_match');
expect(parseAddableNodeType('ua_check')).toBe('ua_check');
expect(parseAddableNodeType('security_check')).toBe('security_check');
expect(parseAddableNodeType('start')).toBeNull();
expect(parseAddableNodeType('')).toBeNull();
});
@@ -4,7 +4,7 @@ export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
export type AddableNodeType = Extract<
WAFRuleNode['type'],
'ip_match' | 'geo_match' | 'ua_check' | 'pow' | 'block'
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
>;
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
@@ -12,6 +12,7 @@ export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
ip_match: 'IP 匹配',
geo_match: '地域匹配',
ua_check: 'UA 检查',
security_check: '安全防护',
pow: 'PoW 挑战',
allow: '通过',
block: '阻止',
@@ -54,6 +55,23 @@ export function createRuleNode(
custom_ua_patterns: [],
},
};
if (type === 'security_check')
return {
id,
type,
position,
config: {
sql_injection: false,
path_traversal: true,
command_injection: false,
xss: false,
ssrf: false,
file_inclusion: true,
malicious_upload: false,
xxe: false,
crlf_injection: false,
},
};
if (type === 'pow')
return {
id,
@@ -79,6 +97,7 @@ export function parseAddableNodeType(value: string): AddableNodeType | null {
value === 'ip_match' ||
value === 'geo_match' ||
value === 'ua_check' ||
value === 'security_check' ||
value === 'pow' ||
value === 'block'
)
@@ -3,6 +3,7 @@ import {
Fingerprint,
Globe2,
ScanSearch,
Shield,
ShieldCheck,
} from 'lucide-react';
@@ -18,6 +19,7 @@ const items = [
{ type: 'ip_match' as const, icon: Fingerprint },
{ type: 'geo_match' as const, icon: Globe2 },
{ type: 'ua_check' as const, icon: ScanSearch },
{ type: 'security_check' as const, icon: Shield },
{ type: 'pow' as const, icon: ShieldCheck },
{ type: 'block' as const, icon: Ban },
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
@@ -25,7 +25,9 @@ it('hides match and block until UA check is enabled', () => {
const { rerender } = render(
<NodeProperties node={node} ipGroups={[]} onChange={onChange} />,
);
expect(screen.queryByRole('switch', { name: /屏蔽常见爬虫/ })).not.toBeInTheDocument();
expect(
screen.queryByRole('switch', { name: /屏蔽常见爬虫/ }),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('浏览器')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', { name: /开启 UA 检查/ }));
expect(onChange).toHaveBeenCalledWith(
@@ -40,9 +42,15 @@ it('hides match and block until UA check is enabled', () => {
onChange={onChange}
/>,
);
expect(screen.getByRole('switch', { name: /屏蔽常见爬虫/ })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /屏蔽非正常/ })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /屏蔽自定义/ })).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: /屏蔽常见爬虫/ }),
).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: /屏蔽非正常/ }),
).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: /屏蔽自定义/ }),
).toBeInTheDocument();
expect(screen.getAllByLabelText('说明').length).toBeGreaterThan(0);
});
@@ -344,6 +344,95 @@ function PropertyFields({
)}
</FieldGroup>
);
if (node.type === 'security_check')
return (
<FieldGroup>
<DisplayNameField node={node} onChange={onChange} />
<div className='space-y-1'>
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
安全防护
<FieldHelp tip='命中任意已启用规则返回 False' />
</p>
</div>
<Separator />
<div className='space-y-3'>
<p className='text-xs font-medium text-muted-foreground'>基础防护</p>
{(
[
{
key: 'path_traversal',
label: '路径穿越防护',
tip: '检测 Path / Query / Body 中的 ../ 与编码变种',
},
{
key: 'file_inclusion',
label: '文件包含(LFI/RFI)',
tip: '检测 Path / Query / Body 中的 php://、file://、/etc/passwd 等',
},
{
key: 'sql_injection',
label: 'SQL 注入',
tip: '检测 Query / Body / Header / Cookie 中的 SQL 注入特征',
},
{
key: 'command_injection',
label: '命令注入',
tip: '检测 Query / Body / Header 中的 OS 命令注入特征',
},
{
key: 'xss',
label: 'XSS',
tip: '检测 Query / Body / Header 中的反射型 XSS 特征',
},
{
key: 'ssrf',
label: 'SSRF',
tip: '检测 Query / Body 中的内网地址与危险协议',
},
{
key: 'malicious_upload',
label: '恶意文件上传',
tip: '检测 Multipart 文件名与危险扩展名',
},
{
key: 'xxe',
label: 'XXE',
tip: '检测 XML Body 中的外部实体特征',
},
{
key: 'crlf_injection',
label: 'CRLF 注入',
tip: '检测 Header / Query / Body 中的换行注入',
},
] as const
).map((item) => (
<Field
key={item.key}
orientation='horizontal'
className='items-center justify-between gap-3'
>
<FieldLabel
htmlFor={`${node.id}-${item.key}`}
className='flex items-center gap-1.5'
>
{item.label}
<FieldHelp tip={item.tip} />
</FieldLabel>
<Switch
id={`${node.id}-${item.key}`}
checked={node.config[item.key]}
onCheckedChange={(checked) =>
onChange({
...node,
config: { ...node.config, [item.key]: checked },
})
}
/>
</Field>
))}
</div>
</FieldGroup>
);
if (node.type === 'pow')
return (
<FieldGroup>
@@ -6,6 +6,7 @@ import {
Globe2,
Play,
ScanSearch,
Shield,
ShieldCheck,
} from 'lucide-react';
@@ -25,6 +26,7 @@ const meta = {
ip_match: { icon: Fingerprint },
geo_match: { icon: Globe2 },
ua_check: { icon: ScanSearch },
security_check: { icon: Shield },
pow: { icon: ShieldCheck },
allow: { icon: Flag },
block: { icon: Ban },
@@ -35,6 +37,7 @@ const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
ua_check: ['true', 'false'],
security_check: ['true', 'false'],
pow: ['next'],
};
+19
View File
@@ -790,6 +790,18 @@ export interface UACheckConfig {
custom_ua_patterns: string[];
}
export interface SecurityCheckConfig {
sql_injection: boolean;
path_traversal: boolean;
command_injection: boolean;
xss: boolean;
ssrf: boolean;
file_inclusion: boolean;
malicious_upload: boolean;
xxe: boolean;
crlf_injection: boolean;
}
export type WAFRuleNode =
| {
id: string;
@@ -819,6 +831,13 @@ export type WAFRuleNode =
position: XYPosition;
config: UACheckConfig;
}
| {
id: string;
type: 'security_check';
label?: string;
position: XYPosition;
config: SecurityCheckConfig;
}
| {
id: string;
type: 'pow';
@@ -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")
+196
View File
@@ -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
+74 -1
View File
@@ -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