feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-15 14:58:31 +08:00
parent bcb278be29
commit 18ecf8045f
12 changed files with 1006 additions and 851 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ export function renderHttpGuide(markdown: string, origin: string) {
continue; continue;
} }
closeTable(); closeTable();
if (line.trim().endsWith('') && line.trim().length < 70) sampleTitle = line.trim().replace(/$/, ''); const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1');
if (caption.endsWith('') && caption.length < 70) sampleTitle = caption.replace(/$/, '');
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`); if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
} }
closeTable(); closeTable();
+2 -3
View File
@@ -15,7 +15,7 @@ import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto'; import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types'; import type { OpenApiRequestLike } from './open-api.types';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol'; import { openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service'; import { SecurityDetectionService } from '../security-detection/security-detection.service';
@@ -97,14 +97,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' }); throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
} }
const path = (request.originalUrl ?? request.url ?? '').split('?')[0]; const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = openApiBodyHash(request.rawBody, request.body);
const expected = openApiSignature( const expected = openApiSignature(
decryptSecret(credential.secretEncrypted), decryptSecret(credential.secretEncrypted),
request.method, request.method,
path, path,
timestampText, timestampText,
nonce, nonce,
bodyHash, request.rawBody,
); );
const expectedBuffer = Buffer.from(expected, 'hex'); const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
+8 -22
View File
@@ -20,37 +20,23 @@ describe('HTTP API remediation boundaries', () => {
'/api/openapi/v1/sms/uplinks', '/api/openapi/v1/sms/uplinks',
'1789344000', '1789344000',
'550e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440000',
openApiBodyHash(undefined, undefined), undefined,
), ),
).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65'); ).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
}); });
it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => { it('preserves internal idempotency hashes but signs exact POST UTF8 bytes', () => {
expect(openApiBodyHash(undefined, undefined)).toBe( expect(openApiBodyHash(undefined, undefined)).toBe(
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a', '44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
); );
const raw = Buffer.from('{ "content": "中文\\n正文" }'); const raw = Buffer.from('{ "content": "中文\\n正文" }');
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex')); expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', openApiBodyHash(raw, {})].join('\n'); const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw.toString('utf8')].join('\n');
expect( expect(
openApiSignature( openApiSignature('offline-secret', 'post', '/api/openapi/v1/sms/messages?ignored=1', '123', 'nonce-0001', raw),
'offline-secret',
'post',
'/api/openapi/v1/sms/messages?ignored=1',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex')); ).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
for (const separator of ['\r\n', '\\n']) for (const separator of ['\r\n', '\\n'])
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe( expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
openApiSignature( openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw),
'offline-secret',
'POST',
'/api/openapi/v1/sms/messages',
'123',
'nonce-0001',
openApiBodyHash(raw, {}),
),
); );
}); });
@@ -169,11 +155,11 @@ describe('HTTP API remediation boundaries', () => {
}); });
it('keeps named code examples beside their source paragraphs', () => { it('keeps named code examples beside their source paragraphs', () => {
const html = renderHttpGuide( const html = renderHttpGuide(
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n五行原文:\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```', '**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n**签名原文:**\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
'', '',
); );
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明')); expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
expect(html).toContain('五行原文 · text'); expect(html).toContain('签名原文 · text');
expect(html).not.toContain('data-show-sample'); expect(html).not.toContain('data-show-sample');
expect(html).not.toContain('<aside'); expect(html).not.toContain('<aside');
expect(html).not.toMatch(/>示例 \d+</); expect(html).not.toMatch(/>示例 \d+</);
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { createHash, createHmac } from 'node:crypto';
import { openApiSignature } from './open-api.protocol';
describe('raw-body request signing contract', () => {
const path = '/api/openapi/v1/sms/messages';
const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db';
const body = Buffer.from(
'{"mobile":"13800138000","content":"【示例签名】您的验证码是1234565分钟内有效。","clientMessageId":"doc-example-20260914-0001"}',
);
const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL';
const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw);
it('matches the independently computed published POST vector', () => {
expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124');
});
it('does not accept legacy body digests or changed body bytes', () => {
const legacy = createHmac('sha256', secret)
.update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n'))
.digest('hex');
expect(sign(body)).not.toBe(legacy);
for (const changed of [
Buffer.concat([body, Buffer.from('\n')]),
Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)),
Buffer.from(body.toString().replace('123456', '654321')),
]) {
expect(sign(changed)).not.toBe(sign(body));
}
});
it('rejects missing POST raw bytes instead of reconstructing JSON', () => {
expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体');
});
it('rejects nonempty GET bodies and distinguishes a trailing LF', () => {
const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce];
const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]);
expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex'));
expect(actual).not.toBe(
createHmac('sha256', secret)
.update(fields.join('\n') + '\n')
.digest('hex'),
);
expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文');
});
it('executes both handbook examples and verifies every complete request packet', () => {
const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace(
/\r\n/g,
'\n',
);
expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例');
expect(guide).not.toContain('### 2.4');
const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
expect(scripts).toHaveLength(2);
for (const script of scripts) {
const outputs: string[] = [];
runInNewContext(script[1], {
Buffer,
require: () => ({ createHmac }),
console: { log: (value: string) => outputs.push(value) },
});
expect(outputs).toHaveLength(1);
expect(guide).toContain(outputs[0]);
}
const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)];
expect(packets).toHaveLength(5);
for (const [, packet] of packets) {
const split = packet.indexOf('\n\n');
const headers = packet.slice(0, split);
const [method, url] = headers.split('\n')[0].split(' ');
const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1];
const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined;
if (raw) expect(raw.length).toBe(Number(header('Content-Length')));
expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe(
header('X-Signature'),
);
}
});
});
+6 -1
View File
@@ -37,7 +37,12 @@ import {
@ApiHeader({ name: 'X-App-Key', required: true }) @ApiHeader({ name: 'X-App-Key', required: true })
@ApiHeader({ name: 'X-Timestamp', required: true }) @ApiHeader({ name: 'X-Timestamp', required: true })
@ApiHeader({ name: 'X-Nonce', required: true }) @ApiHeader({ name: 'X-Nonce', required: true })
@ApiHeader({ name: 'X-Signature', required: true }) @ApiHeader({
name: 'X-Signature',
required: true,
description:
'HMAC-SHA256小写十六进制。方法、路径(不含query)、时间戳、nonce以LF分隔;GET末尾无LFPOST追加LF及原始UTF-8正文,不计算正文摘要。',
})
@ApiResponse({ status: 400, type: OpenApiProblemDto }) @ApiResponse({ status: 400, type: OpenApiProblemDto })
@ApiResponse({ status: 401, type: OpenApiProblemDto }) @ApiResponse({ status: 401, type: OpenApiProblemDto })
@ApiResponse({ status: 403, type: OpenApiProblemDto }) @ApiResponse({ status: 403, type: OpenApiProblemDto })
+15 -5
View File
@@ -64,7 +64,7 @@ export function sendOpenApiProblem(
}); });
} }
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */ /** Internal idempotency fingerprint; this digest is not part of request authentication. */
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) { export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
return createHash('sha256') return createHash('sha256')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {}))) .update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
@@ -77,11 +77,21 @@ export function openApiSignature(
path: string, path: string,
timestamp: string, timestamp: string,
nonce: string, nonce: string,
bodyHash: string, rawBody?: Buffer,
) { ) {
return createHmac('sha256', secret) const verb = method.toUpperCase();
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n')) if (verb === 'GET' && rawBody?.length) {
.digest('hex'); throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'GET请求不得携带正文' });
}
if (verb !== 'GET' && !rawBody) {
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '缺少原始请求体' });
}
const signature = createHmac('sha256', secret).update(
[verb, path.split('?')[0], timestamp, nonce].join('\n'),
'utf8',
);
if (verb !== 'GET') signature.update('\n').update(rawBody!);
return signature.digest('hex');
} }
export function publicOpenApiFailure(error: unknown) { export function publicOpenApiFailure(error: unknown) {
File diff suppressed because it is too large Load Diff
@@ -2307,3 +2307,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
## 2026-09-14 客户端页面与文档调整 ## 2026-09-14 客户端页面与文档调整
模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。 模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。
## 2026-09-15 HTTP 客户手册与签名简化
按用户确认的 B6 手册实施,原2.4移为1.4。POST直接签原始UTF-8正文,GET四项LF分隔且末尾无LF;不兼容尝试旧摘要签名。保持凭据、权限、时间窗、nonce去重、内部发送幂等及Webhook规则。公开页、客户端独立文档页与MD下载共用权威手册,Node示例和完整HTTP报文按新规则验证。新规则替代此前三步正文摘要签名需求,适用本次代码版本;仅授权测试部署,预生产不变。设计及兼容影响见[HTTP整改方案](http-api-assessment-20260910.md)的2026-09-15章节。
+12
View File
@@ -305,3 +305,15 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。 截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。 完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
## 2026-09-15 已确认的新请求签名规则(实施中)
本节按用户最终 B6 手册及本轮实施授权,替代前述 R01/3.2 的摘要签名保留要求;历史日期的实现证据仍保留。本轮公开手册采用 B6 内容,将原 2.4 cURL 使用说明移到 1.4;公开阅读器、客户端 iframe 和 MD 下载继续共用 docs/client-http-api-guide.md。
- POSTUTF-8 编码的方法、路径、timestamp、nonce 以 LF 分隔,nonce 后一个 LF,再原样追加 rawBody 字节,直接 HMAC-SHA256。禁止对解析后 JSON 重建正文;POST 缺失 rawBody 时拒绝,避免签署与收到的字节不一致。
- GET:仅四项以 LF 分隔,末尾无 LF,无正文;携带非空正文拒绝。query 仍不参与签名,路径仍不含域名及 query,不新增编码/排序协议。
- 使用原四个鉴权头、路径和凭据;不自动尝试旧摘要算法,不引入隐式双算法。旧签名返回 SIGNATURE_INVALID,既有接入须同步升级。测试环境本轮发布,预生产不发布;回退应用同时恢复原手册,客户须恢复匹配算法。
- 时间戳窗口、nonce Redis 原子去重、租户/应用隔离、白名单、QPS 和日志脱敏保持。发送幂等使用的内部 bodyHash 保持,已有请求快照/计费/消息/队列均不迁移、不补投。Webhook 验签不变。
- 数据模型、基础设施配置和 Gateway 无改动。历史查询 startTime/endTime 能力保留;手册简化介绍不代表删除后台兼容参数。
- 验收:固定 POST/GET 向量、旧签名拒绝、末尾 LF/CRLF/字面反斜杠/正文空格与字段顺序篡改、缺失 rawBody、非空 GET、重放、权限与隔离;真实 HTTP/PG/Redis 验证以隔离数据和无发送请求完成,线上不创建短信或更改客户配置。两段 Node 示例从权威文档提取运行并与全部 HTTP 报文复算;公开页面三尺寸、目录/检索/下载及客户端共享入口验收。按精确提交标准 validate/preflight/prepare/deploy/verify。
+15
View File
@@ -5526,3 +5526,18 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
### CLIENT-0914 系列执行结果(2026-09-14 23:25 CST ### CLIENT-0914 系列执行结果(2026-09-14 23:25 CST
CLIENT-0914-0107 的模板样式/顺序、文档归属与检索、中文状态/短信正文、回执聚合字段与时区已在独立真实 PostgreSQL/服务组件环境验证;测试部署应用为 4665079。在线文档三尺寸、接口内容和真实数据库查询回执已复核通过;HTTP 环境剪贴板自动复制受限时选中示例并提示手动复制。线上登录后模板/工作台/短信详情全流程因未取得客户端账号仍未执行,不以本地隔离组件或公开文档页面替代。详细日志、首轮 worker 超时与完整复测结果见 [整改记录](client-ui-remediation-20260914.md)。 CLIENT-0914-0107 的模板样式/顺序、文档归属与检索、中文状态/短信正文、回执聚合字段与时区已在独立真实 PostgreSQL/服务组件环境验证;测试部署应用为 4665079。在线文档三尺寸、接口内容和真实数据库查询回执已复核通过;HTTP 环境剪贴板自动复制受限时选中示例并提示手动复制。线上登录后模板/工作台/短信详情全流程因未取得客户端账号仍未执行,不以本地隔离组件或公开文档页面替代。详细日志、首轮 worker 超时与完整复测结果见 [整改记录](client-ui-remediation-20260914.md)。
## 2026-09-15 HTTP 签名简化验收
本组替代现行版本的旧GET{}摘要/三步签名断言,历史执行记录不改写。
| 用例 | 验收内容 |
| --- | --- |
| HTTP-SIGN-0915-01 | 手册2.2 POST原始字节HMAC与固定向量一致;2.3 GET四项以LF分隔、末尾无LF;两段Node示例实际运行,五个完整HTTP报文签名复算一致。 |
| HTTP-SIGN-0915-02 | 真实HTTP鉴权拒绝旧摘要签名、GET末尾LF、CRLF和字面反斜杠n;POST正文空格/末尾换行/内容篡改拒绝。 |
| HTTP-SIGN-0915-03 | POST无rawBody、GET非空正文返回400;不从解析后JSON重建签名字节。 |
| HTTP-SIGN-0915-04 | 真实PG查询仅当前应用资料,其他应用详情404;Redis nonce重放401、过期timestamp及无效凭据401。 |
| HTTP-SIGN-0915-05 | 同幂等键/同正文仍读取既有requires_review;改正文409冲突;无消息、批次或队列新增,不恢复未知发送。 |
| HTTP-SIGN-0915-06 | 原2.4移到1.4并修正引用;MD下载与源一致,三尺寸目录/刷新/检索/空结果/复制正常,加粗标题成为具名示例;不执行示例请求。 |
| HTTP-SIGN-0915-07 | 精确提交测试发布后核对真实文档及新签名查询;旧算法拒绝。账号/凭据缺失时不恢复或新建线上配置,明确未执行范围。 |
+11
View File
@@ -5018,3 +5018,14 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
三条真实测试消息的数据库回执保持不变,已部署客户端分页查询服务已由 null 恢复返回 delivered 和真实时间。线上文档三尺寸/43 内嵌示例/检索通过;HTTP 剪贴板受限时实际提供选中后手动复制。当前版本、服务、迁移、队列和日志检查通过无警告。完整登录后客户端页面仍待可用账号;连接器失败不等于用户未登录,本地真实 PG/服务组件验收不替代线上登录验收。 三条真实测试消息的数据库回执保持不变,已部署客户端分页查询服务已由 null 恢复返回 delivered 和真实时间。线上文档三尺寸/43 内嵌示例/检索通过;HTTP 剪贴板受限时实际提供选中后手动复制。当前版本、服务、迁移、队列和日志检查通过无警告。完整登录后客户端页面仍待可用账号;连接器失败不等于用户未登录,本地真实 PG/服务组件验收不替代线上登录验收。
计划 20260914T151450-4665079ca3d2-874a39f8,备份 attempt-1789399125391792291。远端准备 61.7 秒、备份 34.5 秒、停止开始至启动完成约 13.4 秒。系统盘净增约 1.46 GiB、可用约 17.21 GiB,历史资产未清理。设计、详细证据及未验证项见 [客户端页面与接口文档整改](client-ui-remediation-20260914.md)。预生产未部署;未发送短信或修改业务配置。 计划 20260914T151450-4665079ca3d2-874a39f8,备份 attempt-1789399125391792291。远端准备 61.7 秒、备份 34.5 秒、停止开始至启动完成约 13.4 秒。系统盘净增约 1.46 GiB、可用约 17.21 GiB,历史资产未清理。设计、详细证据及未验证项见 [客户端页面与接口文档整改](client-ui-remediation-20260914.md)。预生产未部署;未发送短信或修改业务配置。
## 2026-09-15 HTTP 签名简化实施与测试发布(进行中)
起点main/实际远端bcb278be29857b73ec11e04650923f473875ca9b,暂存空,53个已有文件保护摘要在.local-data/http-signature-20260915/protected-hashes.json。测试机SSH初期超时、Tailscale relay可达,后续恢复并回读线上4665079ca3d2f985c85c5c1d08f8fe173047e9bdsudo仍需掩码输入。
按B6实施POST原始正文HMAC、GET末尾无LF,保留内部幂等摘要和Webhook规则;旧签名不回退尝试。原2.4移1.4、公开/客户端共用MD,补OpenAPI签名描述。页面只读复现加粗示例标题未识别,reader支持加粗标题。相关设计先更新于http-api-assessment-20260910.md。
定向31项、初轮API全量75套817项及构建通过。独立本地PG16424/cmpp_qa_http_signature104迁移)、Redis16425、真实Nest16426验收16组通过,包含查询、隔离、篡改、旧算法、重放、幂等及MD下载,短信/批次数均0。仅安全辅助日志隔离为空实现,不将日志持久化标为验收。未启动发送或回调WorkerRedis5.0.14提示建议6.2+,实际nonce命令通过。样本初轮漏carriers、参数错误码断言与真实DTO不一致、幂等样本漏credentialId,修正并保留失败日志;不视为业务缺陷。另一本地PG16安装缺dict_snowball,使用既有完整pgsql安装启动本轮隔离实例。
CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页面横向溢出;1.4/2.2/2.3顺序、36个具名示例、目录跳转、复制、刷新、错误码检索和空结果通过,控制台无warn/error。当前仅本地页面,线上验收另记。证据目录.local-data/http-signature-20260915;最终精确候选门禁/提交/推送/发布后补。无预生产或业务配置修改、无短信发送。
+246
View File
@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { createHmac, createHash, randomUUID } from 'node:crypto';
import { request } from 'node:http';
import fs from 'node:fs';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
const dbUrl = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
const redisUrl = new URL(process.env.SIGNATURE_TEST_REDIS_URL || '');
assert(['127.0.0.1', 'localhost'].includes(dbUrl.hostname) && dbUrl.pathname.startsWith('/cmpp_qa_'));
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname) && Number(redisUrl.port) > 10000);
process.env.DATABASE_URL = dbUrl.toString();
process.env.REDIS_URL = redisUrl.toString();
process.env.NODE_ENV = 'test';
process.env.HTTP_API_MASTER_KEY = randomUUID();
require('reflect-metadata');
const { Module } = require('@nestjs/common');
const { NestFactory } = require('@nestjs/core');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { OpenApiService } = require('./dist/open-api/open-api.service');
const { OpenApiAuthGuard } = require('./dist/open-api/open-api-auth.guard');
const { OpenApiController } = require('./dist/open-api/open-api.controller');
const { OpenApiDocsController } = require('./dist/open-api/open-api-docs.controller');
const { OpenApiTraceInterceptor } = require('./dist/open-api/open-api-trace.interceptor');
const { SecurityDetectionService } = require('./dist/security-detection/security-detection.service');
const { encryptSecret } = require('./dist/open-api/open-api.crypto');
const { configureHttpBodyParsers } = require('./dist/http-body-limits');
const db = new PrismaService();
const service = new OpenApiService(db, undefined);
const checks = [];
let app;
const ok = (name) => {
checks.push(name);
console.log('PASS', name);
};
try {
assert.equal(await db.smsMessageRecord.count(), 0, 'Dedicated empty QA database required');
const tenant = await db.tenant.create({ data: { name: '签名隔离验收', code: randomUUID() } });
const createApp = () =>
db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '签名隔离应用',
cmppAccount: randomUUID(),
cmppEnterpriseCode: '000001',
secretHash: 'not-login',
interfaceEnabled: false,
httpConfig: { create: { enabled: true, sendEnabled: true, qpsLimit: 100 } },
},
});
const own = await createApp();
const other = await createApp();
const secret = randomUUID();
const accessKey = randomUUID();
const credential = await db.httpApiCredential.create({
data: {
applicationId: own.id,
name: '签名测试',
accessKey,
secretEncrypted: encryptSecret(secret),
secretLast4: secret.slice(-4),
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离占位',
code: randomUUID(),
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'none',
passwordCipher: 'none',
srcId: 'none',
status: 'disabled',
carriers: ['mobile'],
},
});
const uplink = await db.smsUplinkMessage.create({
data: {
tenantId: tenant.id,
applicationId: own.id,
channelId: channel.id,
phoneNumber: '13800138000',
destId: '10690000',
content: '签名验收',
receivedAt: new Date(),
matchStatus: 'matched',
},
});
const foreign = await db.smsUplinkMessage.create({
data: {
tenantId: tenant.id,
applicationId: other.id,
channelId: channel.id,
phoneNumber: '13800138000',
destId: '10690000',
content: '其他应用',
receivedAt: new Date(),
matchStatus: 'matched',
},
});
class Harness {}
Module({
controllers: [OpenApiController, OpenApiDocsController],
providers: [
{ provide: PrismaService, useValue: db },
{ provide: OpenApiService, useValue: service },
{ provide: SecurityDetectionService, useValue: { recordEvent: async () => {} } },
OpenApiAuthGuard,
OpenApiTraceInterceptor,
],
})(Harness);
app = await NestFactory.create(Harness, { logger: false, rawBody: true, bodyParser: false });
app.setGlobalPrefix('api');
configureHttpBodyParsers(app);
await app.listen(16426, '127.0.0.1');
const invoke = async (path, options = {}) => {
const method = options.method || 'GET';
const timestamp = options.timestamp || String(Math.floor(Date.now() / 1000));
const nonce = options.nonce || randomUUID();
const body = options.body;
const fields = [method, path.split('?')[0], timestamp, nonce];
let bytes = Buffer.from(fields.join(options.separator || '\n'));
if (options.legacy)
bytes = Buffer.from(
[
...fields,
createHash('sha256')
.update(body || '{}')
.digest('hex'),
].join('\n'),
);
else if (method === 'POST')
bytes = Buffer.concat([bytes, Buffer.from('\n'), Buffer.from(options.signedBody ?? body ?? '')]);
else if (options.trailing) bytes = Buffer.concat([bytes, Buffer.from('\n')]);
const signature = createHmac('sha256', secret).update(bytes).digest('hex');
const headers = {
'X-App-Key': options.key || accessKey,
'X-Timestamp': timestamp,
'X-Nonce': nonce,
'X-Signature': signature,
...options.headers,
};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = String(Buffer.byteLength(body));
}
return new Promise((resolve, reject) => {
const req = request('http://127.0.0.1:16426' + path, { method, headers, timeout: 10000 }, (res) => {
let data = '';
res.on('data', (x) => (data += x));
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data) }));
});
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('timeout')));
req.end(body);
});
};
const list = '/api/openapi/v1/sms/uplinks';
let r = await invoke(list);
assert.equal(r.status, 200);
assert.deepEqual(
r.body.items.map((x) => x.id),
[uplink.id],
);
ok('new GET authenticates and queries actual PostgreSQL with app isolation');
r = await invoke(list + '/' + uplink.id);
assert.equal(r.body.content, uplink.content);
assert(!('channelId' in r.body));
ok('detail matches database and hides channel fields');
assert.equal((await invoke(list + '/' + foreign.id)).status, 404);
ok('foreign application detail excluded');
for (const options of [{ legacy: true }, { trailing: true }, { separator: '\r\n' }, { separator: '\\n' }]) {
r = await invoke(list, options);
assert.equal(r.status, 401);
assert.equal(r.body.code, 'SIGNATURE_INVALID');
}
ok('legacy GET, trailing LF, CRLF and literal escape rejected');
const nonce = randomUUID();
assert.equal((await invoke(list, { nonce })).status, 200);
assert.equal((await invoke(list, { nonce })).body.code, 'NONCE_REPLAYED');
ok('real Redis atomic nonce replay rejection');
assert.equal((await invoke(list, { timestamp: '100' })).body.code, 'TIMESTAMP_EXPIRED');
ok('expired timestamp rejected');
assert.equal((await invoke(list, { key: 'unknown' })).body.code, 'CREDENTIAL_INVALID');
ok('unknown credential rejected');
assert.equal((await invoke(list + '?limit=1.5')).body.code, 'LIMIT_INVALID');
ok('signed invalid query reaches parameter validation');
assert.equal((await invoke(list, { body: '{}' })).status, 400);
ok('nonempty GET body rejected');
const post = '/api/openapi/v1/sms/messages';
const raw = '{ "mobile": "invalid", "content": "中文测试" }';
r = await invoke(post, { method: 'POST', body: raw });
assert.equal(r.body.code, 'PARAMETER_INVALID');
ok('new POST original UTF8 body authenticates before safe validation rejection');
for (const options of [
{ legacy: true },
{ signedBody: raw.trim() + '\n' },
{ signedBody: JSON.stringify(JSON.parse(raw)) },
]) {
r = await invoke(post, { method: 'POST', body: raw, ...options });
assert.equal(r.body.code, 'SIGNATURE_INVALID');
}
ok('legacy POST, changed whitespace and trailing newline rejected');
r = await invoke(post, { method: 'POST' });
assert.equal(r.status, 400);
ok('missing raw POST body rejected');
// Stored uncertain request proves internal idempotency remains; never create an SMS.
const valid = '{"mobile":"13800138000","content":"仅幂等核验"}',
idem = 'qa-' + randomUUID();
await db.openApiRequest.create({
data: {
applicationId: own.id,
tenantId: tenant.id,
requestId: randomUUID(),
idempotencyKey: idem,
credentialId: credential.id,
bodyHash: createHash('sha256').update(valid).digest('hex'),
status: 'requires_review',
},
});
r = await invoke(post, { method: 'POST', body: valid, headers: { 'Idempotency-Key': idem } });
assert.equal(r.body.code, 'REQUEST_REQUIRES_REVIEW');
r = await invoke(post, { method: 'POST', body: valid + ' ', headers: { 'Idempotency-Key': idem } });
assert.equal(r.body.code, 'IDEMPOTENCY_CONFLICT');
ok('existing idempotency fingerprint preserved without requeue');
const last = await db.httpApiCredential.findUnique({ where: { id: credential.id } });
assert(last.lastUsedAt);
ok('actual credential usage persisted');
assert.equal(await db.smsMessageRecord.count(), 0);
assert.equal(await db.smsBatchTask.count(), 0);
ok('zero SMS records or batches created');
const md = await (await fetch('http://127.0.0.1:16426/api/client-docs?format=md')).text();
assert.equal(md, fs.readFileSync(new URL('../../docs/client-http-api-guide.md', import.meta.url), 'utf8'));
ok('actual docs download equals authoritative guide');
if (process.env.SIGNATURE_TEST_KEEP_OPEN === '1') {
console.log('READY_BROWSER');
await new Promise((resolve) => {
process.on('SIGINT', resolve);
process.on('SIGTERM', resolve);
});
}
} finally {
if (process.env.SIGNATURE_TEST_REPORT)
fs.writeFileSync(process.env.SIGNATURE_TEST_REPORT, JSON.stringify({ checks }, null, 2));
await app?.close();
await db.$disconnect();
}