1
0

refactor: HTTP checker 质量加固

- failure actual 截断格式改为 …(共 N 字符),标量不序列化直接返回
- 新增 redos.ts 实现 ReDoS 静态检测(嵌套量词/重叠交替),启动期拒绝危险正则
- JSON body rules 共享同一次 JSON.parse 结果,避免重复解析
- checkCssRule 重构为线性流程,消除 exist:true 与无 operator 的冗余分支
- extract checkEarlyTimeout 辅助函数,明确提前 duration 检查意图
- 补充 303/307/308 重定向、相对路径 Location、混合 body rules 集成测试
This commit is contained in:
2026-05-13 21:35:05 +08:00
parent 31aeee6d60
commit bcfac52112
18 changed files with 426 additions and 342 deletions

View File

@@ -483,14 +483,16 @@ CommandChecker.execute → 收集观测(exitCode/stdout/stderr/durationMs)
**Body 规则类型**`runner/http/body.ts` **Body 规则类型**`runner/http/body.ts`
- `contains`:文本包含匹配 - `contains`:文本包含匹配
- `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match` - `regex`正则表达式匹配注意body 正则字段为 `regex`,不是 `match`,启动期会拒绝嵌套量词等 ReDoS 风险模式
- `json`JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符) - `json`JSONPath 提取 + 操作符比较(使用 `es-toolkit/isPlainObject` 区分纯值和操作符)
- `css`cheerio CSS 选择器 + 操作符比较 - `css`cheerio CSS 选择器 + 操作符比较
- `xpath`XPath 节点提取 + 操作符比较 - `xpath`XPath 节点提取 + 操作符比较
**文本规则**`runner/command/text.ts`stdout/stderr 文本匹配,支持 `contains``match`(正则)、操作符比较 **文本规则**`runner/command/text.ts`stdout/stderr 文本匹配,支持 `contains``match`(正则)、操作符比较
**操作符**`expect/operator.ts``equals`(深度比较,`es-toolkit/isEqual`)、`contains``match`(正则)、`empty``isNil`+`isEmptyObject`)、`exists``gte`/`lte`/`gt`/`lt` **操作符**`expect/operator.ts``equals`(深度比较,`es-toolkit/isEqual`)、`contains``match`(正则,启动期通过 `expect/redos.ts` 拒绝 ReDoS 风险模式)、`empty``isNil`+`isEmptyObject`)、`exists``gte`/`lte`/`gt`/`lt`
启动期语义校验会对 HTTP body `regex` 规则和所有 `match` operator 执行静态 ReDoS 检测,常见的嵌套量词模式如 `(a+)+``(\\d+)*x` 会被拒绝,避免运行期正则在大响应体上阻塞事件循环。
### 1.11 错误模式 ### 1.11 错误模式

View File

