diff --git a/docs/changelog/index.md b/docs/changelog/index.md index b0ff7ce2..3dd21af1 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -23,7 +23,7 @@ sidebar: false ### 新增 -- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫与非正常 UA。 +- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。 ### 改进 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index cd18d029..7e95703f 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -15,7 +15,7 @@ | 阻止 | 可创建多个 | 一个或多个 | 无 | HTTP 状态码、HTML 响应体 | | IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID | | 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 | -| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA | +| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA(不含爬虫)/自定义正则 | | PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL | IP 匹配、地域匹配与 UA 检查不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。 diff --git a/docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md b/docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md index d7478407..9d08b2bf 100644 --- a/docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md +++ b/docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md @@ -47,7 +47,9 @@ "operating_systems": [], "match_mode": "or", "block_common_bots": false, - "block_abnormal_ua": false + "block_abnormal_ua": false, + "block_custom_ua": false, + "custom_ua_patterns": [] } ``` @@ -58,7 +60,9 @@ | `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS | | `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` | | `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** | -| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Bot, Other, Unknown}` → **false** | +| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** | +| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** | +| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 | 默认值:开关全 `false`,列表空,`match_mode: "or"`。 @@ -85,20 +89,21 @@ 1) if require_ua and ua 为空 → false 2) browser, os := classify(ua) 3) if block_common_bots and (browser == "Bot" or os == "Bot") → false -4) if block_abnormal_ua and browser in {"Bot","Other","Unknown"} → false -5) has_browsers := browsers 非空; has_os := operating_systems 非空 -6) if not has_browsers and not has_os → true -7) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems -8) if has_browsers and not has_os → browser_hit -9) if has_os and not has_browsers → os_hit -10) if both lists set: +4) if block_abnormal_ua and browser in {"Other","Unknown"} → false +5) if block_custom_ua and UA matches any custom_ua_patterns → false +6) has_browsers := browsers 非空; has_os := operating_systems 非空 +7) if not has_browsers and not has_os → true +8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems +9) if has_browsers and not has_os → browser_hit +10) if has_os and not has_browsers → os_hit +11) if both lists set: match_mode == "and" → browser_hit and os_hit match_mode == "or" → browser_hit or os_hit ``` 说明: -- **屏蔽优先于匹配**:步骤 3–4 在白名单之前。 +- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。 - **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。 - **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。 - 节点本身不 allow/block,仅选句柄;下游连线决定动作。 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 177e0bff..cb61819f 100644 --- a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts @@ -218,6 +218,23 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined { return `节点 ${node.id} 包含无效浏览器标签`; if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label))) return `节点 ${node.id} 包含无效操作系统标签`; + if (node.config.custom_ua_patterns.length > 32) + return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`; + for (const pattern of node.config.custom_ua_patterns) { + if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`; + if (new TextEncoder().encode(pattern).length > 256) + return `节点 ${node.id} 的自定义 UA 正则过长`; + try { + void new RegExp(pattern); + } catch { + return `节点 ${node.id} 的自定义 UA 正则无效`; + } + } + if ( + node.config.block_custom_ua && + node.config.custom_ua_patterns.length === 0 + ) + return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`; } return undefined; } 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 7c7c2c39..2255c562 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 @@ -66,6 +66,8 @@ describe('createRuleNode ua_check', () => { match_mode: 'or', block_common_bots: false, block_abnormal_ua: false, + block_custom_ua: false, + custom_ua_patterns: [], }); } }); 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 37f2d007..2e38f530 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts +++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts @@ -50,6 +50,8 @@ export function createRuleNode( match_mode: 'or', block_common_bots: false, block_abnormal_ua: false, + block_custom_ua: false, + custom_ua_patterns: [], }, }; if (type === 'pow') 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 bb4f8483..9d57b53b 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 @@ -17,6 +17,8 @@ it('hides match and block until UA check is enabled', () => { match_mode: 'or', block_common_bots: false, block_abnormal_ua: false, + block_custom_ua: false, + custom_ua_patterns: [], }, }; const onChange = vi.fn(); @@ -40,12 +42,9 @@ it('hides match and block until UA check is enabled', () => { ); expect(screen.getByLabelText('屏蔽常见爬虫 UA')).toBeInTheDocument(); expect(screen.getByLabelText('屏蔽非正常 UA')).toBeInTheDocument(); - expect( - screen.getByText(/浏览器或操作系统分类为 Bot/), - ).toBeInTheDocument(); - expect( - screen.getByText(/浏览器分类为 Bot、Other 或 Unknown/), - ).toBeInTheDocument(); + expect(screen.getByLabelText('屏蔽自定义 UA')).toBeInTheDocument(); + expect(screen.getByText(/浏览器或操作系统分类为 Bot/)).toBeInTheDocument(); + expect(screen.getByText(/浏览器分类为 Other 或 Unknown/)).toBeInTheDocument(); }); it('edits display name for configurable nodes', () => { 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 0b5cdda4..e62520e0 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -277,7 +277,8 @@ function PropertyFields({ 屏蔽非正常 UA - 浏览器分类为 Bot、Other 或 Unknown(无法识别为已知浏览器) + 浏览器分类为 Other 或 Unknown(不含搜索引擎等爬虫 + Bot,爬虫请用上方开关) + + + + 屏蔽自定义 UA + + + 原始 User-Agent 命中任一条正则时返回 false(Lua 模式语法) + + + + onChange({ + ...node, + config: { ...node.config, block_custom_ua }, + }) + } + /> + + {node.config.block_custom_ua && ( + + + 自定义 UA 正则 + + + onChange({ + ...node, + config: { + ...node.config, + custom_ua_patterns: event.target.value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean), + }, + }) + } + /> + + 每行一条 Lua 模式正则,命中任一条即 false;最多 32 条 + + + )} > )} diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 9cc7f642..feede7b6 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -786,6 +786,8 @@ export interface UACheckConfig { match_mode: 'and' | 'or'; block_common_bots: boolean; block_abnormal_ua: boolean; + block_custom_ua: boolean; + custom_ua_patterns: string[]; } export type WAFRuleNode = diff --git a/internal/apps/agent/nginx/waf_runtime.lua b/internal/apps/agent/nginx/waf_runtime.lua index 14d6cb5b..8f3ea44b 100644 --- a/internal/apps/agent/nginx/waf_runtime.lua +++ b/internal/apps/agent/nginx/waf_runtime.lua @@ -371,6 +371,18 @@ local function parse_os_name(ua) return match_ua_rules(string.lower(ua or ""), os_rules, "Other") end +local function ua_matches_custom_patterns(ua, patterns) + for _, pattern in ipairs(array_or_empty(patterns)) do + if type(pattern) == "string" and pattern ~= "" then + local ok, matched = pcall(function() + return string.find(ua, pattern) ~= nil + end) + if ok and matched then return true end + end + end + return false +end + local function matches_ua_check(config) config = config or {} local ua = ua_trim(ngx.var.http_user_agent or "") @@ -378,7 +390,11 @@ local function matches_ua_check(config) local browser = parse_browser_name(ua) local os_name = parse_os_name(ua) if config.block_common_bots and (browser == "Bot" or os_name == "Bot") then return false end - if config.block_abnormal_ua and (browser == "Bot" or browser == "Other" or browser == "Unknown") then + -- 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 + return false + end + 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) diff --git a/internal/apps/agent/nginx/waf_runtime_spec.lua b/internal/apps/agent/nginx/waf_runtime_spec.lua index 344b9e23..f7eafd64 100644 --- a/internal/apps/agent/nginx/waf_runtime_spec.lua +++ b/internal/apps/agent/nginx/waf_runtime_spec.lua @@ -580,11 +580,30 @@ local function test_ua_check_require_block_and_whitelist() reset_request("ua-site", nil, nil, nil, weird_ua) runtime.check() assert_equal(output.exit, 403, "abnormal UA should be blocked") + reset_request("ua-site", nil, nil, nil, bot_ua) + output = {} + runtime.check() + assert_equal(output.exit, nil, "search bot should not be abnormal when bots switch is off") reset_request("ua-site", nil, nil, nil, chrome_ua) output = {} runtime.check() assert_equal(output.exit, nil, "normal browser should pass abnormal check") + runtime = load_runtime({ + rule_groups = { rule(1, false, ua_graph({ + block_custom_ua = true, + custom_ua_patterns = { "[Pp]ython%-requests" }, + })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + reset_request("ua-site", nil, nil, nil, "python-requests/2.31.0") + runtime.check() + assert_equal(output.exit, 403, "custom regex should block matching UA") + reset_request("ua-site", nil, nil, nil, chrome_ua) + output = {} + runtime.check() + assert_equal(output.exit, nil, "custom regex should allow non-matching UA") + runtime = load_runtime({ rule_groups = { rule(1, false, ua_graph({ browsers = { "Chrome" }, match_mode = "or" })) }, bindings = { binding("ua-site", { 1 }) }, diff --git a/internal/apps/openflare/waf/graph_compile.go b/internal/apps/openflare/waf/graph_compile.go index 5ca18b21..9bcd9285 100644 --- a/internal/apps/openflare/waf/graph_compile.go +++ b/internal/apps/openflare/waf/graph_compile.go @@ -95,6 +95,7 @@ func compileRuleNodeConfig(node RuleNode) (any, error) { } config.Browsers = sortedUniqueStrings(config.Browsers) config.OperatingSystems = sortedUniqueStrings(config.OperatingSystems) + config.CustomUAPatterns = sortedUniqueStrings(config.CustomUAPatterns) if config.MatchMode == "" { config.MatchMode = UACheckMatchModeOr } diff --git a/internal/apps/openflare/waf/graph_types.go b/internal/apps/openflare/waf/graph_types.go index 5d8d7365..7322fdab 100644 --- a/internal/apps/openflare/waf/graph_types.go +++ b/internal/apps/openflare/waf/graph_types.go @@ -93,6 +93,8 @@ type UACheckConfig struct { MatchMode string `json:"match_mode,omitempty"` BlockCommonBots bool `json:"block_common_bots"` BlockAbnormalUA bool `json:"block_abnormal_ua"` + BlockCustomUA bool `json:"block_custom_ua"` + CustomUAPatterns []string `json:"custom_ua_patterns,omitempty"` } // UA check match modes. diff --git a/internal/apps/openflare/waf/graph_validate.go b/internal/apps/openflare/waf/graph_validate.go index f04ca571..dd79f5b3 100644 --- a/internal/apps/openflare/waf/graph_validate.go +++ b/internal/apps/openflare/waf/graph_validate.go @@ -16,9 +16,11 @@ import ( ) const ( - maxRuleGraphNodes = 128 - maxRuleGraphEdges = 256 - maxRuleGraphBytes = 256 * 1024 + maxRuleGraphNodes = 128 + maxRuleGraphEdges = 256 + maxRuleGraphBytes = 256 * 1024 + maxUACustomPatterns = 32 + maxUACustomPatternBytes = 256 ) var ( @@ -307,6 +309,23 @@ func validateUACheckNodeConfig(node RuleNode) error { return fmt.Errorf("节点 %s 的操作系统标签 %s 无效", node.ID, label) } } + if len(cfg.CustomUAPatterns) > maxUACustomPatterns { + return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 条", node.ID, maxUACustomPatterns) + } + for _, pattern := range cfg.CustomUAPatterns { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("节点 %s 的自定义 UA 正则不能为空", node.ID) + } + if len(pattern) > maxUACustomPatternBytes { + return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 字节", node.ID, maxUACustomPatternBytes) + } + if _, err := regexp.Compile(pattern); err != nil { + return fmt.Errorf("节点 %s 的自定义 UA 正则无效: %s", node.ID, pattern) + } + } + if cfg.BlockCustomUA && len(cfg.CustomUAPatterns) == 0 { + return fmt.Errorf("节点 %s 开启屏蔽自定义 UA 时至少需要一条正则", node.ID) + } return nil } diff --git a/internal/apps/openflare/waf/graph_validate_test.go b/internal/apps/openflare/waf/graph_validate_test.go index dc644b75..a5cf4356 100644 --- a/internal/apps/openflare/waf/graph_validate_test.go +++ b/internal/apps/openflare/waf/graph_validate_test.go @@ -85,6 +85,14 @@ func TestValidateRuleGraph(t *testing.T) { g.Nodes[1].Type = RuleNodeUACheck g.Nodes[1].Config = rawConfig(`{"match_mode":"xor"}`) }, "节点 match-1 的匹配模式必须为 and 或 or"}, + {"invalid ua custom regex", func(g *RuleGraph) { + g.Nodes[1].Type = RuleNodeUACheck + g.Nodes[1].Config = rawConfig(`{"block_custom_ua":true,"custom_ua_patterns":["("]}`) + }, "节点 match-1 的自定义 UA 正则无效"}, + {"custom ua requires patterns", func(g *RuleGraph) { + g.Nodes[1].Type = RuleNodeUACheck + g.Nodes[1].Config = rawConfig(`{"block_custom_ua":true}`) + }, "节点 match-1 开启屏蔽自定义 UA 时至少需要一条正则"}, {"unknown config field", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ips":[],"surprise":true}`) }, "节点 match-1 的配置无效"}, {"null config", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`null`) }, "节点 match-1 的配置无效"}, {"too many nodes", func(g *RuleGraph) {