From b75f985815fb2c7748db296dce137cc0a042409b Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 12:33:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(waf):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=98=B2=E6=8A=A4=E8=8A=82=E7=82=B9=20security=5Fchec?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基础特征检测九项可开关;默认开启路径穿越与文件包含;命中任意规则走 false。 --- docs/changelog/index.md | 1 + docs/design/waf-orchestration-design.md | 7 +- .../editor/components/editor-behavior.ts | 1 + .../editor/components/graph-validation.ts | 1 + .../editor/components/node-factory.test.ts | 1 + .../rules/editor/components/node-factory.ts | 21 +- .../rules/editor/components/node-library.tsx | 2 + .../components/node-properties.test.tsx | 16 +- .../editor/components/node-properties.tsx | 89 ++++++++ .../waf/rules/editor/components/rule-node.tsx | 3 + frontend/lib/services/openflare/types.ts | 19 ++ internal/apps/agent/nginx/manager_test.go | 3 + internal/apps/agent/nginx/waf_runtime.lua | 196 ++++++++++++++++++ .../apps/agent/nginx/waf_runtime_spec.lua | 75 ++++++- internal/apps/openflare/waf/graph_compile.go | 3 + .../apps/openflare/waf/graph_compile_test.go | 24 +++ internal/apps/openflare/waf/graph_types.go | 24 +++ internal/apps/openflare/waf/graph_validate.go | 11 +- 18 files changed, 486 insertions(+), 11 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index fec2320f..4e2a4a9f 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -24,6 +24,7 @@ sidebar: false ### 新增 - WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。 +- WAF 规则编排新增「安全防护」节点:可开关路径穿越、文件包含、SQL 注入、XSS、命令注入、SSRF、恶意上传、XXE 与 CRLF 等基础特征检测;默认仅开启路径穿越与文件包含。 ### 改进 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 7e95703f..5292f215 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -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 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。 diff --git a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts index fc9f4ee9..97fc0548 100644 --- a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts +++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts @@ -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 ( diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts index cb61819f..4c1be9bd 100644 --- a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts @@ -31,6 +31,7 @@ const handles: Partial> = { ip_match: ['true', 'false'], geo_match: ['true', 'false'], ua_check: ['true', 'false'], + security_check: ['true', 'false'], pow: ['next'], }; diff --git a/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts b/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts index 2255c562..744547ce 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts +++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts @@ -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(); }); diff --git a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts index 2e38f530..d203764f 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts +++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts @@ -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 = { @@ -12,6 +12,7 @@ export const NODE_TYPE_LABELS: Record = { 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' ) diff --git a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx index 4993889b..76f243e0 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx @@ -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 }[]; diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx index ee9528b4..448cb0bb 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx @@ -25,7 +25,9 @@ it('hides match and block until UA check is enabled', () => { const { rerender } = render( , ); - 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); }); diff --git a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx index ed77eafc..6d0b5d59 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -344,6 +344,95 @@ function PropertyFields({ )} ); + if (node.type === 'security_check') + return ( + + +
+

+ 安全防护 + +

+
+ +
+

基础防护

+ {( + [ + { + 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) => ( + + + {item.label} + + + + onChange({ + ...node, + config: { ...node.config, [item.key]: checked }, + }) + } + /> + + ))} +
+
+ ); if (node.type === 'pow') return ( diff --git a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx index 28a66b0c..e7b72782 100644 --- a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx @@ -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> = { ip_match: ['true', 'false'], geo_match: ['true', 'false'], ua_check: ['true', 'false'], + security_check: ['true', 'false'], pow: ['next'], }; diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index feede7b6..3273196d 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -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'; diff --git a/internal/apps/agent/nginx/manager_test.go b/internal/apps/agent/nginx/manager_test.go index 6bef33d1..51db7f50 100644 --- a/internal/apps/agent/nginx/manager_test.go +++ b/internal/apps/agent/nginx/manager_test.go @@ -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") diff --git a/internal/apps/agent/nginx/waf_runtime.lua b/internal/apps/agent/nginx/waf_runtime.lua index 8f3ea44b..4759dc8d 100644 --- a/internal/apps/agent/nginx/waf_runtime.lua +++ b/internal/apps/agent/nginx/waf_runtime.lua @@ -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 = { + "