@@ -134,7 +134,7 @@ targets:
- Command覆盖命令执行耗时含 stdout/stderr 读取) - Command覆盖命令执行耗时含 stdout/stderr 读取)
- `body`: HTTP 响应体校验(数组,可组合使用) - `body`: HTTP 响应体校验(数组,可组合使用)
- `contains`: 响应体包含的文本 - `contains`: 响应体包含的文本
- `regex`: 响应体匹配的正则表达式 - `regex`: 响应体匹配的正则表达式(启动期会拒绝嵌套量词等存在 ReDoS 风险的模式)
- `json`: JSONPath 提取值比较 - `json`: JSONPath 提取值比较
- `path`: JSONPath 表达式(必填,如 `$.slideshow.title` - `path`: JSONPath 表达式(必填,如 `$.slideshow.title`
- 比较操作符(可选,无操作符时仅检查路径对应值是否存在) - 比较操作符(可选,无操作符时仅检查路径对应值是否存在)
@@ -146,7 +146,7 @@ targets:
- `path`: XPath 表达式(必填,如 `/html/body/h1/text()` - `path`: XPath 表达式(必填,如 `/html/body/h1/text()`
- 比较操作符(可选,无操作符时仅检查节点是否存在) - 比较操作符(可选,无操作符时仅检查节点是否存在)
- `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象) - `stdout` / `stderr`: Command 输出校验(数组,每项为一个操作符对象)
- 比较操作符:`equals`(默认)、`contains``match`(正则)、`empty``exists``gte``lte``gt``lt` - 比较操作符:`equals`(默认)、`contains``match`(正则,启动期会拒绝存在 ReDoS 风险的模式)、`empty``exists``gte``lte``gt``lt`
大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。 大小说明:`maxBodyBytes``maxOutputBytes` 支持单位 `KB``MB``GB`,也可直接使用数字(非负安全整数字节数)。

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-05-13

View File

@@ -1,93 +0,0 @@
## Context
HTTP checker 是 DiAL 拨测系统的核心 runner 之一,负责对 HTTP 目标执行请求并校验响应。经审查发现以下质量问题:
1. **actual 值截断格式不符合 spec**spec 要求 failure 中的 actual 摘要需截断并附带字符计数,但当前 `truncateActual` 函数只加省略号无计数,导致用户无法判断原始响应体规模。
2. **ReDoS 风险**:用户配置的 regex body 规则和 match operator 直接对大响应体执行 `new RegExp().test()`,恶意或不当正则可能导致 CPU 阻塞。
3. **JSON 重复解析**:多条 json body 规则各自独立调用 `JSON.parse(body)`,对大 JSON 响应体造成不必要的重复开销。
4. **CSS 规则分支冗余**`checkCssRule` 中"无 operator 时检查元素存在"和"exists: true"是重复逻辑。
5. **重定向测试不足**303、307/308、相对路径 Location 等分支缺少测试覆盖。
当前代码结构:
- `src/server/checker/expect/failure.ts` — failure 构造函数
- `src/server/checker/runner/http/body.ts` — body 规则检查
- `src/server/checker/runner/http/execute.ts` — HTTP 执行主流程
- `src/server/checker/expect/operator.ts` — operator 匹配逻辑
## Goals / Non-Goals
**Goals:**
- 实现 failure actual 值截断,满足 spec 要求
- 消除 regex 相关的 ReDoS 风险
- 优化多条 JSON 规则的解析性能
- 精简冗余代码分支
- 补全重定向和集成测试覆盖
**Non-Goals:**
- 不改变 CheckResult / CheckFailure 的类型结构(截断在构造时完成,对外接口不变)
- 不引入新依赖
- 不改变 HTTP checker 的功能行为(纯内部质量改进)
- 不添加 response timing 分段记录(暂缓)
- 不添加重试机制(拨测场景下重试会掩盖网络问题信号)
## Decisions
### Decision 1: actual 截断在 mismatchFailure 构造点统一实施
**选择**:在 `expect/failure.ts``mismatchFailure` 函数内部对 actual 参数截断,阈值 200 字符。
**替代方案**
- 在存储层store.ts insertCheckResult截断 — 但这样 API 实时返回的 failure 仍然很大
- 在每个调用点手动截断 — 分散且容易遗漏
**理由**:构造点截断是最集中的拦截位置,所有 mismatch failure 都经过此函数一处修改全局生效。expected 值不截断(来自用户配置,通常很短)。
**截断格式**`<前 200 字符>…(共 N 字符)` — 保留前缀便于诊断,附带总长度便于判断规模(省略号为单字符 U+2026
### Decision 2: ReDoS 防护使用正则复杂度静态检测
**选择**:在启动期 validate 阶段对 regex body 规则和 match operator 进行静态复杂度检测,拒绝含有嵌套量词等危险模式的正则。运行期不做额外防护。
**替代方案**
- 运行期用 AbortSignal + setTimeout 强制中断 — Bun 的 RegExp 执行不可中断,无法实现
- 使用 safe-regex 库 — 引入新依赖,违反项目规范
- 限制正则执行的输入长度 — 会影响正常大响应体的匹配
**理由**:自行实现轻量级检测函数,检查常见 ReDoS 模式(嵌套量词 `(a+)+`、重叠交替 `(a|a)*`)。在 validate 阶段拒绝危险正则,比运行期防护更可靠——配置错误应该在启动时暴露。
**检测规则**
- 嵌套量词:量词内包含量词(如 `(a+)+``(a*)*``(a+)*`
- 重叠字符类交替后跟量词:`(x|x)+` 模式
### Decision 3: JSON parse 结果缓存在 checkBodyExpect 层
**选择**:在 `checkBodyExpect` 函数中,首次遇到 json 规则时执行 `JSON.parse`,将结果缓存并传递给后续 json 规则复用。
**实现方式**:修改 `checkSingleBodyRule` 签名,接受可选的 `parsedJson` 参数;在 `checkBodyExpect` 循环中维护一个 `let parsedJson: { ok: boolean; value?: unknown; error?: string }` 状态。
**理由**:改动最小,不改变外部接口,只在内部传递缓存。对于非 json 规则contains、regex、css、xpath无影响。
### Decision 4: CSS 规则分支合并策略
**选择**:将 `checkCssRule` 重构为线性流程:
1. 解析 HTML
2. 处理 `exists: false`(元素不存在即通过)
3. 查找元素(不存在则失败)
4. 处理 `exists: true`(到这里已确认存在,直接通过)
5. 提取值attr 或 text
6. 无 operator 时检查值非 undefined 即通过
7. 有 operator 时执行匹配
**理由**:消除当前三层嵌套判断中的重复逻辑,使控制流线性化,更易理解和维护。
### Decision 5: execute.ts 提前 duration 检查保留但加注释
**选择**:保留第 56-74 行的提前 duration 检查逻辑(它是有效的性能优化——避免读取注定超时的 body但重构为独立的 helper 函数使意图更明确。
**理由**:删除它会导致超时场景下仍然读取完整 body 后才报错,浪费网络带宽和时间。提取为 `checkEarlyTimeout` 函数名即可自解释。
## Risks / Trade-offs
- **ReDoS 静态检测的误报**:过于严格的检测可能拒绝合法但看起来复杂的正则。→ 缓解:只检测最常见的嵌套量词模式,不做过度分析;提供清晰的错误信息指导用户修改。
- **actual 截断丢失诊断信息**:截断后用户无法看到完整 actual 值。→ 缓解200 字符的前缀通常足够定位问题;如需完整响应体,用户应直接请求目标 URL 查看。
- **JSON parse 缓存的内存占用**:对于大 JSON 响应体,缓存的 parsed 对象会在整个 body rules 检查期间驻留内存。→ 缓解:这是短暂的(单次检查周期内),且原本每条规则都会各自 parse 一份,缓存反而减少了峰值内存。

View File

@@ -1,28 +0,0 @@
## Why
HTTP checker 经过审查发现若干质量问题failure 中 actual 值截断格式不符合 spec 要求缺少字符计数导致诊断信息不完整、regex 规则缺少 ReDoS 防护存在 CPU 阻塞风险、多条 JSON body 规则重复 parse 造成不必要开销、CSS 规则分支冗余、重定向测试覆盖不足。需要统一修复以提升健壮性和代码质量。
## What Changes
- 修正 `mismatchFailure` 中 actual 值截断格式,添加字符计数信息,格式为 `前 N 字符…(共 M 字符)`
- 为 regex body 规则和 match operator 添加 ReDoS 防护(执行超时或正则复杂度检测)
- 优化多条 JSON body 规则共享同一次 `JSON.parse` 结果,避免重复解析
- 精简 `body.ts``checkCssRule` 的冗余分支逻辑
- 精简 `execute.ts` 中提前 duration 检查的代码结构
- 补充重定向相关测试303 method 转换、307/308 保持 method、相对路径 Location、混合 body rules 集成测试
## Capabilities
### New Capabilities
### Modified Capabilities
- `expect-body-checkers`: 新增 actual 值截断的具体实现要求spec 已声明但未细化截断阈值和格式)
## Impact
- `src/server/checker/expect/failure.ts` — 新增截断逻辑
- `src/server/checker/runner/http/body.ts` — JSON parse 优化、CSS 分支精简
- `src/server/checker/runner/http/execute.ts` — duration 检查精简
- `src/server/checker/expect/operator.ts` — match operator ReDoS 防护
- `tests/server/checker/runner/http/runner.test.ts` — 补充重定向和集成测试
- `tests/server/checker/runner/shared/body.test.ts` — 补充截断相关测试

View File

@@ -1,101 +0,0 @@
## MODIFIED Requirements
> 注:仅展示变更的 scenarios其余 scenarios 保持不变
### Requirement: 结构化 expect 失败信息
系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符超出部分以省略标记和总字符数替代。expected 值不截断。
#### Scenario: body 规则失败信息
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
#### Scenario: actual 值截断
- **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
- **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
#### Scenario: actual 值未超限
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
#### Scenario: actual 值为对象或数组
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
#### Scenario: actual 值为标量
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
- **THEN** failure.actual SHALL 保留原始值,不做截断
### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
#### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段
- **WHEN** HTTP target 配置 `expect.body: [{match: "ok"}]` 且该规则没有 contains、regex、json、css、xpath 任一支持字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: body rule 未知字段启动失败
- **WHEN** HTTP target 配置 `expect.body: [{contains: "ok", note: "ignored"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示 `note` 是未知字段
#### Scenario: body rule 多支持字段非法
- **WHEN** HTTP target 的同一条 body rule 同时配置 contains 和 regex
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator match 正则非法
- **WHEN** HTTP target 的 expect.headers、json、css 或 xpath operator 配置了不可编译的 match 正则
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 数值比较类型非法
- **WHEN** HTTP target 的 expect operator 配置 gt、gte、lt 或 lte且对应值不是有限数字
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 布尔类型非法
- **WHEN** HTTP target 的 expect operator 配置 empty 或 exists且对应值不是布尔值
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: JSONPath 子集非法
- **WHEN** HTTP target 的 json body rule path 不符合系统支持的 JSONPath 子集
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: operator 未知字段非法
- **WHEN** HTTP target 的 expect operator 配置了 `foo: "bar"` 等未知 operator 字段
- **THEN** 系统 SHALL 在启动期配置校验失败
#### Scenario: equals 支持对象
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.payload", equals: {status: "ok"}}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和对象期望
#### Scenario: equals 支持数组
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.items", equals: ["a", "b"]}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期使用深度相等比较提取值和数组期望
#### Scenario: 纯 operator 对象不能为空
- **WHEN** HTTP target 的 `expect.headers` 中某个 header 期望配置为空对象 `{}`
- **THEN** 系统 SHALL 在启动期配置校验失败,要求显式配置至少一个 operator
#### Scenario: json rule 允许存在性语义
- **WHEN** HTTP target 配置 `expect.body: [{json: {path: "$.status"}}]`
- **THEN** 系统 SHALL 接受该配置,并在运行期以 JSONPath 值存在作为通过语义
#### Scenario: css rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{css: {selector: "h1", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: xpath rule 未知字段非法
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: regex body 规则含嵌套量词启动失败
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: match operator 含嵌套量词启动失败
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: 安全正则通过校验
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)

View File

@@ -1,38 +0,0 @@
## 1. failure actual 截断
- [ ] 1.1 修改 `src/server/checker/expect/failure.ts``truncateActual` 函数,截断后缀从 `...` 改为 `…(共 N 字符)`,其中省略号为单字符 U+2026
- [ ] 1.2 更新 `tests/server/checker/runner/shared/failure.test.ts` 中截断相关测试断言,匹配新格式(检查省略号为单字符且带字符计数)
## 2. ReDoS 防护
- [ ] 2.1 在 `src/server/checker/expect/` 下新增 `redos.ts`,实现 `isUnsafeRegex(pattern: string): boolean` 函数,检测嵌套量词模式
- [ ] 2.2 在 `src/server/checker/runner/http/validate.ts``validateRegexRule``src/server/checker/expect/validate-operator.ts` 的 match 校验中调用 `isUnsafeRegex`,不安全时返回 issue
- [ ] 2.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 ReDoS 正则启动校验失败的测试用例
- [ ] 2.4 在 `tests/server/checker/runner/shared/` 下新增 `redos.test.ts`,覆盖常见 ReDoS 模式和安全正则的判定
## 3. JSON parse 优化
- [ ] 3.1 修改 `src/server/checker/runner/http/body.ts``checkBodyExpect` 函数,维护 parsedJson 缓存状态,首次 json 规则 parse 后复用结果
- [ ] 3.2 修改 `checkJsonRule` 签名接受可选的预解析 JSON 对象,避免重复 `JSON.parse`
- [ ] 3.3 在 `tests/server/checker/runner/shared/body.test.ts` 中补充多条 json 规则共享 parse 结果的测试(验证行为正确性)
## 4. CSS 规则精简
- [ ] 4.1 重构 `src/server/checker/runner/http/body.ts``checkCssRule` 为线性流程:解析 HTML → exists:false 短路 → 查找元素 → exists:true 短路 → 提取值 → operator 匹配
- [ ] 4.2 确认 `tests/server/checker/runner/shared/body.test.ts` 中现有 CSS 测试全部通过
## 5. execute.ts 精简
- [ ] 5.1 将 `src/server/checker/runner/http/execute.ts` 第 56-74 行的提前 duration 检查提取为 `checkEarlyTimeout` 辅助函数,明确意图
## 6. 补充测试
- [ ] 6.1 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 303 重定向 method 转 GET 的测试
- [ ] 6.2 在 `tests/server/checker/runner/http/runner.test.ts` 中补充 307/308 保持原始 method 和 body 的测试
- [ ] 6.3 在 `tests/server/checker/runner/http/runner.test.ts` 中补充相对路径 Location header 重定向的测试
- [ ] 6.4 在 `tests/server/checker/runner/http/runner.test.ts` 中补充混合 body rulescontains + json + css集成测试
## 7. 质量保障
- [ ] 7.1 执行完整测试套件 `bun test`、代码检查 `bun run lint`、格式检查 `bun run format:check` 确保无回归
- [ ] 7.2 更新 DEVELOPMENT.md 中 ReDoS 校验相关说明(如有必要)

View File

@@ -117,15 +117,27 @@
- **THEN** 系统 SHALL 判定 matched 为 false - **THEN** 系统 SHALL 判定 matched 为 false
### Requirement: 结构化 expect 失败信息 ### Requirement: 结构化 expect 失败信息
系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。 系统 SHALL 在任一 expect 规则失败时生成结构化 failure用于标识失败阶段、规则路径、期望值、实际值和可读错误信息。actual 值 SHALL 在构造时截断至不超过 200 字符超出部分以省略标记和总字符数替代。expected 值不截断。
#### Scenario: body 规则失败信息 #### Scenario: body 规则失败信息
- **WHEN** HTTP target 的 `expect.body[1].json` 规则失败 - **WHEN** HTTP target 的 `expect.body[1].json` 规则失败
- **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message - **THEN** failure SHALL 包含 kind=`mismatch`、phase=`body`、path 指向 `expect.body[1]`,并包含 message
#### Scenario: actual 值截断 #### Scenario: actual 值截断
- **WHEN** 失败规则的实际值超过系统允许记录的摘要长度 - **WHEN** 失败规则的实际值为字符串且长度超过 200 字符
- **THEN** 系统 MUST 截断 actual 摘要,而不是持久化完整响应体或命令输出 - **THEN** failure.actual SHALL 为前 200 字符加 `…(共 N 字符)` 后缀,其中 N 为原始总字符数
#### Scenario: actual 值未超限
- **WHEN** 失败规则的实际值为字符串且长度不超过 200 字符
- **THEN** failure.actual SHALL 保留完整原始值,不做截断
#### Scenario: actual 值为对象或数组
- **WHEN** 失败规则的实际值为对象或数组,且 JSON 序列化后长度超过 200 字符
- **THEN** failure.actual SHALL 为序列化后前 200 字符加 `…(共 N 字符)` 后缀
#### Scenario: actual 值为标量
- **WHEN** 失败规则的实际值为 number、boolean、null 或 undefined
- **THEN** failure.actual SHALL 保留原始值,不做截断
### Requirement: 状态码范围匹配 ### Requirement: 状态码范围匹配
系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"``"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。 系统 SHALL 支持在 `expect.status` 数组中使用范围模式字符串(`"1xx"``"2xx"``"3xx"``"4xx"``"5xx"`),与精确数字混合使用。范围模式 SHALL 匹配对应百位段内的所有状态码。其他范围模式 SHALL 在启动期配置校验失败。
@@ -163,10 +175,10 @@
- **THEN** 系统 SHALL 在启动期配置校验失败 - **THEN** 系统 SHALL 在启动期配置校验失败
### Requirement: HTTP expect 规则启动期校验 ### Requirement: HTTP expect 规则启动期校验
系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象。 系统 SHALL 在启动期校验 HTTP expect 中已支持字段的类型、格式、未知字段和可编译表达式。HTTP expect、body rule、json/css/xpath rule 和 operator 对象中的未知字段 SHALL 导致启动期配置失败。每个 body rule 对象 MUST 恰好包含 contains、regex、json、css、xpath 中的一种规则类型。纯 operator 对象 MUST 至少包含一个已知 operatorbody 提取规则可以不配置 operator并以路径、元素或节点存在作为通过语义。`equals` operator SHALL 支持任意 JSON value包括数组和对象。系统 SHALL 在启动期对 regex body 规则和 match operator 的正则表达式进行 ReDoS 安全检测,含有嵌套量词等危险模式的正则 SHALL 导致启动期配置失败。
#### Scenario: body rule 使用 regex 字段 #### Scenario: body rule 使用 regex 字段
- **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译 - **WHEN** HTTP target 配置 `expect.body: [{regex: "ok|healthy"}]` 且 regex 可编译且无 ReDoS 风险
- **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体 - **THEN** 系统 SHALL 接受该配置,并在运行期按 regex body 规则匹配响应体
#### Scenario: body rule 不支持 match 字段 #### Scenario: body rule 不支持 match 字段
@@ -225,6 +237,18 @@
- **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]` - **WHEN** HTTP target 配置 `expect.body: [{xpath: {path: "/html/body", unknown: true}}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段 - **THEN** 系统 SHALL 在启动期配置校验失败,提示未知字段
#### Scenario: regex body 规则含嵌套量词启动失败
- **WHEN** HTTP target 配置 `expect.body: [{regex: "(a+)+$"}]`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: match operator 含嵌套量词启动失败
- **WHEN** HTTP target 的 expect operator 配置 `{match: "(\\d+)*x"}`
- **THEN** 系统 SHALL 在启动期配置校验失败,提示正则存在 ReDoS 风险
#### Scenario: 安全正则通过校验
- **WHEN** HTTP target 配置 `expect.body: [{regex: "\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"}]`
- **THEN** 系统 SHALL 接受该配置(无嵌套量词,无 ReDoS 风险)
### Requirement: HTTP body 运行期失败结构化 ### Requirement: HTTP body 运行期失败结构化
系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。 系统 SHALL 将 HTTP body 运行期失败记录为结构化 CheckFailure并保留与具体规则相关的 phase 和 path。响应内容不符合配置 SHALL 记录为 mismatch响应内容无法按配置解析或解码 SHALL 记录为 error。

View File

@@ -28,7 +28,9 @@ export function mismatchFailure(
export function truncateActual(value: unknown, maxLen = 200): unknown { export function truncateActual(value: unknown, maxLen = 200): unknown {
if (value === undefined || value === null) return value; if (value === undefined || value === null) return value;
const str = typeof value === "string" ? value : JSON.stringify(value);
const str = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : undefined;
if (str === undefined) return value;
if (str.length <= maxLen) return value; if (str.length <= maxLen) return value;
return str.slice(0, maxLen) + "..."; return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;
} }

View File

@@ -0,0 +1,151 @@
export function isUnsafeRegex(pattern: string): boolean {
const groups = findQuantifiedGroups(pattern);
return groups.some((group) => containsQuantifier(group) || containsOverlappingAlternation(group));
}
function containsOverlappingAlternation(pattern: string): boolean {
const branches = splitTopLevelAlternation(stripGroupPrefix(pattern));
if (branches.length < 2) return false;
for (let i = 0; i < branches.length; i++) {
const current = branches[i]!;
if (current === "") continue;
for (let j = i + 1; j < branches.length; j++) {
const next = branches[j]!;
if (next === "") continue;
if (current === next || current.startsWith(next) || next.startsWith(current)) return true;
}
}
return false;
}
function containsQuantifier(pattern: string): boolean {
const input = stripGroupPrefix(pattern);
let inCharClass = false;
for (let i = 0; i < input.length; i++) {
const char = input[i]!;
if (isEscaped(input, i)) continue;
if (char === "[") {
inCharClass = true;
continue;
}
if (char === "]") {
inCharClass = false;
continue;
}
if (inCharClass) continue;
if (char === "*" || char === "+" || char === "?") return true;
if (char === "{" && readQuantifierBody(input, i) !== null) return true;
}
return false;
}
function findQuantifiedGroups(pattern: string): string[] {
const groups: string[] = [];
const stack: number[] = [];
let inCharClass = false;
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]!;
if (isEscaped(pattern, i)) continue;
if (char === "[") {
inCharClass = true;
continue;
}
if (char === "]") {
inCharClass = false;
continue;
}
if (inCharClass) continue;
if (char === "(") {
stack.push(i);
continue;
}
if (char === ")") {
const start = stack.pop();
if (start === undefined) continue;
if (hasRepeatingQuantifierAt(pattern, i + 1)) {
groups.push(pattern.slice(start + 1, i));
}
}
}
return groups;
}
function hasRepeatingQuantifierAt(pattern: string, index: number): boolean {
const char = pattern[index];
if (char === "*" || char === "+") return true;
if (char !== "{") return false;
const body = readQuantifierBody(pattern, index);
if (body === null) return false;
const parts = body.split(",");
if (parts.length === 1) return Number(parts[0]) > 1;
if (parts[1] === "") return true;
return Number(parts[1]) > 1;
}
function isEscaped(pattern: string, index: number): boolean {
let slashCount = 0;
for (let i = index - 1; i >= 0 && pattern[i] === "\\"; i--) {
slashCount++;
}
return slashCount % 2 === 1;
}
function readQuantifierBody(pattern: string, index: number): null | string {
const end = pattern.indexOf("}", index + 1);
if (end === -1) return null;
const body = pattern.slice(index + 1, end);
return /^\d+(?:,\d*)?$/.test(body) ? body : null;
}
function splitTopLevelAlternation(pattern: string): string[] {
const branches: string[] = [];
let start = 0;
let depth = 0;
let inCharClass = false;
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i]!;
if (isEscaped(pattern, i)) continue;
if (char === "[") {
inCharClass = true;
continue;
}
if (char === "]") {
inCharClass = false;
continue;
}
if (inCharClass) continue;
if (char === "(") {
depth++;
continue;
}
if (char === ")") {
depth = Math.max(0, depth - 1);
continue;
}
if (char === "|" && depth === 0) {
branches.push(pattern.slice(start, i));
start = i + 1;
}
}
branches.push(pattern.slice(start));
return branches;
}
function stripGroupPrefix(pattern: string): string {
if (pattern.startsWith("?:") || pattern.startsWith("?=") || pattern.startsWith("?!")) return pattern.slice(2);
if (pattern.startsWith("?<=") || pattern.startsWith("?<!")) return pattern.slice(3);
const namedCapture = /^\?<[^>]+>/.exec(pattern);
return namedCapture ? pattern.slice(namedCapture[0].length) : pattern;
}

View File

@@ -3,6 +3,7 @@ import type { JsonValue } from "../types";
import { OperatorKeys } from "../schema/fragments"; import { OperatorKeys } from "../schema/fragments";
import { issue, joinPath } from "../schema/issues"; import { issue, joinPath } from "../schema/issues";
import { isUnsafeRegex } from "./redos";
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys); const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
@@ -70,10 +71,10 @@ export function validateOperatorValue(
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try { try {
new RegExp(value); new RegExp(value);
return [];
} catch { } catch {
return [issue("invalid-regex", path, "正则不合法", targetName)]; return [issue("invalid-regex", path, "正则不合法", targetName)];
} }
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
default: default:
return [issue("unknown-operator", path, "是未知 operator", targetName)]; return [issue("unknown-operator", path, "是未知 operator", targetName)];
} }

