diff --git a/api/src/open-api/open-api-webhook-lookup.spec.ts b/api/src/open-api/open-api-webhook-lookup.spec.ts new file mode 100644 index 0000000..b86bffd --- /dev/null +++ b/api/src/open-api/open-api-webhook-lookup.spec.ts @@ -0,0 +1,42 @@ +import { createServer, get, type RequestOptions } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { pinnedWebhookLookup } from './open-api.protocol'; + +describe('webhook pinned DNS lookup', () => { + it('supports single-address and all-address callbacks without resolving another address', () => { + const callback = jest.fn(); + const lookup = pinnedWebhookLookup('203.0.113.10', 4); + lookup('ignored.example', {}, callback); + expect(callback).toHaveBeenLastCalledWith(null, '203.0.113.10', 4); + lookup('ignored.example', { all: true }, callback); + expect(callback).toHaveBeenLastCalledWith(null, [{ address: '203.0.113.10', family: 4 }]); + pinnedWebhookLookup('2001:db8::10', 6)('ignored.example', { all: true }, callback); + expect(callback).toHaveBeenLastCalledWith(null, [{ address: '2001:db8::10', family: 6 }]); + }); + + it('delivers through the real Node HTTP connector when automatic family selection requests all addresses', async () => { + const server = createServer((_request, response) => response.end('received')); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const port = (server.address() as AddressInfo).port; + const options: RequestOptions & { autoSelectFamily: boolean } = { + lookup: pinnedWebhookLookup('127.0.0.1', 4), + autoSelectFamily: true, + agent: false, + }; + const body = await new Promise((resolve, reject) => { + const request = get(`http://webhook.invalid:${port}/`, options, (response) => { + let received = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => (received += chunk)); + response.on('end', () => resolve(received)); + }); + request.setTimeout(3000, () => request.destroy(new Error('test HTTP timeout'))); + request.on('error', reject); + }); + expect(body).toBe('received'); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } + }); +}); diff --git a/api/src/open-api/open-api.protocol.ts b/api/src/open-api/open-api.protocol.ts index 6b8bdc3..58df99d 100644 --- a/api/src/open-api/open-api.protocol.ts +++ b/api/src/open-api/open-api.protocol.ts @@ -1,5 +1,14 @@ import { createHash, createHmac } from 'node:crypto'; import { HttpException } from '@nestjs/common'; +import type { LookupFunction } from 'node:net'; + +/** Keep the validated address pinned while honoring Node's all-address lookup contract. */ +export function pinnedWebhookLookup(address: string, family: number): LookupFunction { + return (_hostname, options, callback) => { + if (options.all) callback(null, [{ address, family }]); + else callback(null, address, family); + }; +} /** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */ export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) { diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index 57b7c78..2869223 100644 --- a/api/src/open-api/open-api.service.ts +++ b/api/src/open-api/open-api.service.ts @@ -26,7 +26,7 @@ import { SendChainService } from '../send-chain/send-chain.service'; import { decryptSecret, encryptSecret } from './open-api.crypto'; import type { OpenApiAuthContext } from './open-api.types'; import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service'; -import { publicOpenApiFailure, webhookJobId } from './open-api.protocol'; +import { pinnedWebhookLookup, publicOpenApiFailure, webhookJobId } from './open-api.protocol'; import { automaticDeliveryMode } from './delivery-mode'; export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport'); @@ -934,7 +934,7 @@ async function postWebhook( { method: 'POST', headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) }, - lookup: (_hostname, _options, callback) => callback(null, target.address, target.family), + lookup: pinnedWebhookLookup(target.address, target.family), }, (response) => { const chunks: Buffer[] = []; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index fb2ecd9..ff4a4a3 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2278,3 +2278,8 @@ 按[HTTP整改方案](http-api-assessment-20260910.md)本轮实施章节执行R01~R09。保持v1四接口和GET兼容摘要;补参数/响应契约、公共错误和交互关联ID。新请求以确定性业务关联及消息/响应/发送待办原子落库避免永久processing;未知结果必须核对,不自动新建短信。新回调耐久恢复采用安全任务编号、租约和原子尝试记录,旧回调不自动回填或重投。 用户本轮明确:上行不得暴露通道、供应商编号及内部匹配诊断,仅输出客户自己的公共业务字段和所属企业/应用ID。公共文档与客户端Tab同源复用,未开通/无应用/配置失败可读通用文档,不能用默认值冒充真实应用配置。发送正向链路和历史回调处理须另有专项授权。本轮提交、推送、测试环境部署已授权;预生产未授权。 + + +### 2026-09-14 HTTP回调传输兼容验收补充 + +Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后的固定DNS地址必须同时符合lookup单地址和all-address回调契约,不得通过重新解析或取消私网限制解决投递问题。完整模拟链路验收及限制见 [HTTP全量验收](http-api-full-acceptance-20260914.md)。 diff --git a/docs/http-api-assessment-20260910.md b/docs/http-api-assessment-20260910.md index d74ba5e..59975a1 100644 --- a/docs/http-api-assessment-20260910.md +++ b/docs/http-api-assessment-20260910.md @@ -286,3 +286,8 @@ R03不能仅通过“增加重试”关闭。后续详细设计至少说明: 2026-09-14 核验:本地、真实 Git 远端、预生产部署标记均为 `d13ca0713abd6afbea5a62af39bcbb876b8bb186`。预生产鉴权 guard、OpenApiService、controller 经换行规范化的文本摘要与本地一致;公开 Swagger/JSON 均 HTTP 200。文档依据当前 controller、DTO、guard、service、异常过滤器、回执/上行事件生产代码和 Prisma 字段;未把历史数据库样本当作本次业务验收。 内部追踪:[测试用例](system-functional-test-cases.md)、[测试与实施进度](testing-progress.md)。后续实现发生变化时,签名函数、字段表、错误码、回调示例和在线契约须一起更新。 + + +## 2026-09-14 真实HTTP全量验收发现的Webhook DNS缺陷 + +测试环境 f0e8434 上,专用模拟应用已产生真实送达回执,但5个Webhook事件均在2次尝试后失败,lastError为 `Invalid IP address: undefined`,受控HTTPS接收端无请求。Node自动地址族选择以 `all=true` 调用自定义lookup,旧实现仍返回单地址三参数,违反回调契约。保留原SSRF解析、私网拒绝及地址固定,只按all选项返回固定地址数组或原单地址。未改变重试、计费、发送与租户规则。真实Node HTTPS连接旧实现失败/修正实现200,本地真实HTTP连接回归及API全量73套783项、类型构建通过;线上修复与Webhook全场景验收尚待标准发布完成。用户已另行授权本修复提交、推送及测试发布,不涉及预生产。完整结果将登记 [HTTP全量验收](http-api-full-acceptance-20260914.md)。 diff --git a/docs/http-api-full-acceptance-20260914.md b/docs/http-api-full-acceptance-20260914.md new file mode 100644 index 0000000..fadf49a --- /dev/null +++ b/docs/http-api-full-acceptance-20260914.md @@ -0,0 +1,11 @@ +# HTTP接口全量真实模拟验收(2026-09-14) + +状态:执行中,不表示全部通过。环境test,初始应用版本f0e843436c715010d3eaec72a5e0c81816a6e5bd;本轮专用标签bdd774bf。 + +已覆盖正常短信三网、长短信、UTF8转义、鉴权/参数错误、上行分页筛选与字段投影、同租户跨应用/跨租户隔离、同键并发只产生一次Submit。测试证据位于 `%TEMP%/cmpp-http-full-20260914/`,不含鉴权秘密。 + +真实缺陷:Webhook在Node自动地址族选择时因lookup回调格式错误,未到HTTP接收端就报Invalid IP address: undefined。最小修复保持SSRF地址校验不变;API 73套783项及构建通过,已授权提交/推送/测试发布,发布及回调复验未完成。 + +共享模拟接入号造成3条上行ambiguous,公开接口正确隐藏;独立接入号后的3条上行已正确匹配。首条正常请求因缺少批准模板返回422,配置测试模板后新请求送达;首个过期凭据受本机/服务器约4秒时差影响,超过服务器期限重测401。保留原结果,不计为服务端缺陷。 + +待完成:标准发布、Webhook成功和失败重试/超时、最终PG/Redis/计费对账、测试设施收尾;错误JSON的统一问题响应和未知字段处理须按契约单列评估。未触碰预生产、真实通道、原客户配置、余额或历史短信重投。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 858c590..26377df 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5455,3 +5455,15 @@ TC-CHANNEL-WORD测试部署验收:应用8e4bc5a;新Tab/API200真实空词库 ### TC-HTTP-REMED-IMPL-20260914 测试环境补验 实际发布f0e8434,标准preflight/prepare/deploy/verify通过,迁移、服务、队列、日志和恢复资产已检查。测试公开文档页三尺寸/检索/示例/复制降级/下载/刷新及真实JSON/401关联ID通过。已登录客户端完整交互、真实发送与计费/Gateway供应商链路未验证,未获专项发送授权;不将隔离和公开页结果写成这几项通过。证据见[最终发布记录](release-20260914-test-http-api.md)。 + + +## HTTP全量真实模拟链路验收(2026-09-14) + +- TC-HTTP-FULL-AUTH:四接口鉴权缺失/错误、时间、nonce、凭据撤销/过期、IP、权限和QPS;查询只读隔离到本轮新应用。 +- TC-HTTP-FULL-PARAM:手机号、正文、客户编号、幂等键及query的类型、长度、重复、日期、cursor、limit边界;异常不得创建短信。 +- TC-HTTP-FULL-SEND:三网、长短信、UTF8转义、同键重放/冲突及5请求并发,PG业务记录、实际CMPP Submit、回执、队列和计费对账;仅虚构号码和既有LGST模拟通道。 +- TC-HTTP-FULL-MO:真实CMPP DELIVER、ACK、PG落库、唯一/歧义归属、分页筛选、公共字段白名单、同租户跨应用和跨租户不可见。 +- TC-HTTP-FULL-HOOK:真实公网HTTPS收件端,验证原始体HMAC、eventId去重、2xx、400终止、429/503/超时重试、次数/退避、人工重试;仅本轮事件。Node all-address lookup必须用真实HTTP连接回归,不能只mock transport。 +- TC-HTTP-FULL-CLOSE:新测试凭据停用、专用应用停止测试、本轮receiver/tunnel/simulator关闭;原配置和历史记录保留,不手工修改余额或清队列。 + +执行结果与限制见 [HTTP全量验收](http-api-full-acceptance-20260914.md),用例存在不表示全部通过。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index a06438e..e811bc0 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4936,3 +4936,8 @@ git diff --check 认证入口纠正后,标准LF预检发现旧源码CRLF差异;逐字节确认后新建CRLF计划20260914T050659-f0e843436c71-48bc2b23,复用同一精确提交有效证据,重新preflight通过。prepare→deploy→独立verify→report完成,测试实际应用f0e843436c715010d3eaec72a5e0c81816a6e5bd,服务正常、verificationWarnings为空;独立恢复备份约496.57MB保留,停止至恢复约13.5秒。 测试实际公开文档/JSON为200,七query/唯一幂等头/未认证401关联ID通过;Edge三尺寸、检索、示例、复制降级、同字节下载和刷新通过,无pageerror。未将公开页验收冒称已登录客户端完整验收,未发送/补发/重投/入队短信,未作真实计费/供应商链路测试。测试系统盘78%、可用22,848,892,928字节,51目录容量清单和上一有效8e4bc5a组件保留;历史资产清理目标未完成。密码仅标准输入传递,不写入文件;原认证/路径/换行失败和等待不并入停机耗时。详见[最终发布记录](release-20260914-test-http-api.md)。预生产未操作,文档收尾提交不改变测试选定应用版本。 + + +## 2026-09-14 真实HTTP全量验收发现的Webhook DNS缺陷 + +测试环境 f0e8434 上,专用模拟应用已产生真实送达回执,但5个Webhook事件均在2次尝试后失败,lastError为 `Invalid IP address: undefined`,受控HTTPS接收端无请求。Node自动地址族选择以 `all=true` 调用自定义lookup,旧实现仍返回单地址三参数,违反回调契约。保留原SSRF解析、私网拒绝及地址固定,只按all选项返回固定地址数组或原单地址。未改变重试、计费、发送与租户规则。真实Node HTTPS连接旧实现失败/修正实现200,本地真实HTTP连接回归及API全量73套783项、类型构建通过;线上修复与Webhook全场景验收尚待标准发布完成。用户已另行授权本修复提交、推送及测试发布,不涉及预生产。完整结果将登记 [HTTP全量验收](http-api-full-acceptance-20260914.md)。 diff --git a/tools/testing/http-cmpp-simulator.mjs b/tools/testing/http-cmpp-simulator.mjs new file mode 100644 index 0000000..73b2e5a --- /dev/null +++ b/tools/testing/http-cmpp-simulator.mjs @@ -0,0 +1,190 @@ +// Real CMPP 2 TCP test peers. No real supplier forwarding exists in this module. +import net from 'node:net'; +import assert from 'node:assert/strict'; + +const z = (n) => Buffer.alloc(n); +const fixed = (s, n) => { + const b = z(n); + b.write(s ?? '', 'ascii'); + return b; +}; +const u32 = (n) => { + const b = z(4); + b.writeUInt32BE(n >>> 0); + return b; +}; +const u64 = (n) => { + const b = z(8); + b.writeBigUInt64BE(BigInt(n)); + return b; +}; +export function packet(type, sequence, body = z(0)) { + return Buffer.concat([u32(body.length + 12), u32(type), u32(sequence), body]); +} +export function parseSubmit(body) { + const count = body[116]; + const end = 117 + 21 * count; + assert(body.length >= end + 9); + return { + total: body[8], + number: body[9], + receipt: body[10], + udhi: body[45], + format: body[46], + source: body.subarray(95, 116).toString('ascii').replace(/\0/g, ''), + phones: Array.from({ length: count }, (_, i) => + body + .subarray(117 + i * 21, 138 + i * 21) + .toString('ascii') + .replace(/\0/g, ''), + ), + content: body.subarray(end + 1, end + 1 + body[end]), + }; +} +export function decodeUcs2(buffer) { + return Buffer.from(buffer).swap16().toString('utf16le'); +} +function framed(socket, onPacket, onError) { + let accumulated = z(0); + socket.on('error', onError); + socket.on('data', (chunk) => { + try { + accumulated = Buffer.concat([accumulated, chunk]); + while (accumulated.length >= 12) { + const length = accumulated.readUInt32BE(0); + assert(length >= 12 && length <= 65536, 'Invalid CMPP frame length'); + if (accumulated.length < length) return; + const value = accumulated.subarray(0, length); + accumulated = accumulated.subarray(length); + onPacket(value.readUInt32BE(4), value.readUInt32BE(8), value.subarray(12)); + } + } catch (error) { + onError(error); + socket.destroy(); + } + }); +} +export async function startSupplier({ host, port, accounts, phoneAllowed, events, receiptStatus = () => 'DELIVRD' }) { + assert.equal(host, '127.0.0.1'); + assert.equal(port, 17900); + const sockets = new Set(); + const connected = new Set(); + const peers = new Map(); + let id = BigInt(Date.now()) * 1000n; + let sequence = 80000; + const server = net.createServer((socket) => { + if (socket.remoteAddress?.replace('::ffff:', '') !== '127.0.0.1') { + socket.destroy(); + return; + } + sockets.add(socket); + let account; + socket.on('close', () => { + sockets.delete(socket); + if (account) connected.delete(account); + }); + framed( + socket, + (type, seq, body) => { + if (type === 1) { + account = body.subarray(0, 6).toString('ascii').replace(/\0/g, ''); + assert(accounts.has(account), 'Unexpected simulator account'); + assert.equal(body[22], 0x20); + socket.write(packet(0x80000001, seq, Buffer.concat([z(17), Buffer.from([0x20])]))); + connected.add(account); + peers.set(account, socket); + events.push({ type: 'connect', account, time: Date.now() }); + } else if (type === 8) socket.write(packet(0x80000008, seq, z(1))); + else if (type === 2) { + socket.write(packet(0x80000002, seq)); + socket.end(); + } else if (type === 4) { + assert(account); + const submit = parseSubmit(body); + assert(submit.phones.every(phoneAllowed), 'Unexpected recipient: simulator stop condition'); + assert(events.filter((x) => x.type === 'submit').length < 160, 'Wire submit cap reached'); + const messageId = ++id; + events.push({ + type: 'submit', + account, + time: Date.now(), + id: String(messageId), + ...submit, + content: submit.content.toString('base64'), + }); + socket.write(packet(0x80000004, seq, Buffer.concat([u64(messageId), z(1)]))); + if (submit.receipt) { + setTimeout(() => { + if (socket.destroyed) return; + const now = new Date(); + const stamp = [ + now.getFullYear() % 100, + now.getMonth() + 1, + now.getDate(), + now.getHours(), + now.getMinutes(), + ] + .map((n) => String(n).padStart(2, '0')) + .join(''); + const report = Buffer.concat([ + u64(messageId), + fixed(receiptStatus(submit), 7), + fixed(stamp, 10), + fixed(stamp, 10), + fixed(submit.phones[0], 21), + u32(sequence), + ]); + const deliver = Buffer.concat([ + u64(messageId), + fixed(submit.source, 21), + z(10), + z(3), + fixed(submit.phones[0], 21), + Buffer.from([1, report.length]), + report, + z(8), + ]); + socket.write(packet(5, ++sequence, deliver)); + }, 150); + } + } else if (type === 0x80000005) + events.push({ type: 'supplierReceiptAck', account, result: body[8], time: Date.now() }); + }, + (error) => { + events.push({ type: 'error', message: error.message }); + }, + ); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, resolve); + }); + return { + connected, + sendUplink(account, phone, dest, text) { + const socket = peers.get(account); + assert(socket && !socket.destroyed); + assert(phoneAllowed(phone)); + const content = Buffer.from(text, 'utf16le').swap16(); + assert(content.length <= 140); + const messageId = ++id; + const body = Buffer.concat([ + u64(messageId), + fixed(dest, 21), + z(10), + z(2), + Buffer.from([8]), + fixed(phone, 21), + Buffer.from([0, content.length]), + content, + z(8), + ]); + socket.write(packet(5, ++sequence, body)); + events.push({ type: 'supplierUplink', account, phone, dest, text, id: String(messageId), time: Date.now() }); + }, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +}