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

@@ -67,13 +67,17 @@ describe("HttpChecker", () => {
beforeAll(() => {
server = Bun.serve({
fetch(req) {
async fetch(req) {
const url = new URL(req.url);
switch (url.pathname) {
case "/echo":
return new Response(JSON.stringify({ body: req.body ? "present" : "empty", method: req.method }), {
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": {
const gbkBytes = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]);
return new Response(gbkBytes, { headers: { "content-type": "text/plain; charset=gbk" } });
@@ -88,6 +92,10 @@ describe("HttpChecker", () => {
});
case "/large":
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":
return new Response("not found", { status: 404 });
case "/ok":
@@ -96,6 +104,12 @@ describe("HttpChecker", () => {
});
case "/redirect":
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":
return new Response(null, { headers: { location: `${baseUrl}/redirect-chain-2` }, status: 302 });
case "/redirect-chain-2":
@@ -109,6 +123,8 @@ describe("HttpChecker", () => {
}
case "/redirect-post":
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":
return new Response("x".repeat(2000));
case "/unknown-charset": {
@@ -529,6 +545,74 @@ describe("HttpChecker", () => {
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 () => {
const targetServer = Bun.serve({
fetch(req) {
@@ -616,6 +700,16 @@ describe("HttpChecker", () => {
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 启动校验失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { equals: "ok", path: "status" } }] },
@@ -636,6 +730,16 @@ describe("HttpChecker", () => {
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 类型启动失败", () => {
const errors = validateHttpTarget({
expect: { body: [{ json: { gte: "abc", path: "$.count" } }] },

View File

@@ -130,6 +130,28 @@ describe("checkBodyExpect (BodyRule[])", () => {
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("第二条规则失败返回正确索引", () => {
const body = JSON.stringify({ status: "ok" });
const r = checkBodyExpect(body, [{ contains: "ok" }, { json: { equals: "error", path: "$.status" } }]);

View File

@@ -12,18 +12,18 @@ describe("truncateActual", () => {
expect(truncateActual(str)).toBe(str);
});
test("超过限制长度截断并加省略号", () => {
test("超过限制长度截断并加省略号与字符计数", () => {
const str = "a".repeat(300);
const result = truncateActual(str) as string;
expect(result.length).toBe(203);
expect(result.endsWith("...")).toBe(true);
expect(result).toBe(`${"a".repeat(200)}…(共 300 字符)`);
expect(result.includes("...")).toBe(false);
expect(result.startsWith("a".repeat(200))).toBe(true);
});
test("自定义最大长度", () => {
const str = "abcdefghij";
const result = truncateActual(str, 5) as string;
expect(result).toBe("abcde...");
expect(result).toBe("abcde…(共 10 字符)");
});
test("null 不截断", () => {
@@ -34,9 +34,16 @@ describe("truncateActual", () => {
expect(truncateActual(undefined)).toBe(undefined);
});
test("数字转换为字符串后判断", () => {
test("标量不截断", () => {
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", () => {
const longStr = "x".repeat(300);
const f = mismatchFailure("body", "body[0]", "short", longStr, "too long");
expect((f.actual as string).endsWith("...")).toBe(true);
expect((f.actual as string).length).toBe(203);
expect(f.actual).toBe(`${"x".repeat(200)}…(共 300 字符)`);
});
});

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);
});
});