mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
feat(waf): UA 非正常不含爬虫,并支持自定义正则屏蔽
block_abnormal_ua 仅 Other/Unknown;新增 block_custom_ua 与 custom_ua_patterns。
This commit is contained in:
@@ -23,7 +23,7 @@ sidebar: false
|
||||
|
||||
### 新增
|
||||
|
||||
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫与非正常 UA。
|
||||
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
|
||||
|
||||
### 改进
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user