Commit a1702e1c by DaiJiezhang

feat: 契约比对工具,并补齐 NestJS 缺失的安全响应头

scripts/contract/compare.mjs
对目标服务重放全部端点,与 Java 录制的快照逐字段比对,差异精确到字段路径。
此前每验证一个模块都要临时写一次比对脚本,现在一条命令即可,
且比人工核对更细——首次运行就发现了下面这个人工绝对会漏的问题。

两条比对规则,区分对待:
- 对象字段顺序:排序后比较。消费方按名字取值,顺序无语义;且 Java 的 Map.of
  迭代顺序取决于 JVM 启动时的随机哈希种子,实测重启前后会变,本就不是稳定契约。
- 数组元素顺序:严格保持。顺序即业务语义(列表排序、分页),
  若一并排序,"本该倒序却写成正序"这类真缺陷会被掩盖,比对就从保障变成掩护。

响应头差异用显式白名单放行,理由写在代码里:
- content-type:Java 自身也不统一,部分接口带 charset 部分不带,前端无感
- set-cookie:Java 在已认证 GET 后下发删除 CSRF cookie 的指令,属框架副作用,不予复现

修复:NestJS 缺少 Spring Security 默认的安全响应头
X-Content-Type-Options、X-Frame-Options、X-XSS-Protection、
Cache-Control/Pragma/Expires 六个头此前完全缺失。
这些头由 Spring Security 开箱提供,换框架后不会自动出现,
缺了功能完全正常、任何人工测试都发现不了,但防点击劫持、防 MIME 嗅探、
防敏感数据被缓存三道防线同时消失。补齐后认证端点比对 3/3 一致。

