mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
feat(waf): 新增 UA 检查节点 ua_check
支持要求携带 UA、浏览器/OS 白名单 and-or 匹配,以及优先屏蔽爬虫与非正常 UA。
This commit is contained in:
@@ -21,6 +21,10 @@ sidebar: false
|
||||
|
||||
## [unreleased]
|
||||
|
||||
### 新增
|
||||
|
||||
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫与非正常 UA。
|
||||
|
||||
### 改进
|
||||
|
||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||
|
||||
@@ -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 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。
|
||||
|
||||
@@ -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.)
|
||||
@@ -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,仅选句柄;下游连线决定动作。
|
||||
|
||||
### 示例
|
||||
|
||||
@@ -78,6 +78,7 @@ export function isConnectionAllowed(
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
ua_check: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -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<Record<WAFRuleNode['type'], string[]>> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<WAFRuleNode['type'], string> = {
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
@@ -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(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
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',
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</FieldGroup>
|
||||
);
|
||||
if (node.type === 'ua_check')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>UA 检查</p>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between'
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
<FieldLabel htmlFor={`${node.id}-require-ua`}>
|
||||
开启 UA 检查
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
开启后如果请求头不携带 UA 返回 False
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Switch
|
||||
id={`${node.id}-require-ua`}
|
||||
checked={node.config.require_ua}
|
||||
onCheckedChange={(require_ua) =>
|
||||
onChange({ ...node, config: { ...node.config, require_ua } })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>UA 匹配</p>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-match-mode`}>匹配模式</FieldLabel>
|
||||
<Select
|
||||
value={node.config.match_mode}
|
||||
onValueChange={(match_mode: 'and' | 'or') =>
|
||||
onChange({ ...node, config: { ...node.config, match_mode } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${node.id}-match-mode`} className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='or'>或(OR)</SelectItem>
|
||||
<SelectItem value='and'>且(AND)</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>
|
||||
浏览器与操作系统两侧都有选择时生效
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<MultiSelect
|
||||
id={`${node.id}-browsers`}
|
||||
label='浏览器'
|
||||
options={UA_BROWSER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
searchText: `${option.label} ${option.value}`,
|
||||
}))}
|
||||
value={node.config.browsers}
|
||||
onChange={(browsers) =>
|
||||
onChange({ ...node, config: { ...node.config, browsers } })
|
||||
}
|
||||
/>
|
||||
<MultiSelect
|
||||
id={`${node.id}-os`}
|
||||
label='操作系统'
|
||||
options={UA_OS_OPTIONS.map((option) => ({
|
||||
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 },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>屏蔽</p>
|
||||
<FieldDescription>命中返回 false,优先级高于匹配</FieldDescription>
|
||||
</div>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between'
|
||||
>
|
||||
<FieldLabel htmlFor={`${node.id}-block-bots`}>
|
||||
屏蔽常见爬虫 UA
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-block-bots`}
|
||||
checked={node.config.block_common_bots}
|
||||
onCheckedChange={(block_common_bots) =>
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, block_common_bots },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between'
|
||||
>
|
||||
<FieldLabel htmlFor={`${node.id}-block-abnormal`}>
|
||||
屏蔽非正常 UA
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-block-abnormal`}
|
||||
checked={node.config.block_abnormal_ua}
|
||||
onCheckedChange={(block_abnormal_ua) =>
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, block_abnormal_ua },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
);
|
||||
if (node.type === 'pow')
|
||||
return (
|
||||
<FieldGroup>
|
||||
|
||||
@@ -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<Record<WAFRuleNode['type'], string[]>> = {
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
ua_check: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string>(
|
||||
UA_BROWSER_OPTIONS.map((option) => option.value),
|
||||
);
|
||||
export const UA_OS_LABELS = new Set<string>(
|
||||
UA_OS_OPTIONS.map((option) => option.value),
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`{}`)},
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user