diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 0e60deea..b0ff7ce2 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -21,6 +21,10 @@ sidebar: false ## [unreleased] +### 新增 + +- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫与非正常 UA。 + ### 改进 - WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。 diff --git a/docs/design/waf-orchestration-design.md b/docs/design/waf-orchestration-design.md index 02058e5b..cd18d029 100644 --- a/docs/design/waf-orchestration-design.md +++ b/docs/design/waf-orchestration-design.md @@ -15,9 +15,10 @@ | 阻止 | 可创建多个 | 一个或多个 | 无 | HTTP 状态码、HTML 响应体 | | IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID | | 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 | +| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA | | PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL | -IP 匹配和地域匹配不区分黑名单或白名单。`true` 只表示请求匹配节点配置,`false` 只表示未匹配;放行或阻止的业务含义完全由连线决定。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。 +IP 匹配、地域匹配与 UA 检查不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。 不在第一阶段实现循环、脚本节点、任意表达式节点、子图调用和跨规则跳转。 @@ -40,7 +41,7 @@ IP 匹配和地域匹配不区分黑名单或白名单。`true` 只表示请求 * 图是有向无环图,禁止自环和任意循环。 * 恰好存在一个开始节点和一个通过节点;阻止节点可以存在多个。 * 开始节点无入边且恰好有一个 `next` 出口;通过和阻止节点无出口。 -* IP 匹配与地域匹配的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。 +* IP 匹配、地域匹配与 UA 检查的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。 * 除终止节点外不得存在悬空出口;每个非开始节点至少有一条入边。 * 所有节点都必须从开始节点可达,且从每个可执行节点出发都能抵达通过或阻止。 * 边的源端口必须属于源节点类型;同一源端口不得连接多个目标。 @@ -86,7 +87,7 @@ React Flow 编辑页采用全宽画布和固定右侧属性栏: * 顶部提供返回、规则名称、启用状态、校验状态和保存操作。 * 画布使用紧凑高度和较小的首次适配缩放,支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。 -* “添加处理单元”提供 IP 匹配、地域匹配、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。 +* “添加处理单元”提供 IP 匹配、地域匹配、UA 检查、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。 * 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。 * 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。 * 地域匹配属性使用完整国家与 ISO 3166-2 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。 diff --git a/docs/superpowers/plans/2026-07-19-waf-ua-check-node.md b/docs/superpowers/plans/2026-07-19-waf-ua-check-node.md new file mode 100644 index 00000000..2a4b7be4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-waf-ua-check-node.md @@ -0,0 +1,42 @@ +# WAF UA Check Node Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add WAF graph node `ua_check` (require UA, browser/OS whitelist with and/or, bot/abnormal blocks) end-to-end: validate/compile, Lua runtime, editor UI. + +**Architecture:** Match-node pattern like `geo_match`. Control plane stores `UACheckConfig`; edge classifies `http_user_agent` with analytics-equivalent token rules; evaluation order: require → block bots → block abnormal → whitelist. + +**Tech Stack:** Go (waf package), Lua (OpenResty waf_runtime), React/TS editor, Vitest, Go tests. + +**Spec:** `docs/superpowers/specs/2026-07-19-waf-ua-check-node-design.md` + +## Global Constraints + +- Type `ua_check`; handles `true`/`false`. +- Config fields: `require_ua`, `browsers`, `operating_systems`, `match_mode` (`and`|`or`, default `or`), `block_common_bots`, `block_abnormal_ua`. +- Closed enums for browser/OS labels matching analytics. +- Block before whitelist; empty lists = no whitelist constraint. +- No schema_version bump; no new HTTP API. +- Changelog + Chinese design doc update. + +## File Map + +| File | Role | +|------|------| +| `internal/apps/openflare/waf/graph_types.go` | Type + config | +| `internal/apps/openflare/waf/graph_validate.go` | Validate + handles | +| `internal/apps/openflare/waf/graph_compile.go` | Compile normalize | +| `internal/apps/openflare/waf/*_test.go` | Go tests | +| `internal/apps/agent/nginx/waf_runtime.lua` | Runtime eval | +| `internal/apps/agent/nginx/waf_runtime_spec.lua` | Lua specs | +| `internal/apps/agent/nginx/manager_test.go` | Embed smoke if needed | +| Frontend editor components + types | UI | +| `docs/design/waf-orchestration-design.md` | Node table | +| `docs/changelog/index.md` | Unreleased | + +### Task 1: Backend types/validate/compile +### Task 2: Lua runtime + specs +### Task 3: Frontend editor +### Task 4: Docs + gates + +(Detailed code follows during implementation; execute TDD per layer.) 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 5b187fd6..d7478407 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 @@ -86,18 +86,21 @@ 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) browser_ok := browsers 为空 or browser ∈ browsers - os_ok := operating_systems 为空 or os ∈ operating_systems -6) if browsers 与 operating_systems 皆空 → true -7) if match_mode == "and" → browser_ok and os_ok - if match_mode == "or" → browser_ok or os_ok +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: + match_mode == "and" → browser_hit and os_hit + match_mode == "or" → browser_hit or os_hit ``` 说明: - **屏蔽优先于匹配**:步骤 3–4 在白名单之前。 - **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。 -- **仅一侧列表有值**:另一侧 `*_ok` 恒 true;`and`/`or` 结果等价于该侧是否命中。 +- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。 - 节点本身不 allow/block,仅选句柄;下游连线决定动作。 ### 示例 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 bdb2023c..fc9f4ee9 100644 --- a/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts +++ b/frontend/app/(main)/waf/rules/editor/components/editor-behavior.ts @@ -78,6 +78,7 @@ export function isConnectionAllowed( start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], + ua_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 bfa9c21b..177e0bff 100644 --- a/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts +++ b/frontend/app/(main)/waf/rules/editor/components/graph-validation.ts @@ -1,5 +1,7 @@ import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare'; +import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options'; + export type GraphIssueCode = | 'schema' | 'size_limit' @@ -28,6 +30,7 @@ const handles: Partial> = { start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], + ua_check: ['true', 'false'], pow: ['next'], }; @@ -208,6 +211,14 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined { new TextEncoder().encode(node.config.response_body).length > 16 * 1024) ) return `节点 ${node.id} 的阻止响应配置无效`; + if (node.type === 'ua_check') { + if (!['and', 'or'].includes(node.config.match_mode)) + return `节点 ${node.id} 的匹配模式必须为 and 或 or`; + if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label))) + return `节点 ${node.id} 包含无效浏览器标签`; + if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label))) + return `节点 ${node.id} 包含无效操作系统标签`; + } 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 1e1b422e..7c7c2c39 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 @@ -48,7 +48,25 @@ describe('createRuleNode', () => { 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('start')).toBeNull(); expect(parseAddableNodeType('')).toBeNull(); }); }); + +describe('createRuleNode ua_check', () => { + it('creates default UA check config', () => { + const node = createRuleNode('ua_check', { x: 1, y: 2 }); + expect(node.type).toBe('ua_check'); + if (node.type === 'ua_check') { + expect(node.config).toEqual({ + require_ua: false, + browsers: [], + operating_systems: [], + match_mode: 'or', + block_common_bots: false, + block_abnormal_ua: false, + }); + } + }); +}); 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 aa04b7cd..37f2d007 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-factory.ts +++ b/frontend/app/(main)/waf/rules/editor/components/node-factory.ts @@ -4,13 +4,14 @@ export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node'; export type AddableNodeType = Extract< WAFRuleNode['type'], - 'ip_match' | 'geo_match' | 'pow' | 'block' + 'ip_match' | 'geo_match' | 'ua_check' | 'pow' | 'block' >; export const NODE_TYPE_LABELS: Record = { start: '开始', ip_match: 'IP 匹配', geo_match: '地域匹配', + ua_check: 'UA 检查', pow: 'PoW 挑战', allow: '通过', block: '阻止', @@ -37,6 +38,20 @@ export function createRuleNode( }; if (type === 'geo_match') return { id, type, position, config: { countries: [], regions: [] } }; + if (type === 'ua_check') + return { + id, + type, + position, + config: { + require_ua: false, + browsers: [], + operating_systems: [], + match_mode: 'or', + block_common_bots: false, + block_abnormal_ua: false, + }, + }; if (type === 'pow') return { id, @@ -61,6 +76,7 @@ export function parseAddableNodeType(value: string): AddableNodeType | null { if ( value === 'ip_match' || value === 'geo_match' || + value === 'ua_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 a6bc8fa6..4993889b 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-library.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-library.tsx @@ -1,4 +1,10 @@ -import { Ban, Fingerprint, Globe2, ShieldCheck } from 'lucide-react'; +import { + Ban, + Fingerprint, + Globe2, + ScanSearch, + ShieldCheck, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -11,6 +17,7 @@ import { const items = [ { type: 'ip_match' as const, icon: Fingerprint }, { type: 'geo_match' as const, icon: Globe2 }, + { type: 'ua_check' as const, icon: ScanSearch }, { 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 d07a2273..9ec88e2a 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 @@ -5,6 +5,35 @@ import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare'; import { NodeProperties } from './node-properties'; +it('toggles UA check switches and match mode', () => { + const node: WAFRuleNode = { + id: 'ua', + type: 'ua_check', + position: { x: 0, y: 0 }, + config: { + require_ua: false, + browsers: [], + operating_systems: [], + match_mode: 'or', + block_common_bots: false, + block_abnormal_ua: false, + }, + }; + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('开启 UA 检查')); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ require_ua: true }), + }), + ); + expect(screen.getByLabelText('屏蔽常见爬虫 UA')).toBeInTheDocument(); + expect(screen.getByLabelText('屏蔽非正常 UA')).toBeInTheDocument(); + expect( + screen.getByText('命中返回 false,优先级高于匹配'), + ).toBeInTheDocument(); +}); + it('edits display name for configurable nodes', () => { const node: WAFRuleNode = { id: 'match', 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 35e8c4c9..f659ea7b 100644 --- a/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/node-properties.tsx @@ -25,11 +25,13 @@ import { SelectValue, } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare'; import { countryOptions, regionOptions, type GeoOption } from './geo-options'; import { NODE_TYPE_LABELS } from './node-factory'; +import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options'; export function NodeProperties({ node, @@ -137,6 +139,133 @@ function PropertyFields({ /> ); + if (node.type === 'ua_check') + return ( + + +
+