当前全量比对:9 项一致、22 项待实现(尚未迁移的五个业务模块,均返回 404)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 9742b99a
......@@ -2,12 +2,13 @@
* 文件用途(白话):应用根模块,注册全局的响应包装与数据库连接。
* 关联文件:main.ts、common/*、health/health.controller.ts。
*/
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { PrismaModule } from './common/prisma.module';
import { ApiResponseInterceptor } from './common/api-response.interceptor';
import { HealthController } from './health/health.controller';
import { AuthModule } from './auth/auth.module';
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
import { SystemUserModule } from './system-user/system-user.module';
@Module({
......@@ -15,4 +16,9 @@ import { SystemUserModule } from './system-user/system-user.module';
controllers: [HealthController],
providers: [{ provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor }],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// 安全响应头对所有路由生效,与 Spring Security 的默认行为一致
consumer.apply(SecurityHeadersMiddleware).forRoutes('*');
}
}
/**
* 文件用途(白话):补上 Spring Security 默认会加的那几个安全响应头。
* 关联文件:backend/.../config/SecurityConfig.java、main.ts。
* 关联逻辑(数据流):任意响应 -> 本中间件写入固定响应头 -> 浏览器据此收紧行为。
*
* 这几个头是 Spring Security 开箱带的,换到 NestJS 后不会自动出现,
* 漏掉就是实打实的安全退化,而且功能完全正常、任何人工测试都发现不了。
* 取值逐一对齐 Java 侧实测结果:
*
* X-Content-Type-Options: nosniff 禁止浏览器猜测响应类型,避免把 JSON 当脚本执行
* X-Frame-Options: DENY 禁止页面被嵌入 iframe,防点击劫持
* X-XSS-Protection: 0 显式关闭浏览器老式 XSS 过滤器(现代做法,该过滤器本身会引入漏洞)
* Cache-Control / Pragma / Expires 禁止缓存,避免带权限的数据留在中间层或磁盘上
*/
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class SecurityHeadersMiddleware implements NestMiddleware {
use(_req: Request, res: Response, next: NextFunction) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-XSS-Protection', '0');
res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('X-Frame-Options', 'DENY');
next();
}
}
......@@ -17,9 +17,39 @@
| 项 | 决定 |
|---|---|
| 修改时机 | 迁移完成后,在 NestJS 上改(重构期行为冻结) |
| `number_type` 取值 | 直接存 `SELF`,与手动新建完全一致 |
| `number_type` 取值 | **保持 `EXTERNAL` 不变**(2026-08-19 修正,原定改为 SELF) |
| 删除企微账号时 | 号码同样保留,不连带删除 |
**业务理由(2026-08-19 补充)**
台账里的号码都跑过实名注册与开卡流程,是有获取成本的真实资产,
不该因为解除某个业务绑定就从台账消失。
而且现状是**大多数号码只绑定了企微**,只有少部分同时绑定微信、抖音、域名或商户。
这意味着当前的软删逻辑影响面很大——每次改绑企微,被删掉的多半正是这类真实号码,
只有少数因为被其他资产引用才侥幸保留。
**迁移期间的注意事项**:规则尚未修改前,在企微模块做改绑或删除操作
仍会软删对应号码。若需要保留,可先记下号码,改完规则后再从台账恢复
(软删只是把 delete_time 置为时间戳,数据仍在,可改回 0 恢复)。
**为什么保留 EXTERNAL 标记(2026-08-19 修正)**
号码来源的追溯链已经存在,且前端已完整使用:
```java
phone.setNumberType("EXTERNAL") // 区别于手动建档的 SELF
phone.setSourceAssetType("WECOM") // 从哪类业务录入
phone.setSourceAssetId(企微账号ID) // 具体哪一条
```
前端手机号列表的「号码类型」列据此渲染:自有号码显示纯文本,
外部号码显示为可点击链接,点击跳转到企微页面并筛出关联账号
(见 `frontend/src/modules/phone/PhoneAssetView.js` 的号码类型列)。
若把 `number_type` 改成 `SELF`,该链接会失效,"这个号是从企微录入的"这一事实将无从查起。
因此本次只改「不再软删」这一条,来源标记原样保留。
**受影响的代码位置**(当前 Java 侧):
- `WecomAccountService` 改绑分支的软删逻辑(约 184 行)
- `WecomAccountService` 删除账号时的连带清理
......
......@@ -178,3 +178,21 @@ Prisma 读取 2026-08-01T18:39:41.000Z 数值原样,但被标记为 UTC
保持一致的部分:token 用随机 UUID 生成、cookie 属性为 `Path=/` 且不带 HttpOnly
(前端需要用 `document.cookie` 读取)、校验方式为请求头 `X-XSRF-TOKEN` 与 cookie 值比对、
`/api/auth/login` 豁免校验、校验失败返回 403 与文案「安全校验已失效,请刷新页面后重试」。
## 15. 事务:全项目只有企微模块需要
```
WecomAccountService:79 @Transactional create
WecomAccountService:96 @Transactional update
WecomAccountService:123 @Transactional softDelete
```
原因是这三个方法会同时写 `as_wecom_account``as_phone_asset` 两张表
(自动创建外部手机号、改绑时软删旧号)。其余模块均为单表操作,没有事务需求。
NestJS 侧必须用 `prisma.$transaction` 把这三个流程整体包住。
不包的后果:中途失败会留下已创建但无人引用的手机号记录,
而且不会有任何报错——数据默默变脏,只有对账时才发现。
注意 Prisma 的事务写法:交互式事务需把回调内所有查询都改用事务客户端 `tx`
漏掉一处该操作就跑在事务之外,等于没包。
/**
* 文件用途(白话):拿 NestJS 的响应和之前录下的 Java 快照逐字段比对,把不一致的地方列出来。
* 关联文件:record.mjs(录制)、normalize.mjs(归一化)、endpoints.mjs(端点清单)。
* 关联逻辑(数据流):snapshots/*.json 基准 -> 对目标服务重放同一请求 -> 归一化 -> 逐字段比对 -> 差异报告。
*
* 用法:
* node scripts/contract/compare.mjs --base http://127.0.0.1:7691
* node scripts/contract/compare.mjs --base http://127.0.0.1:7691 --filter auth
*
* 两条比对规则,区分对待(这是本工具能不能用的关键):
*
* 对象的字段顺序 —— 排序后比较。
* 消费方按名字取值,顺序没有语义。而且 Java 侧 Map.of 的迭代顺序取决于
* JVM 启动时的随机哈希种子,实测重启前后会变,本就不是稳定契约。
*
* 数组的元素顺序 —— 严格按原顺序比较。
* 顺序就是业务语义:列表排序、分页结果都靠它。若把数组也排序,
* "本该倒序却写成正序"这类真 bug 就会被掩盖,比对从保障变成掩护。
*/
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeBody, normalizeHeaders } from './normalize.mjs';
import { readEndpoints, errorEndpoints } from './endpoints.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const SNAP = join(here, 'snapshots');
const argOf = (flag, fallback) => { const i = process.argv.indexOf(flag); return i > -1 ? process.argv[i + 1] : fallback; };
const BASE = argOf('--base', 'http://127.0.0.1:7691');
const FILTER = argOf('--filter', null);
const env = Object.fromEntries(readFileSync(join(here, '../../backend/.env'), 'utf8')
.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.includes('='))
.map(l => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
/** 对象键排序、数组顺序保留。差异定位靠它把两边整理成同一形状。 */
function canonical(value) {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') {
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
}
return value;
}
/** 深度比较,返回具体到字段路径的差异列表,便于直接定位改哪一行。 */
function diff(expected, actual, path = '') {
const out = [];
const bothObjects = expected && actual && typeof expected === 'object' && typeof actual === 'object'
&& Array.isArray(expected) === Array.isArray(actual);
if (!bothObjects) {
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
out.push({ path: path || '(根)', expected, actual });
}
return out;
}
if (Array.isArray(expected)) {
if (expected.length !== actual.length) out.push({ path: `${path}.length`, expected: expected.length, actual: actual.length });
for (let i = 0; i < Math.min(expected.length, actual.length); i++) out.push(...diff(expected[i], actual[i], `${path}[${i}]`));
return out;
}
for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
const p = path ? `${path}.${key}` : key;
if (!(key in actual)) { out.push({ path: p, expected: expected[key], actual: '(缺少该字段)' }); continue; }
if (!(key in expected)) { out.push({ path: p, expected: '(不应存在)', actual: actual[key] }); continue; }
out.push(...diff(expected[key], actual[key], p));
}
return out;
}
/**
* 响应头里两类差异经确认无害,在此显式放行——写成白名单而不是默默跳过,
* 是为了让"为什么允许"和"允许了什么"都留在代码里可查。
*/
const HEADER_DIFF_ALLOWED = {
'content-type': 'Java 自身也不统一(部分接口带 charset、部分不带),HTTP 头解析不区分大小写与空格,前端无感',
'set-cookie': 'Java 会在已认证 GET 后下发删除 CSRF cookie 的指令,属框架副作用,不予复现,详见 CONTRACT-NOTES 第 14 条',
};
/** 判断某处头部差异是否落在白名单内。 */
function headerDiffAllowed(path) {
const key = path.replace(/^headers\./, '').split(/[.\[]/)[0];
return HEADER_DIFF_ALLOWED[key];
}
async function csrfAndSession() {
const c = await fetch(`${BASE}/api/auth/csrf`);
const setCookie = c.headers.getSetCookie();
const xsrf = setCookie.join(';').match(/XSRF-TOKEN=([^;]+)/)?.[1];
if (!xsrf) throw new Error('未能取得 CSRF 令牌,目标服务可能未实现 /api/auth/csrf');
const l = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': xsrf, Cookie: `XSRF-TOKEN=${xsrf}` },
body: JSON.stringify({ username: env['xyw.contract-test.username'], password: env['xyw.contract-test.password'] }),
});
if (l.status !== 200) throw new Error(`登录失败 HTTP ${l.status}`);
return { xsrf, session: l.headers.getSetCookie().map((x) => x.split(';')[0]).join('; ') };
}
async function replay(ep, ctx) {
const headers = { Cookie: ep.anonymous ? `XSRF-TOKEN=${ctx.xsrf}` : `${ctx.session}; XSRF-TOKEN=${ctx.xsrf}` };
if (!ep.skipCsrf && ep.method !== 'GET') headers['X-XSRF-TOKEN'] = ctx.xsrf;
if (ep.body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(`${BASE}${ep.path}`, {
method: ep.method, headers,
body: ep.body !== undefined ? JSON.stringify(ep.body) : undefined,
redirect: 'manual',
});
const raw = await res.text();
let body; try { body = JSON.parse(raw); } catch { body = `<NON_JSON:${raw.slice(0, 40)}>`; }
return { status: res.status, headers: normalizeHeaders(res.headers, res.headers.getSetCookie()), body: normalizeBody(body) };
}
const all = [...readEndpoints, ...errorEndpoints].filter((ep) => !FILTER || ep.name.includes(FILTER));
const ctx = await csrfAndSession();
const results = [];
for (const ep of all) {
const file = join(SNAP, `${ep.name}.json`);
if (!existsSync(file)) { results.push({ name: ep.name, state: 'NO_SNAPSHOT' }); continue; }
const snapshot = JSON.parse(readFileSync(file, 'utf8'));
let actual;
try { actual = await replay(ep, ctx); }
catch (err) { results.push({ name: ep.name, state: 'ERROR', message: err.message }); continue; }
const differences = [
...diff(canonical(snapshot.status), canonical(actual.status), 'status'),
...diff(canonical(snapshot.body), canonical(actual.body), 'body'),
...diff(canonical(snapshot.headers), canonical(actual.headers), 'headers'),
];
const real = differences.filter((d) => !(d.path.startsWith('headers.') && headerDiffAllowed(d.path)));
const waived = differences.filter((d) => d.path.startsWith('headers.') && headerDiffAllowed(d.path));
results.push(real.length === 0
? { name: ep.name, state: 'MATCH', waived: waived.length }
: { name: ep.name, state: 'DIFF', differences: real, allowDiff: ep.allowDiff, waived: waived.length });
}
const match = results.filter((r) => r.state === 'MATCH');
const allowed = results.filter((r) => r.state === 'DIFF' && r.allowDiff);
const failed = results.filter((r) => r.state === 'DIFF' && !r.allowDiff);
const missing = results.filter((r) => r.state === 'NO_SNAPSHOT' || r.state === 'ERROR');
console.log(`比对目标 ${BASE}${FILTER ? `(筛选 ${FILTER})` : ''}`);
console.log(`一致 ${match.length} / 允许差异 ${allowed.length} / 不一致 ${failed.length} / 无法比对 ${missing.length}\n`);
for (const f of failed) {
console.log(`❌ ${f.name}`);
for (const d of f.differences.slice(0, 8)) {
console.log(` ${d.path}`);
console.log(` 期望 ${JSON.stringify(d.expected)?.slice(0, 100)}`);
console.log(` 实际 ${JSON.stringify(d.actual)?.slice(0, 100)}`);
}
if (f.differences.length > 8) console.log(` …… 另有 ${f.differences.length - 8} 处差异`);
}
for (const a of allowed) console.log(`➖ ${a.name}(已标注允许差异:${a.allowDiff})`);
for (const m of missing) console.log(`⚠️ ${m.name} ${m.state === 'ERROR' ? m.message : '缺少基准快照'}`);
const waivedTotal = results.reduce((n, r) => n + (r.waived ?? 0), 0);
if (waivedTotal) console.log(`
(另有 ${waivedTotal} 处响应头差异在白名单内,已放行)`);
process.exitCode = failed.length > 0 ? 1 : 0;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment