fix: honor Node all-address DNS lookup for HTTP webhooks
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 14:03:00 +08:00
parent 40c8e279f0
commit 92b112cc6e
9 changed files with 281 additions and 2 deletions
@@ -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<void>((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<string>((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<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
});
+9
View File
@@ -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) {
+2 -2
View File
@@ -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[] = [];
@@ -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)。
+5
View File
@@ -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)。
+11
View File
@@ -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的统一问题响应和未知字段处理须按契约单列评估。未触碰预生产、真实通道、原客户配置、余额或历史短信重投。
+12
View File
@@ -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),用例存在不表示全部通过。
+5
View File
@@ -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)。
+190
View File
@@ -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));
},
};
}