View File

@@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
import { errorFailure, mismatchFailure } from "../../expect/failure"; import { errorFailure, mismatchFailure } from "../../expect/failure";
import { applyOperator, evaluateJsonPath } from "../../expect/operator"; import { applyOperator, evaluateJsonPath } from "../../expect/operator";
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult { export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
if (!rules || rules.length === 0) return { failure: null, matched: true }; if (!rules || rules.length === 0) return { failure: null, matched: true };
let parsedJson: ParsedJsonResult | undefined;
for (let i = 0; i < rules.length; i++) { for (let i = 0; i < rules.length; i++) {
const result = checkSingleBodyRule(body, rules[i]!, i); const rule = rules[i]!;
if ("json" in rule && parsedJson === undefined) {
parsedJson = parseJsonBody(body);
}
const result = checkSingleBodyRule(body, rule, i, parsedJson);
if (!result.matched) return result; if (!result.matched) return result;
} }
@@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
} }
const el = $(selector); const el = $(selector);
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (attr !== undefined) {
if (el.attr(attr) === undefined) {
return {
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (el.length === 0) {
return {
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (operators.exists === true) {
if (el.length === 0) {
return {
failure: mismatchFailure("body", fullPath, true, false, `selector ${selector} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
if (operators.exists === false) { if (operators.exists === false) {
if (el.length > 0) { if (el.length > 0) {
return { return {
@@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
} }
if (el.length === 0) { if (el.length === 0) {
const expected = operators.exists === true ? true : "element found";
const actual = operators.exists === true ? false : "no match";
return { return {
failure: mismatchFailure("body", fullPath, "element found", "no match", `selector ${selector} not found`), failure: mismatchFailure("body", fullPath, expected, actual, `selector ${selector} not found`),
matched: false, matched: false,
}; };
} }
if (operators.exists === true) return { failure: null, matched: true };
const actual = attr ? el.attr(attr) : el.text(); const actual = attr ? el.attr(attr) : el.text();
const opKeys = Object.keys(operators);
if (opKeys.length === 0) {
if (actual === undefined) {
return {
failure: mismatchFailure("body", fullPath, "attr present", undefined, `attribute ${attr} not found`),
matched: false,
};
}
return { failure: null, matched: true };
}
const matched = applyOperator(actual ?? "", operators); const matched = applyOperator(actual ?? "", operators);
if (!matched) { if (!matched) {
return { return {
@@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
return { failure: null, matched: true }; return { failure: null, matched: true };
} }
function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectResult { function checkJsonRule(body: string, rule: JsonRule, rulePath: string, parsedJson?: ParsedJsonResult): ExpectResult {
const { path, ...operators } = rule; const { path, ...operators } = rule;
const fullPath = `${rulePath}.json(${path})`; const fullPath = `${rulePath}.json(${path})`;
let json: unknown; const jsonResult = parsedJson ?? parseJsonBody(body);
try { if (!jsonResult.ok) {
json = JSON.parse(body);
} catch {
return { return {
failure: errorFailure("body", fullPath, "body is not valid JSON"), failure: errorFailure("body", fullPath, jsonResult.error),
matched: false, matched: false,
}; };
} }
const actual = evaluateJsonPath(json, path); const actual = evaluateJsonPath(jsonResult.value, path);
const opKeys = Object.keys(operators); const opKeys = Object.keys(operators);
if (opKeys.length === 0) { if (opKeys.length === 0) {
@@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe
return { failure: null, matched: true }; return { failure: null, matched: true };
} }
function checkSingleBodyRule(body: string, rule: BodyRule, index: number): ExpectResult { function checkSingleBodyRule(body: string, rule: BodyRule, index: number, parsedJson?: ParsedJsonResult): ExpectResult {
const rulePath = `body[${index}]`; const rulePath = `body[${index}]`;
if ("contains" in rule) { if ("contains" in rule) {
@@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec
} }
if ("json" in rule) { if ("json" in rule) {
return checkJsonRule(body, rule.json, rulePath); return checkJsonRule(body, rule.json, rulePath, parsedJson);
} }
if ("css" in rule) { if ("css" in rule) {
@@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
} }
return { failure: null, matched: true }; return { failure: null, matched: true };
} }
function parseJsonBody(body: string): ParsedJsonResult {
try {
return { ok: true, value: JSON.parse(body) as unknown };
} catch {
return { error: "body is not valid JSON", ok: false };
}
}

View File

@@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
const hasBodyRules = !!(expect?.body && expect.body.length > 0); const hasBodyRules = !!(expect?.body && expect.body.length > 0);
if (hasBodyRules && expect?.maxDurationMs !== undefined) { const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
const elapsed = performance.now() - start; if (earlyTimeout) {
if (elapsed > expect.maxDurationMs) { return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
const durationMs = Math.round(elapsed);
return makeResult(
t,
timestamp,
elapsed,
mismatchFailure(
"duration",
"duration",
`<=${expect.maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
),
statusCode,
);
}
} }
if (hasBodyRules) { if (hasBodyRules) {
@@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
return newInit; return newInit;
} }
function checkEarlyTimeout(
start: number,
maxDurationMs: number | undefined,
): null | { elapsed: number; failure: CheckResult["failure"] } {
if (maxDurationMs === undefined) return null;
const elapsed = performance.now() - start;
if (elapsed <= maxDurationMs) return null;
const durationMs = Math.round(elapsed);
return {
elapsed,
failure: mismatchFailure(
"duration",
"duration",
`<=${maxDurationMs}ms`,
durationMs,
`duration ${durationMs}ms > ${maxDurationMs}ms`,
),
};
}
function decodeBody( function decodeBody(
data: Uint8Array, data: Uint8Array,
headers: Headers, headers: Headers,

View File

@@ -4,6 +4,7 @@ import * as xpath from "xpath";
import type { ConfigValidationIssue } from "../../schema/issues"; import type { ConfigValidationIssue } from "../../schema/issues";
import type { CheckerValidationInput } from "../types"; import type { CheckerValidationInput } from "../types";
import { isUnsafeRegex } from "../../expect/redos";
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator"; import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments"; import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
import { issue, joinPath } from "../../schema/issues"; import { issue, joinPath } from "../../schema/issues";
@@ -188,10 +189,10 @@ function validateRegexRule(rule: unknown, path: string, targetName?: string): Co
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)]; if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
try { try {
new RegExp(rule); new RegExp(rule);
return [];
} catch { } catch {
return [issue("invalid-regex", path, "正则不合法", targetName)]; return [issue("invalid-regex", path, "正则不合法", targetName)];
} }
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
} }
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] { function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {

View File

@@ -67,13 +67,17 @@ describe("HttpChecker", () => {
beforeAll(() => { beforeAll(() => {
server = Bun.serve({ server = Bun.serve({
fetch(req) { async fetch(req) {
const url = new URL(req.url); const url = new URL(req.url);
switch (url.pathname) { switch (url.pathname) {
case "/echo": case "/echo":
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), { return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });
case "/echo-actual":
return new Response(JSON.stringify({ body: await req.text(), method: req.method }), {
headers: { "content-type": "application/json" },
});
case "/gbk": { case "/gbk": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]); const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } }); return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
@@ -88,6 +92,10 @@ describe("HttpChecker", () => {
}); });
case "/large": case "/large":
return new Response("x".repeat(2000)); return new Response("x".repeat(2000));
case "/mixed":
return new Response(JSON.stringify({ html: "<span>OK</span>", status: "ok" }), {
headers: { "content-type": "application/json" },
});
case "/notfound": case "/notfound":
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
case "/ok": case "/ok":
@@ -96,6 +104,12 @@ describe("HttpChecker", () => {
}); });
case "/redirect": case "/redirect":
return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 }); return new Response(null, { headers: { location: `${baseUrl}/ok` }, status: 301 });
case "/redirect-303":
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 303 });
case "/redirect-307":
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 307 });
case "/redirect-308":
return new Response(null, { headers: { location: `${baseUrl}/echo-actual` }, status: 308 });
case "/redirect-chain-1": case "/redirect-chain-1":
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 }); return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
case "/redirect-chain-2": case "/redirect-chain-2":
@@ -109,6 +123,8 @@ describe("HttpChecker", () => {
} }
case "/redirect-post": case "/redirect-post":
return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 }); return new Response(null, { headers: { location: `${baseUrl}/echo` }, status: 301 });
case "/redirect-relative":
return new Response(null, { headers: { location: "/ok" }, status: 302 });
case "/slow-body": case "/slow-body":
return new Response("x".repeat(2000)); return new Response("x".repeat(2000));
case "/unknown-charset": { case "/unknown-charset": {
@@ -529,6 +545,74 @@ describe("HttpChecker", () => {
expect(result.matched).toBe(true); expect(result.matched).toBe(true);
}); });
test("303 重定向将 method 转为 GET 且清空 body", async () => {
const result = await checker.execute(
makeTarget({
body: "payload",
expect: {
body: [{ json: { equals: "GET", path: "$.method" } }, { json: { equals: "", path: "$.body" } }],
status: [200],
},
headers: { "content-type": "text/plain" },
maxRedirects: 1,
method: "POST",
url: `${baseUrl}/redirect-303`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("307/308 重定向保持原始 method 和 body", async () => {
for (const statusCode of [307, 308]) {
const result = await checker.execute(
makeTarget({
body: `payload-${statusCode}`,
expect: {
body: [
{ json: { equals: "POST", path: "$.method" } },
{ json: { equals: `payload-${statusCode}`, path: "$.body" } },
],
status: [200],
},
headers: { "content-type": "text/plain" },
maxRedirects: 1,
method: "POST",
url: `${baseUrl}/redirect-${statusCode}`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
}
});
test("相对路径 Location header 重定向", async () => {
const result = await checker.execute(
makeTarget({ maxRedirects: 1, url: `${baseUrl}/redirect-relative` }),
makeCtx(),
);
expect(result.matched).toBe(true);
expect(result.statusDetail).toBe("HTTP 200");
});
test("混合 body rules 集成检查", async () => {
const result = await checker.execute(
makeTarget({
expect: {
body: [
{ contains: '"status":"ok"' },
{ json: { equals: "ok", path: "$.status" } },
{ css: { equals: "OK", selector: "span" } },
],
status: [200],
},
url: `${baseUrl}/mixed`,
}),
makeCtx(),
);
expect(result.matched).toBe(true);
});
test("跨 origin 重定向剥离敏感 headers", async () => { test("跨 origin 重定向剥离敏感 headers", async () => {
const targetServer = Bun.serve({ const targetServer = Bun.serve({
fetch(req) { fetch(req) {
@@ -616,6 +700,16 @@ describe("HttpChecker", () => {
expect(errors).toContain("regex 正则不合法"); expect(errors).toContain("regex 正则不合法");
}); });
test("ReDoS regex body rule 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ regex: "(a+)+$" }] },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("正则存在 ReDoS 风险");
});
test("非法 JSONPath 启动校验失败", () => { test("非法 JSONPath 启动校验失败", () => {
const errors = validateHttpTarget({ const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] }, expect: { body: [{ json: { equals: "ok", path: "status" } }] },
@@ -636,6 +730,16 @@ describe("HttpChecker", () => {
expect(errors).toContain("match 正则不合法"); expect(errors).toContain("match 正则不合法");
}); });
test("ReDoS operator match 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { headers: { "x-test": { match: "(\\d+)*x" } } },
http: { url: "https://example.com" },
name: "test",
type: "http",
});
expect(errors).toContain("正则存在 ReDoS 风险");
});
test("非法 operator gte 类型启动失败", () => { test("非法 operator gte 类型启动失败", () => {
const errors = validateHttpTarget({ const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] }, expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },

View File

@@ -130,6 +130,28 @@ describe("checkBodyExpect (BodyRule[])", () => {
expect(r.failure).toBeNull(); expect(r.failure).toBeNull();
}); });
test("多条 json 规则共享解析结果且全部通过", () => {
const body = JSON.stringify({ count: 5, status: "healthy" });
const originalParse = JSON.parse;
let parseCount = 0;
JSON.parse = ((text, reviver) => {
parseCount++;
return originalParse(text, reviver) as unknown;
}) as typeof JSON.parse;
try {
const r = checkBodyExpect(body, [
{ json: { equals: "healthy", path: "$.status" } },
{ json: { gte: 1, path: "$.count" } },
]);
expect(r.matched).toBe(true);
expect(r.failure).toBeNull();
expect(parseCount).toBe(1);
} finally {
JSON.parse = originalParse;
}
});
test("第二条规则失败返回正确索引", () => { test("第二条规则失败返回正确索引", () => {
const body = JSON.stringify({ status: "ok" }); const body = JSON.stringify({ status: "ok" });
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]); const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]);

View File

@@ -12,18 +12,18 @@ describe("truncateActual", () => {
expect(truncateActual(str)).toBe(str); expect(truncateActual(str)).toBe(str);
}); });
test("超过限制长度截断并加省略号", () => { test("超过限制长度截断并加省略号与字符计数", () => {
const str = "a".repeat(300); const str = "a".repeat(300);
const result = truncateActual(str) as string; const result = truncateActual(str) as string;
expect(result.length).toBe(203); expect(result).toBe(`${"a".repeat(200)}…(共 300 字符)`);
expect(result.endsWith("...")).toBe(true); expect(result.includes("...")).toBe(false);
expect(result.startsWith("a".repeat(200))).toBe(true); expect(result.startsWith("a".repeat(200))).toBe(true);
}); });
test("自定义最大长度", () => { test("自定义最大长度", () => {
const str = "abcdefghij"; const str = "abcdefghij";
const result = truncateActual(str, 5) as string; const result = truncateActual(str, 5) as string;
expect(result).toBe("abcde..."); expect(result).toBe("abcde…(共 10 字符)");
}); });
test("null 不截断", () => { test("null 不截断", () => {
@@ -34,9 +34,16 @@ describe("truncateActual", () => {
expect(truncateActual(undefined)).toBe(undefined); expect(truncateActual(undefined)).toBe(undefined);
}); });
test("数字转换为字符串后判断", () => { test("标量不截断", () => {
expect(truncateActual(42)).toBe(42); expect(truncateActual(42)).toBe(42);
expect(truncateActual(123456789, 3) as string).toBe("123..."); expect(truncateActual(123456789, 3)).toBe(123456789);
expect(truncateActual(true, 3)).toBe(true);
});
test("对象序列化后超限时截断", () => {
const result = truncateActual({ value: "x".repeat(20) }, 10) as string;
expect(result.startsWith('{"value":"')).toBe(true);
expect(result.endsWith("…(共 32 字符)")).toBe(true);
}); });
}); });
@@ -56,8 +63,7 @@ describe("mismatchFailure", () => {
test("自动截断过长的 actual", () => { test("自动截断过长的 actual", () => {
const longStr = "x".repeat(300); const longStr = "x".repeat(300);
const f = mismatchFailure("body", "body[0]", "short", longStr, "too long"); const f = mismatchFailure("body", "body[0]", "short", longStr, "too long");
expect((f.actual as string).endsWith("...")).toBe(true); expect(f.actual).toBe(`${"x".repeat(200)}…(共 300 字符)`);
expect((f.actual as string).length).toBe(203);
}); });
}); });

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import { isUnsafeRegex } from "../../../../../src/server/checker/expect/redos";
describe("isUnsafeRegex", () => {
test("识别嵌套量词", () => {
expect(isUnsafeRegex("(a+)+$")).toBe(true);
expect(isUnsafeRegex("(a*)*")).toBe(true);
expect(isUnsafeRegex("(a?)+")).toBe(true);
expect(isUnsafeRegex("(\\d+)*x")).toBe(true);
expect(isUnsafeRegex("(?:a+)+")).toBe(true);
});
test("识别重叠交替分支", () => {
expect(isUnsafeRegex("(a|a)+")).toBe(true);
expect(isUnsafeRegex("(a|aa)*")).toBe(true);
});
test("安全正则不误判", () => {
expect(isUnsafeRegex("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")).toBe(false);
expect(isUnsafeRegex("^(ok|healthy)$")).toBe(false);
expect(isUnsafeRegex("^[a-z0-9_-]+$")).toBe(false);
expect(isUnsafeRegex("([a+])+")).toBe(false);
});
});