feat(waf): UA 非正常不含爬虫,并支持自定义正则屏蔽

block_abnormal_ua 仅 Other/Unknown;新增 block_custom_ua 与 custom_ua_patterns。
This commit is contained in:
ryan
2026-07-19 11:43:45 +08:00
parent 7476c86976
commit d47ceb9971
15 changed files with 168 additions and 23 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ sidebar: false
### 新增
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫与非正常 UA。
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
### 改进
+1 -1
View File
@@ -15,7 +15,7 @@
| 阻止 | 可创建多个 | 一个或多个 | 无 | HTTP 状态码、HTML 响应体 |
| IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID |
| 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 |
| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA |
| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA(不含爬虫)/自定义正则 |
| PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL |
IP 匹配、地域匹配与 UA 检查不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
@@ -47,7 +47,9 @@
"operating_systems": [],
"match_mode": "or",
"block_common_bots": false,
"block_abnormal_ua": false
"block_abnormal_ua": false,
"block_custom_ua": false,
"custom_ua_patterns": []
}
```
@@ -58,7 +60,9 @@
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **false** |
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Bot, Other, Unknown}` → **false** |
| `block_abnormal_ua` | bool | 屏蔽非正常 UA:browser ∈ `{Other, Unknown}`(**不含** Bot/搜索引擎爬虫)→ **false** |
| `block_custom_ua` | bool | 屏蔽自定义 UA:原始 UA 命中 `custom_ua_patterns` 任一条 → **false** |
| `custom_ua_patterns` | string[] | 正则列表(边缘为 Lua 模式);开启 `block_custom_ua` 时至少一条 |
默认值:开关全 `false`,列表空,`match_mode: "or"`。
@@ -85,20 +89,21 @@
1) if require_ua and ua 为空 → false
2) browser, os := classify(ua)
3) if block_common_bots and (browser == "Bot" or os == "Bot") → false
4) if block_abnormal_ua and browser in {"Bot","Other","Unknown"} → false
5) has_browsers := browsers 非空; has_os := operating_systems 非空
6) if not has_browsers and not has_os → true
7) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
8) if has_browsers and not has_os → browser_hit
9) if has_os and not has_browsers → os_hit
10) if both lists set:
4) if block_abnormal_ua and browser in {"Other","Unknown"} → false
5) if block_custom_ua and UA matches any custom_ua_patterns → false
6) has_browsers := browsers 非空; has_os := operating_systems 非空
7) if not has_browsers and not has_os → true
8) browser_hit := browser ∈ browsers; os_hit := os ∈ operating_systems
9) if has_browsers and not has_os → browser_hit
10) if has_os and not has_browsers → os_hit
11) if both lists set:
match_mode == "and" → browser_hit and os_hit
match_mode == "or" → browser_hit or os_hit
```
说明:
- **屏蔽优先于匹配**:步骤 3–4 在白名单之前。
- **屏蔽优先于匹配**:步骤 3–5 在白名单之前。
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
@@ -218,6 +218,23 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
return `节点 ${node.id} 包含无效浏览器标签`;
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
return `节点 ${node.id} 包含无效操作系统标签`;
if (node.config.custom_ua_patterns.length > 32)
return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
for (const pattern of node.config.custom_ua_patterns) {
if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
if (new TextEncoder().encode(pattern).length > 256)
return `节点 ${node.id} 的自定义 UA 正则过长`;
try {
void new RegExp(pattern);
} catch {
return `节点 ${node.id} 的自定义 UA 正则无效`;
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
}
return undefined;
}
@@ -66,6 +66,8 @@ describe('createRuleNode ua_check', () => {
match_mode: 'or',
block_common_bots: false,
block_abnormal_ua: false,
block_custom_ua: false,
custom_ua_patterns: [],
});
}
});
@@ -50,6 +50,8 @@ export function createRuleNode(
match_mode: 'or',
block_common_bots: false,
block_abnormal_ua: false,
block_custom_ua: false,
custom_ua_patterns: [],
},
};
if (type === 'pow')
@@ -17,6 +17,8 @@ it('hides match and block until UA check is enabled', () => {
match_mode: 'or',
block_common_bots: false,
block_abnormal_ua: false,
block_custom_ua: false,
custom_ua_patterns: [],
},
};
const onChange = vi.fn();
@@ -40,12 +42,9 @@ it('hides match and block until UA check is enabled', () => {
);
expect(screen.getByLabelText('屏蔽常见爬虫 UA')).toBeInTheDocument();
expect(screen.getByLabelText('屏蔽非正常 UA')).toBeInTheDocument();
expect(
screen.getByText(/浏览器或操作系统分类为 Bot/),
).toBeInTheDocument();
expect(
screen.getByText(/浏览器分类为 Bot、Other 或 Unknown/),
).toBeInTheDocument();
expect(screen.getByLabelText('屏蔽自定义 UA')).toBeInTheDocument();
expect(screen.getByText(/浏览器或操作系统分类为 Bot/)).toBeInTheDocument();
expect(screen.getByText(/浏览器分类为 Other 或 Unknown/)).toBeInTheDocument();
});
it('edits display name for configurable nodes', () => {
@@ -277,7 +277,8 @@ function PropertyFields({
屏蔽非正常 UA
</FieldLabel>
<FieldDescription>
浏览器分类为 Bot、Other 或 Unknown(无法识别为已知浏览器)
浏览器分类为 Other 或 Unknown(不含搜索引擎等爬虫
Bot,爬虫请用上方开关)
</FieldDescription>
</div>
<Switch
@@ -292,6 +293,58 @@ function PropertyFields({
}
/>
</Field>
<Field
orientation='horizontal'
className='items-start justify-between gap-3'
>
<div className='space-y-1'>
<FieldLabel htmlFor={`${node.id}-block-custom`}>
屏蔽自定义 UA
</FieldLabel>
<FieldDescription>
原始 User-Agent 命中任一条正则时返回 false(Lua 模式语法)
</FieldDescription>
</div>
<Switch
id={`${node.id}-block-custom`}
className='mt-0.5'
checked={node.config.block_custom_ua}
onCheckedChange={(block_custom_ua) =>
onChange({
...node,
config: { ...node.config, block_custom_ua },
})
}
/>
</Field>
{node.config.block_custom_ua && (
<Field>
<FieldLabel htmlFor={`${node.id}-custom-patterns`}>
自定义 UA 正则
</FieldLabel>
<Textarea
id={`${node.id}-custom-patterns`}
rows={4}
value={node.config.custom_ua_patterns.join('\n')}
placeholder={'python%-requests\ncurl/'}
onChange={(event) =>
onChange({
...node,
config: {
...node.config,
custom_ua_patterns: event.target.value
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
},
})
}
/>
<FieldDescription>
每行一条 Lua 模式正则,命中任一条即 false;最多 32 条
</FieldDescription>
</Field>
)}
</div>
</>
)}
+2
View File
@@ -786,6 +786,8 @@ export interface UACheckConfig {
match_mode: 'and' | 'or';
block_common_bots: boolean;
block_abnormal_ua: boolean;
block_custom_ua: boolean;
custom_ua_patterns: string[];
}
export type WAFRuleNode =
+17 -1
View File
@@ -371,6 +371,18 @@ local function parse_os_name(ua)
return match_ua_rules(string.lower(ua or ""), os_rules, "Other")
end
local function ua_matches_custom_patterns(ua, patterns)
for _, pattern in ipairs(array_or_empty(patterns)) do
if type(pattern) == "string" and pattern ~= "" then
local ok, matched = pcall(function()
return string.find(ua, pattern) ~= nil
end)
if ok and matched then return true end
end
end
return false
end
local function matches_ua_check(config)
config = config or {}
local ua = ua_trim(ngx.var.http_user_agent or "")
@@ -378,7 +390,11 @@ local function matches_ua_check(config)
local browser = parse_browser_name(ua)
local os_name = parse_os_name(ua)
if config.block_common_bots and (browser == "Bot" or os_name == "Bot") then return false end
if config.block_abnormal_ua and (browser == "Bot" or browser == "Other" or browser == "Unknown") then
-- Abnormal excludes search-engine / crawler Bot labels; use block_common_bots for those.
if config.block_abnormal_ua and (browser == "Other" or browser == "Unknown") then
return false
end
if config.block_custom_ua and ua_matches_custom_patterns(ua, config.custom_ua_patterns) then
return false
end
local browsers = array_or_empty(config.browsers)
@@ -580,11 +580,30 @@ local function test_ua_check_require_block_and_whitelist()
reset_request("ua-site", nil, nil, nil, weird_ua)
runtime.check()
assert_equal(output.exit, 403, "abnormal UA should be blocked")
reset_request("ua-site", nil, nil, nil, bot_ua)
output = {}
runtime.check()
assert_equal(output.exit, nil, "search bot should not be abnormal when bots switch is off")
reset_request("ua-site", nil, nil, nil, chrome_ua)
output = {}
runtime.check()
assert_equal(output.exit, nil, "normal browser should pass abnormal check")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({
block_custom_ua = true,
custom_ua_patterns = { "[Pp]ython%-requests" },
})) },
bindings = { binding("ua-site", { 1 }) },
})
reset_request("ua-site", nil, nil, nil, "python-requests/2.31.0")
runtime.check()
assert_equal(output.exit, 403, "custom regex should block matching UA")
reset_request("ua-site", nil, nil, nil, chrome_ua)
output = {}
runtime.check()
assert_equal(output.exit, nil, "custom regex should allow non-matching UA")
runtime = load_runtime({
rule_groups = { rule(1, false, ua_graph({ browsers = { "Chrome" }, match_mode = "or" })) },
bindings = { binding("ua-site", { 1 }) },
@@ -95,6 +95,7 @@ func compileRuleNodeConfig(node RuleNode) (any, error) {
}
config.Browsers = sortedUniqueStrings(config.Browsers)
config.OperatingSystems = sortedUniqueStrings(config.OperatingSystems)
config.CustomUAPatterns = sortedUniqueStrings(config.CustomUAPatterns)
if config.MatchMode == "" {
config.MatchMode = UACheckMatchModeOr
}
@@ -93,6 +93,8 @@ type UACheckConfig struct {
MatchMode string `json:"match_mode,omitempty"`
BlockCommonBots bool `json:"block_common_bots"`
BlockAbnormalUA bool `json:"block_abnormal_ua"`
BlockCustomUA bool `json:"block_custom_ua"`
CustomUAPatterns []string `json:"custom_ua_patterns,omitempty"`
}
// UA check match modes.
+22 -3
View File
@@ -16,9 +16,11 @@ import (
)
const (
maxRuleGraphNodes = 128
maxRuleGraphEdges = 256
maxRuleGraphBytes = 256 * 1024
maxRuleGraphNodes = 128
maxRuleGraphEdges = 256
maxRuleGraphBytes = 256 * 1024
maxUACustomPatterns = 32
maxUACustomPatternBytes = 256
)
var (
@@ -307,6 +309,23 @@ func validateUACheckNodeConfig(node RuleNode) error {
return fmt.Errorf("节点 %s 的操作系统标签 %s 无效", node.ID, label)
}
}
if len(cfg.CustomUAPatterns) > maxUACustomPatterns {
return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 条", node.ID, maxUACustomPatterns)
}
for _, pattern := range cfg.CustomUAPatterns {
if strings.TrimSpace(pattern) == "" {
return fmt.Errorf("节点 %s 的自定义 UA 正则不能为空", node.ID)
}
if len(pattern) > maxUACustomPatternBytes {
return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 字节", node.ID, maxUACustomPatternBytes)
}
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("节点 %s 的自定义 UA 正则无效: %s", node.ID, pattern)
}
}
if cfg.BlockCustomUA && len(cfg.CustomUAPatterns) == 0 {
return fmt.Errorf("节点 %s 开启屏蔽自定义 UA 时至少需要一条正则", node.ID)
}
return nil
}
@@ -85,6 +85,14 @@ func TestValidateRuleGraph(t *testing.T) {
g.Nodes[1].Type = RuleNodeUACheck
g.Nodes[1].Config = rawConfig(`{"match_mode":"xor"}`)
}, "节点 match-1 的匹配模式必须为 and 或 or"},
{"invalid ua custom regex", func(g *RuleGraph) {
g.Nodes[1].Type = RuleNodeUACheck
g.Nodes[1].Config = rawConfig(`{"block_custom_ua":true,"custom_ua_patterns":["("]}`)
}, "节点 match-1 的自定义 UA 正则无效"},
{"custom ua requires patterns", func(g *RuleGraph) {
g.Nodes[1].Type = RuleNodeUACheck
g.Nodes[1].Config = rawConfig(`{"block_custom_ua":true}`)
}, "节点 match-1 开启屏蔽自定义 UA 时至少需要一条正则"},
{"unknown config field", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`{"ips":[],"surprise":true}`) }, "节点 match-1 的配置无效"},
{"null config", func(g *RuleGraph) { g.Nodes[1].Config = rawConfig(`null`) }, "节点 match-1 的配置无效"},
{"too many nodes", func(g *RuleGraph) {