feat(waf): 新增 UA 检查节点 ua_check

支持要求携带 UA、浏览器/OS 白名单 and-or 匹配,以及优先屏蔽爬虫与非正常 UA。
This commit is contained in:
ryan
2026-07-19 11:35:31 +08:00
parent 047ed6554d
commit 28eef0bbcd
22 changed files with 643 additions and 15 deletions
@@ -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),
);
+16
View File
@@ -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';