mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat(waf): 新增 UA 检查节点 ua_check
支持要求携带 UA、浏览器/OS 白名单 and-or 匹配,以及优先屏蔽爬虫与非正常 UA。
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user