fix(frontend): 升级 Next→15.5.20 + React 19.2.7 修 RCE/补丁 + 版本下界守卫(CR-C1)

- next/eslint-config-next 15.1.3→15.5.20(backport 安全维护线),react/react-dom 19.0.0→19.2.7
- @types/react(-dom) →^19.2.0;pnpm audit --prod 无 next/react/react-dom 高危/严重告警
- 新增 lib/security/dependencyFloors 守卫 + 回归测试锁定安全下界
- 连带修 useDraftStream.test noUncheckedIndexedAccess(重锁失效增量缓存后暴露,对齐 useReviewStream 既有 ! 约定)
This commit is contained in:
Yaojia Wang
2026-07-08 11:04:55 +02:00
parent baa41d370b
commit f4c2c4e8ec
5 changed files with 390 additions and 280 deletions

View File

@@ -0,0 +1,70 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { isAtLeast, SECURITY_FLOORS } from "./dependencyFloors";
// 读取本包 package.json 声明版本(相对本测试文件 ../../ → apps/web/package.json
function declaredVersions(): Record<string, string> {
const raw = readFileSync(
new URL("../../package.json", import.meta.url),
"utf-8",
);
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
return { ...pkg.devDependencies, ...pkg.dependencies };
}
describe("SECURITY_FLOORS 锁定安全下界CR-C1", () => {
const declared = declaredVersions();
it.each(Object.keys(SECURITY_FLOORS))(
"%s 声明版本不得低于安全下界",
(name) => {
const version = declared[name];
if (version === undefined) {
throw new Error(`${name} 未在 package.json 声明`);
}
const floor = SECURITY_FLOORS[name as keyof typeof SECURITY_FLOORS];
expect(
isAtLeast(version, floor),
`${name}@${version} 低于安全下界 ${floor}`,
).toBe(true);
},
);
});
describe("isAtLeast 语义版本比较", () => {
it("相等即满足", () => {
expect(isAtLeast("15.5.20", "15.5.20")).toBe(true);
});
it("patch 更高满足、更低不满足", () => {
expect(isAtLeast("19.2.7", "19.2.0")).toBe(true);
expect(isAtLeast("19.2.0", "19.2.7")).toBe(false);
});
it("minor 边界", () => {
expect(isAtLeast("15.5.0", "15.1.3")).toBe(true);
expect(isAtLeast("15.1.3", "15.5.20")).toBe(false);
});
it("major 边界", () => {
expect(isAtLeast("19.0.0", "18.9.9")).toBe(true);
expect(isAtLeast("18.9.9", "19.0.0")).toBe(false);
});
it("去除前导 ^ / ~ 范围符", () => {
expect(isAtLeast("^19.2.7", "19.2.7")).toBe(true);
expect(isAtLeast("~19.2.7", "19.2.7")).toBe(true);
expect(isAtLeast("19.2.0", "^19.2.7")).toBe(false);
});
it("缺省段按 0 计", () => {
expect(isAtLeast("15", "15.0.0")).toBe(true);
expect(isAtLeast("15.5", "15.5.0")).toBe(true);
expect(isAtLeast("15", "15.5.0")).toBe(false);
});
});

View File

@@ -0,0 +1,30 @@
// 依赖安全下界守卫:锁定 next/react/react-dom 不得低于修补 RCE / 安全补丁的最低版本。
// dependencyFloors.test.ts 读取 package.json 声明版本比对回归防止误降级回易受攻击版本CR-C1
export const SECURITY_FLOORS = {
next: "15.5.20",
react: "19.2.7",
"react-dom": "19.2.7",
} as const;
// 语义版本比较version >= floor仅比对 major.minor.patch忽略预发布 / 构建元数据)。
// 去除前导 ^ / ~ 范围符;缺省段按 0 计;非数字段按 0 计。
export function isAtLeast(version: string, floor: string): boolean {
const a = parseSemver(version);
const b = parseSemver(floor);
for (let i = 0; i < 3; i += 1) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (av > bv) return true;
if (av < bv) return false;
}
return true;
}
function parseSemver(raw: string): number[] {
const cleaned = raw.trim().replace(/^[\^~]/, "");
return cleaned.split(".").map((part) => {
const n = Number.parseInt(part, 10);
return Number.isNaN(n) ? 0 : n;
});
}

View File

@@ -85,7 +85,7 @@ describe("useDraftStream", () => {
await result.current.start("p1", 2, " 写得热血一点 "); await result.current.start("p1", 2, " 写得热血一点 ");
}); });
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0]!;
expect(init.method).toBe("POST"); expect(init.method).toBe("POST");
expect(init.headers["Content-Type"]).toBe("application/json"); expect(init.headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" }); expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" });
@@ -99,7 +99,7 @@ describe("useDraftStream", () => {
await result.current.start("p1", 2, " "); await result.current.start("p1", 2, " ");
}); });
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0]!;
expect(init.body).toBeUndefined(); expect(init.body).toBeUndefined();
expect(init.headers["Content-Type"]).toBeUndefined(); expect(init.headers["Content-Type"]).toBeUndefined();
}); });

View File

@@ -16,21 +16,21 @@
"@dagrejs/dagre": "^3.0.0", "@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next": "15.1.3", "next": "15.5.20",
"openapi-fetch": "^0.13.0", "openapi-fetch": "^0.13.0",
"react": "19.0.0", "react": "19.2.7",
"react-dom": "19.0.0" "react-dom": "19.2.7"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "19.0.0", "@types/react": "^19.2.0",
"@types/react-dom": "19.0.0", "@types/react-dom": "^19.2.0",
"@vitest/coverage-v8": "^2.1.9", "@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "15.1.3", "eslint-config-next": "15.5.20",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"openapi-typescript": "^7.5.0", "openapi-typescript": "^7.5.0",
"playwright": "^1.61.1", "playwright": "^1.61.1",

554
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff