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:
@@ -28,7 +28,9 @@ export function mismatchFailure(
|
||||
|
||||
export function truncateActual(value: unknown, maxLen = 200): unknown {
|
||||
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;
|
||||
return str.slice(0, maxLen) + "...";
|
||||
return `${str.slice(0, maxLen)}…(共 ${str.length} 字符)`;
|
||||
}
|
||||
|
||||
151
src/server/checker/expect/redos.ts
Normal file
151
src/server/checker/expect/redos.ts
Normal 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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { JsonValue } from "../types";
|
||||
|
||||
import { OperatorKeys } from "../schema/fragments";
|
||||
import { issue, joinPath } from "../schema/issues";
|
||||
import { isUnsafeRegex } from "./redos";
|
||||
|
||||
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)];
|
||||
try {
|
||||
new RegExp(value);
|
||||
return [];
|
||||
} catch {
|
||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||
}
|
||||
return isUnsafeRegex(value) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||
default:
|
||||
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
||||
}
|
||||
|
||||
@@ -8,11 +8,20 @@ import type { BodyRule, CssRule, JsonRule, XpathRule } from "./types";
|
||||
import { errorFailure, mismatchFailure } from "../../expect/failure";
|
||||
import { applyOperator, evaluateJsonPath } from "../../expect/operator";
|
||||
|
||||
type ParsedJsonResult = { error: string; ok: false } | { ok: true; value: unknown };
|
||||
|
||||
export function checkBodyExpect(body: string, rules?: BodyRule[]): ExpectResult {
|
||||
if (!rules || rules.length === 0) return { failure: null, matched: true };
|
||||
|
||||
let parsedJson: ParsedJsonResult | undefined;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,36 +43,7 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
||||
}
|
||||
|
||||
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 (el.length > 0) {
|
||||
return {
|
||||
@@ -75,13 +55,28 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
||||
}
|
||||
|
||||
if (el.length === 0) {
|
||||
const expected = operators.exists === true ? true : "element found";
|
||||
const actual = operators.exists === true ? false : "no match";
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
if (operators.exists === true) return { failure: null, matched: true };
|
||||
|
||||
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);
|
||||
if (!matched) {
|
||||
return {
|
||||
@@ -92,21 +87,19 @@ function checkCssRule(body: string, rule: CssRule, rulePath: string): ExpectResu
|
||||
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 fullPath = `${rulePath}.json(${path})`;
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
const jsonResult = parsedJson ?? parseJsonBody(body);
|
||||
if (!jsonResult.ok) {
|
||||
return {
|
||||
failure: errorFailure("body", fullPath, "body is not valid JSON"),
|
||||
failure: errorFailure("body", fullPath, jsonResult.error),
|
||||
matched: false,
|
||||
};
|
||||
}
|
||||
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
const actual = evaluateJsonPath(jsonResult.value, path);
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
@@ -129,7 +122,7 @@ function checkJsonRule(body: string, rule: JsonRule, rulePath: string): ExpectRe
|
||||
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}]`;
|
||||
|
||||
if ("contains" in rule) {
|
||||
@@ -155,7 +148,7 @@ function checkSingleBodyRule(body: string, rule: BodyRule, index: number): Expec
|
||||
}
|
||||
|
||||
if ("json" in rule) {
|
||||
return checkJsonRule(body, rule.json, rulePath);
|
||||
return checkJsonRule(body, rule.json, rulePath, parsedJson);
|
||||
}
|
||||
|
||||
if ("css" in rule) {
|
||||
@@ -208,3 +201,11 @@ function checkXpathRule(body: string, rule: XpathRule, rulePath: string): Expect
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,24 +53,9 @@ export class HttpChecker implements CheckerDefinition<ResolvedHttpTarget> {
|
||||
|
||||
const hasBodyRules = !!(expect?.body && expect.body.length > 0);
|
||||
|
||||
if (hasBodyRules && expect?.maxDurationMs !== undefined) {
|
||||
const elapsed = performance.now() - start;
|
||||
if (elapsed > expect.maxDurationMs) {
|
||||
const durationMs = Math.round(elapsed);
|
||||
return makeResult(
|
||||
t,
|
||||
timestamp,
|
||||
elapsed,
|
||||
mismatchFailure(
|
||||
"duration",
|
||||
"duration",
|
||||
`<=${expect.maxDurationMs}ms`,
|
||||
durationMs,
|
||||
`duration ${durationMs}ms > ${expect.maxDurationMs}ms`,
|
||||
),
|
||||
statusCode,
|
||||
);
|
||||
}
|
||||
const earlyTimeout = hasBodyRules ? checkEarlyTimeout(start, expect?.maxDurationMs) : null;
|
||||
if (earlyTimeout) {
|
||||
return makeResult(t, timestamp, earlyTimeout.elapsed, earlyTimeout.failure, statusCode);
|
||||
}
|
||||
|
||||
if (hasBodyRules) {
|
||||
@@ -203,6 +188,28 @@ function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: strin
|
||||
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(
|
||||
data: Uint8Array,
|
||||
headers: Headers,
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as xpath from "xpath";
|
||||
import type { ConfigValidationIssue } from "../../schema/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { isUnsafeRegex } from "../../expect/redos";
|
||||
import { isPlainRecord, validateOperatorObject } from "../../expect/validate-operator";
|
||||
import { BodyRuleTypeKeys, OperatorKeys } from "../../schema/fragments";
|
||||
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)];
|
||||
try {
|
||||
new RegExp(rule);
|
||||
return [];
|
||||
} catch {
|
||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||
}
|
||||
return isUnsafeRegex(rule) ? [issue("unsafe-regex", path, "正则存在 ReDoS 风险", targetName)] : [];
|
||||
}
|
||||
|
||||
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
|
||||
Reference in New Issue
Block a user