UA 检查

+ +
+ + 开启 UA 检查 + + + 开启后如果请求头不携带 UA 返回 False + +
+ + onChange({ ...node, config: { ...node.config, require_ua } }) + } + /> +
+
+ +
+

UA 匹配

+ + 匹配模式 + + + 浏览器与操作系统两侧都有选择时生效 + + + ({ + value: option.value, + label: option.label, + searchText: `${option.label} ${option.value}`, + }))} + value={node.config.browsers} + onChange={(browsers) => + onChange({ ...node, config: { ...node.config, browsers } }) + } + /> + ({ + value: option.value, + label: option.label, + searchText: `${option.label} ${option.value}`, + }))} + value={node.config.operating_systems} + onChange={(operating_systems) => + onChange({ + ...node, + config: { ...node.config, operating_systems }, + }) + } + /> +
+ +
+
+

屏蔽

+ 命中返回 false,优先级高于匹配 +
+ + + 屏蔽常见爬虫 UA + + + onChange({ + ...node, + config: { ...node.config, block_common_bots }, + }) + } + /> + + + + 屏蔽非正常 UA + + + onChange({ + ...node, + config: { ...node.config, block_abnormal_ua }, + }) + } + /> + +
+
+ ); 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 7c2495b0..28a66b0c 100644 --- a/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/rule-node.tsx @@ -5,6 +5,7 @@ import { Flag, Globe2, Play, + ScanSearch, ShieldCheck, } from 'lucide-react'; @@ -23,6 +24,7 @@ const meta = { start: { icon: Play }, ip_match: { icon: Fingerprint }, geo_match: { icon: Globe2 }, + ua_check: { icon: ScanSearch }, pow: { icon: ShieldCheck }, allow: { icon: Flag }, block: { icon: Ban }, @@ -32,6 +34,7 @@ const outputHandles: Partial> = { start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], + ua_check: ['true', 'false'], pow: ['next'], }; diff --git a/frontend/app/(main)/waf/rules/editor/components/ua-options.ts b/frontend/app/(main)/waf/rules/editor/components/ua-options.ts new file mode 100644 index 00000000..0d0517aa --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/ua-options.ts @@ -0,0 +1,33 @@ +export const UA_BROWSER_OPTIONS = [ + { value: 'Chrome', label: 'Chrome' }, + { value: 'Safari', label: 'Safari' }, + { value: 'Firefox', label: 'Firefox' }, + { value: 'Edge', label: 'Edge' }, + { value: 'Opera', label: 'Opera' }, + { value: 'Chromium', label: 'Chromium' }, + { value: 'WeChat', label: '微信' }, + { value: 'Postman', label: 'Postman' }, + { value: 'CLI', label: 'CLI' }, + { value: 'Bot', label: 'Bot' }, + { value: 'Unknown', label: 'Unknown' }, + { value: 'Other', label: 'Other' }, +] as const; + +export const UA_OS_OPTIONS = [ + { value: 'Android', label: 'Android' }, + { value: 'iOS', label: 'iOS' }, + { value: 'Windows', label: 'Windows' }, + { value: 'macOS', label: 'macOS' }, + { value: 'Chrome OS', label: 'Chrome OS' }, + { value: 'Linux', label: 'Linux' }, + { value: 'Bot', label: 'Bot' }, + { value: 'Unknown', label: 'Unknown' }, + { value: 'Other', label: 'Other' }, +] as const; + +export const UA_BROWSER_LABELS = new Set( + UA_BROWSER_OPTIONS.map((option) => option.value), +); +export const UA_OS_LABELS = new Set( + UA_OS_OPTIONS.map((option) => option.value), +); diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 2c097a7a..9cc7f642 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -779,6 +779,15 @@ export interface BlockNodeConfig { response_body: string; } +export interface UACheckConfig { + require_ua: boolean; + browsers: string[]; + operating_systems: string[]; + match_mode: 'and' | 'or'; + block_common_bots: boolean; + block_abnormal_ua: boolean; +} + export type WAFRuleNode = | { id: string; @@ -801,6 +810,13 @@ export type WAFRuleNode = position: XYPosition; config: GeoMatchConfig; } + | { + id: string; + type: 'ua_check'; + label?: string; + position: XYPosition; + config: UACheckConfig; + } | { id: string; type: 'pow'; diff --git a/internal/apps/agent/nginx/manager_test.go b/internal/apps/agent/nginx/manager_test.go index 888f3df9..6bef33d1 100644 --- a/internal/apps/agent/nginx/manager_test.go +++ b/internal/apps/agent/nginx/manager_test.go @@ -782,6 +782,9 @@ func TestManagedWAFLuaExecutesCompiledGraphWithoutRequestIO(t *testing.T) { if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "ip_match"`) { t.Fatal("expected WAF runtime to execute compiled IP match nodes") } + if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "ua_check"`) { + t.Fatal("expected WAF runtime to execute compiled UA 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 31085701..14d6cb5b 100644 --- a/internal/apps/agent/nginx/waf_runtime.lua +++ b/internal/apps/agent/nginx/waf_runtime.lua @@ -294,6 +294,108 @@ local function matches_ip_values(config, ip) return false end +local function ua_trim(value) + return (string.gsub(value or "", "^%s*(.-)%s*$", "%1")) +end + +local function ua_label_in(items, value) + if type(items) ~= "table" or not value then return false end + for _, item in ipairs(items) do + if tostring(item) == value then return true end + end + return false +end + +local function match_ua_rules(ua_lower, rules, fallback) + if ua_lower == "" then return "Unknown" end + for _, rule in ipairs(rules) do + local matched = false + for _, token in ipairs(rule.contains or {}) do + if string.find(ua_lower, token, 1, true) then + matched = true + break + end + end + if not matched and type(rule.all_of) == "table" and #rule.all_of > 0 then + matched = true + for _, token in ipairs(rule.all_of) do + if not string.find(ua_lower, token, 1, true) then + matched = false + break + end + end + end + if matched then + local excluded = false + for _, token in ipairs(rule.none_of or {}) do + if string.find(ua_lower, token, 1, true) then + excluded = true + break + end + end + if not excluded then return rule.label end + end + end + return fallback +end + +-- Mirrors internal/repository/analytics/browser.go browserRules / osRules. +local browser_rules = { + { label = "WeChat", contains = { "micromessenger" } }, + { label = "Postman", contains = { "postman" } }, + { label = "CLI", contains = { "curl/", "wget/" } }, + { label = "Edge", contains = { "edg/", "edgios/", "edga/" } }, + { label = "Opera", contains = { "opr/", "opera" } }, + { label = "Firefox", contains = { "firefox", "fxios" } }, + { label = "Chrome", contains = { "crios", "chrome" }, none_of = { "chromium" } }, + { label = "Chromium", contains = { "chromium" } }, + { label = "Safari", contains = { "safari" } }, + { label = "Bot", contains = { "bot", "spider", "crawler", "slurp" } }, +} + +local os_rules = { + { label = "Android", contains = { "android" } }, + { label = "iOS", contains = { "iphone", "ipad", "ipod", "ios" } }, + { label = "Windows", contains = { "windows" } }, + { label = "macOS", contains = { "mac os x", "macintosh", "macos" } }, + { label = "Chrome OS", contains = { "cros" } }, + { label = "Linux", contains = { "linux" } }, + { label = "Bot", contains = { "bot", "spider", "crawler" } }, +} + +local function parse_browser_name(ua) + return match_ua_rules(string.lower(ua or ""), browser_rules, "Other") +end + +local function parse_os_name(ua) + return match_ua_rules(string.lower(ua or ""), os_rules, "Other") +end + +local function matches_ua_check(config) + config = config or {} + local ua = ua_trim(ngx.var.http_user_agent or "") + if config.require_ua and ua == "" then return false end + 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 + return false + end + local browsers = array_or_empty(config.browsers) + local operating_systems = array_or_empty(config.operating_systems) + local has_browsers = #browsers > 0 + local has_os = #operating_systems > 0 + if not has_browsers and not has_os then return true end + local browser_ok = ua_label_in(browsers, browser) + local os_ok = ua_label_in(operating_systems, os_name) + if has_browsers and not has_os then return browser_ok end + if has_os and not has_browsers then return os_ok end + local mode = config.match_mode + if mode ~= "and" and mode ~= "or" then mode = "or" end + if mode == "and" then return browser_ok and os_ok end + return browser_ok or os_ok +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 @@ -347,6 +449,8 @@ local function execute_graph(graph) local region_required = type(config.regions) == "table" and #config.regions > 0 local country, region = geo_lookup(ngx.var.remote_addr or "", region_required) 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 == "pow" then if pow_runtime.evaluate(node.config or {}) ~= true then return { kind = "takeover" } diff --git a/internal/apps/agent/nginx/waf_runtime_spec.lua b/internal/apps/agent/nginx/waf_runtime_spec.lua index 28396db4..344b9e23 100644 --- a/internal/apps/agent/nginx/waf_runtime_spec.lua +++ b/internal/apps/agent/nginx/waf_runtime_spec.lua @@ -79,8 +79,15 @@ local function load_runtime(config, options) return runtime end -local function reset_request(site, ip, uri, is_internal) - ngx.var = { openflare_waf_site = site, remote_addr = ip or "192.0.2.1", uri = uri or "/", request_id = "request-1", openflare_internal = is_internal == true } +local function reset_request(site, ip, uri, is_internal, user_agent) + ngx.var = { + openflare_waf_site = site, + remote_addr = ip or "192.0.2.1", + uri = uri or "/", + request_id = "request-1", + openflare_internal = is_internal == true, + http_user_agent = user_agent, + } ngx.ctx = {} ngx.header = {} output = {} @@ -531,6 +538,98 @@ local function test_request_path_has_no_file_io() io.open = original_open end +local function test_ua_check_require_block_and_whitelist() + local chrome_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + local safari_ios_ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + local bot_ua = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + local weird_ua = "TotallyUnknownClient/1.0" + + local function ua_graph(config) + return graph({ + start = start_to("ua"), + ua = node("ua_check", config, { ["true"] = "allow", ["false"] = "blocked" }), + blocked = node("block", { status_code = 403, response_body = "ua blocked" }), + allow = node("allow"), + }) + end + + local runtime = load_runtime({ + rule_groups = { rule(1, false, ua_graph({ require_ua = true })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + reset_request("ua-site", nil, nil, nil, nil) + runtime.check() + assert_equal(output.exit, 403, "missing UA with require_ua should block") + reset_request("ua-site", nil, nil, nil, chrome_ua) + output = {} + runtime.check() + assert_equal(output.exit, nil, "present UA with require_ua should allow") + + runtime = load_runtime({ + rule_groups = { rule(1, false, ua_graph({ block_common_bots = true })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + reset_request("ua-site", nil, nil, nil, bot_ua) + runtime.check() + assert_equal(output.exit, 403, "common bot should be blocked") + + runtime = load_runtime({ + rule_groups = { rule(1, false, ua_graph({ block_abnormal_ua = true })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + 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, 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({ browsers = { "Chrome" }, match_mode = "or" })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + reset_request("ua-site", nil, nil, nil, safari_ios_ua) + runtime.check() + assert_equal(output.exit, 403, "Safari should miss Chrome whitelist") + reset_request("ua-site", nil, nil, nil, chrome_ua) + output = {} + runtime.check() + assert_equal(output.exit, nil, "Chrome should hit whitelist") + + runtime = load_runtime({ + rule_groups = { rule(1, false, ua_graph({ + browsers = { "Chrome" }, + operating_systems = { "iOS" }, + match_mode = "and", + })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + reset_request("ua-site", nil, nil, nil, chrome_ua) + runtime.check() + assert_equal(output.exit, 403, "Chrome desktop should fail Chrome+iOS and") + reset_request("ua-site", nil, nil, nil, safari_ios_ua) + output = {} + runtime.check() + assert_equal(output.exit, 403, "Safari iOS should fail Chrome+iOS and") + + runtime = load_runtime({ + rule_groups = { rule(1, false, ua_graph({ + browsers = { "Chrome" }, + operating_systems = { "iOS" }, + match_mode = "or", + })) }, + bindings = { binding("ua-site", { 1 }) }, + }) + reset_request("ua-site", nil, nil, nil, chrome_ua) + runtime.check() + assert_equal(output.exit, nil, "Chrome desktop should pass Chrome|iOS or") + reset_request("ua-site", nil, nil, nil, safari_ios_ua) + output = {} + runtime.check() + assert_equal(output.exit, nil, "Safari iOS should pass Chrome|iOS or") +end + test_ip_true_and_false() test_ipv6_exact_cidr_and_group() test_geo_true_and_false() @@ -546,5 +645,6 @@ 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() +test_ua_check_require_block_and_whitelist() return true diff --git a/internal/apps/openflare/waf/graph_compile.go b/internal/apps/openflare/waf/graph_compile.go index 0bef0f4f..5ca18b21 100644 --- a/internal/apps/openflare/waf/graph_compile.go +++ b/internal/apps/openflare/waf/graph_compile.go @@ -88,6 +88,17 @@ func compileRuleNodeConfig(node RuleNode) (any, error) { case RuleNodePoW: var config PoWNodeConfig return config, decodeStrictConfig(node.Config, &config) + case RuleNodeUACheck: + var config UACheckConfig + if err := decodeStrictConfig(node.Config, &config); err != nil { + return nil, err + } + config.Browsers = sortedUniqueStrings(config.Browsers) + config.OperatingSystems = sortedUniqueStrings(config.OperatingSystems) + if config.MatchMode == "" { + config.MatchMode = UACheckMatchModeOr + } + return config, nil case RuleNodeBlock: var config BlockNodeConfig return config, decodeStrictConfig(node.Config, &config) diff --git a/internal/apps/openflare/waf/graph_compile_test.go b/internal/apps/openflare/waf/graph_compile_test.go index 1fd4e9b3..c2d44558 100644 --- a/internal/apps/openflare/waf/graph_compile_test.go +++ b/internal/apps/openflare/waf/graph_compile_test.go @@ -42,6 +42,39 @@ func TestCompileRuleGraph(t *testing.T) { } } +func TestCompileUACheckConfigNormalizesListsAndMatchMode(t *testing.T) { + graph := RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{ + {ID: "start", Type: RuleNodeStart, Config: rawConfig(`{}`)}, + {ID: "ua", Type: RuleNodeUACheck, Config: rawConfig(`{"browsers":["Safari","Chrome","Chrome"],"operating_systems":["iOS","Android"],"require_ua":true,"block_common_bots":true}`)}, + {ID: "allow", Type: RuleNodeAllow, Config: rawConfig(`{}`)}, + {ID: "block", Type: RuleNodeBlock, Config: rawConfig(`{"status_code":403}`)}, + }, Edges: []RuleEdge{ + {ID: "e1", Source: "start", SourceHandle: "next", Target: "ua"}, + {ID: "e2", Source: "ua", SourceHandle: "true", Target: "allow"}, + {ID: "e3", Source: "ua", SourceHandle: "false", Target: "block"}, + }} + compiled, err := CompileRuleGraph(graph) + if err != nil { + t.Fatalf("CompileRuleGraph() error = %v", err) + } + cfg, ok := compiled.Nodes["ua"].Config.(UACheckConfig) + if !ok { + t.Fatalf("config type = %T", compiled.Nodes["ua"].Config) + } + if !reflect.DeepEqual(cfg.Browsers, []string{"Chrome", "Safari"}) { + t.Fatalf("browsers = %#v", cfg.Browsers) + } + if !reflect.DeepEqual(cfg.OperatingSystems, []string{"Android", "iOS"}) { + t.Fatalf("os = %#v", cfg.OperatingSystems) + } + if cfg.MatchMode != UACheckMatchModeOr { + t.Fatalf("match_mode = %q, want or", cfg.MatchMode) + } + if !cfg.RequireUA || !cfg.BlockCommonBots || cfg.BlockAbnormalUA { + t.Fatalf("flags = %#v", cfg) + } +} + func TestCompileRuleGraphIsDeterministicForNodeAndEdgeOrder(t *testing.T) { first := RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{ {ID: "start", Type: RuleNodeStart, Config: rawConfig(`{}`)}, diff --git a/internal/apps/openflare/waf/graph_types.go b/internal/apps/openflare/waf/graph_types.go index 4a924b49..5d8d7365 100644 --- a/internal/apps/openflare/waf/graph_types.go +++ b/internal/apps/openflare/waf/graph_types.go @@ -24,6 +24,8 @@ const ( RuleNodeGeoMatch RuleNodeType = "geo_match" // RuleNodePoW runs a proof-of-work challenge before continuing. RuleNodePoW RuleNodeType = "pow" + // RuleNodeUACheck branches on User-Agent presence, classification, and lists. + RuleNodeUACheck RuleNodeType = "ua_check" ) // RuleGraph is the editor-facing representation of an executable WAF graph. @@ -83,6 +85,22 @@ type BlockNodeConfig struct { ResponseBody string `json:"response_body,omitempty"` } +// UACheckConfig configures User-Agent presence, whitelist, and block switches. +type UACheckConfig struct { + RequireUA bool `json:"require_ua"` + Browsers []string `json:"browsers,omitempty"` + OperatingSystems []string `json:"operating_systems,omitempty"` + MatchMode string `json:"match_mode,omitempty"` + BlockCommonBots bool `json:"block_common_bots"` + BlockAbnormalUA bool `json:"block_abnormal_ua"` +} + +// UA check match modes. +const ( + UACheckMatchModeAnd = "and" + UACheckMatchModeOr = "or" +) + // DefaultRuleGraph returns the minimal start-to-allow graph. func DefaultRuleGraph() RuleGraph { return RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{ diff --git a/internal/apps/openflare/waf/graph_validate.go b/internal/apps/openflare/waf/graph_validate.go index 45c74096..f04ca571 100644 --- a/internal/apps/openflare/waf/graph_validate.go +++ b/internal/apps/openflare/waf/graph_validate.go @@ -87,7 +87,7 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE startID = node.ID case RuleNodeAllow: allowCount++ - case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW: + case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW, RuleNodeUACheck: default: return nil, "", fmt.Errorf("节点 %s 的类型 %s 未知", node.ID, node.Type) } @@ -180,6 +180,8 @@ func validateRuleNodeConfig(ctx context.Context, node RuleNode, exists func(cont return validateGeoMatchNodeConfig(node) case RuleNodePoW: return validatePoWNodeConfig(node) + case RuleNodeUACheck: + return validateUACheckNodeConfig(node) case RuleNodeBlock: return validateBlockNodeConfig(node) } @@ -283,6 +285,42 @@ func validateBlockNodeConfig(node RuleNode) error { return nil } +func validateUACheckNodeConfig(node RuleNode) error { + var cfg UACheckConfig + if err := decodeNodeConfig(node, &cfg); err != nil { + return err + } + mode := cfg.MatchMode + if mode == "" { + mode = UACheckMatchModeOr + } + if mode != UACheckMatchModeAnd && mode != UACheckMatchModeOr { + return fmt.Errorf("节点 %s 的匹配模式必须为 and 或 or", node.ID) + } + for _, label := range cfg.Browsers { + if !uaBrowserLabels[label] { + return fmt.Errorf("节点 %s 的浏览器标签 %s 无效", node.ID, label) + } + } + for _, label := range cfg.OperatingSystems { + if !uaOSLabels[label] { + return fmt.Errorf("节点 %s 的操作系统标签 %s 无效", node.ID, label) + } + } + return nil +} + +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, + "Unknown": true, "Other": true, +} + +var uaOSLabels = map[string]bool{ + "Android": true, "iOS": true, "Windows": true, "macOS": true, "Chrome OS": true, + "Linux": true, "Bot": true, "Unknown": true, "Other": true, +} + func decodeNodeConfig(node RuleNode, dst any) error { if err := decodeStrictConfig(node.Config, dst); err != nil { return fmt.Errorf("节点 %s 的配置无效: %w", node.ID, err) @@ -321,7 +359,7 @@ func requiredHandles(t RuleNodeType) []string { switch t { case RuleNodeStart, RuleNodePoW: return []string{"next"} - case RuleNodeIPMatch, RuleNodeGeoMatch: + case RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodeUACheck: return []string{"true", "false"} default: return nil diff --git a/internal/apps/openflare/waf/graph_validate_test.go b/internal/apps/openflare/waf/graph_validate_test.go index b1b0d99b..dc644b75 100644 --- a/internal/apps/openflare/waf/graph_validate_test.go +++ b/internal/apps/openflare/waf/graph_validate_test.go @@ -77,6 +77,14 @@ func TestValidateRuleGraph(t *testing.T) { g.Edges[1].SourceHandle = "next" g.Edges = g.Edges[:2] }, "节点 match-1 的 PoW 难度必须在 1-16 之间"}, + {"invalid ua browser", func(g *RuleGraph) { + g.Nodes[1].Type = RuleNodeUACheck + g.Nodes[1].Config = rawConfig(`{"browsers":["NotABrowser"],"match_mode":"or"}`) + }, "节点 match-1 的浏览器标签 NotABrowser 无效"}, + {"invalid ua match mode", func(g *RuleGraph) { + g.Nodes[1].Type = RuleNodeUACheck + g.Nodes[1].Config = rawConfig(`{"match_mode":"xor"}`) + }, "节点 match-1 的匹配模式必须为 and 或 or"}, {"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) {