diff --git a/api/package-lock.json b/api/package-lock.json index 5562ccc..a6bf8fb 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -29,6 +29,7 @@ "tldts": "^7.4.12" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.9.3", "jest": "^30.4.2", @@ -2012,6 +2013,27 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", @@ -2101,6 +2123,31 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -2108,6 +2155,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2173,6 +2227,20 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -2183,6 +2251,27 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/api/package.json b/api/package.json index 8b6723b..f8a4a91 100644 --- a/api/package.json +++ b/api/package.json @@ -38,6 +38,7 @@ "tldts": "^7.4.12" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.9.3", "jest": "^30.4.2", diff --git a/api/src/http-body-limits.spec.ts b/api/src/http-body-limits.spec.ts index 0100a49..d64b25c 100644 --- a/api/src/http-body-limits.spec.ts +++ b/api/src/http-body-limits.spec.ts @@ -1,12 +1,7 @@ import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits'; -const express = require('express') as () => { - use(...args: unknown[]): void; - post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void; - listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null }; -}; -const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown }; -const http = require('node:http') as typeof import('node:http'); +import express from 'express'; +import * as http from 'node:http'; describe('configureHttpBodyParsers', () => { it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => { @@ -17,7 +12,7 @@ describe('configureHttpBodyParsers', () => { expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb'); expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb'); - expect(use).toHaveBeenCalledTimes(1); + expect(use).toHaveBeenCalledTimes(2); expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function)); expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' }); expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true }); @@ -28,12 +23,16 @@ describe('configureHttpBodyParsers', () => { configureHttpBodyParsers({ use: serverApp.use.bind(serverApp), useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) { - serverApp.use(type === 'json' - ? expressModule.json({ limit: options.limit }) - : expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true })); + serverApp.use( + type === 'json' + ? express.json({ limit: options.limit }) + : express.urlencoded({ limit: options.limit, extended: options.extended ?? true }), + ); }, } as never); - serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 })); + serverApp.post('/api/client/send/imports/preview', (request, response) => + response.json({ size: (request as typeof request & { rawBody?: Buffer }).rawBody?.length ?? 0 }), + ); serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true })); const server = await new Promise>((resolve) => { @@ -47,19 +46,49 @@ describe('configureHttpBodyParsers', () => { expect(importResponse.status).toBe(200); expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) }); await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 }); + const oversized = await postJSON(address.port, '/api/openapi/v1/sms/messages', body); + expect(oversized.status).toBe(413); + expect(JSON.parse(oversized.body)).toMatchObject({ code: 'PAYLOAD_TOO_LARGE', status: 413 }); + for (const malformed of ['{"private-marker":', '"private-marker"']) { + const invalid = await postJSON(address.port, '/api/openapi/v1/sms/messages', malformed); + expect(invalid.status).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ code: 'PARAMETER_INVALID', requestId: invalid.requestId }); + expect(invalid.requestId).toMatch(/^req_/); + expect(invalid.contentType).toContain('application/problem+json'); + expect(invalid.body).not.toContain('private-marker'); + } + const ordinary = await postJSON(address.port, '/api/ordinary', '{'); + expect(ordinary.status).toBe(400); + expect(ordinary.requestId).toBeUndefined(); } finally { - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } }); }); function postJSON(port: number, path: string, body: string) { - return new Promise<{ status: number; body: string }>((resolve, reject) => { - const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => { - const chunks: Buffer[] = []; - response.on('data', (chunk: Buffer) => chunks.push(chunk)); - response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); - }); + return new Promise<{ status: number; body: string; requestId?: string; contentType?: string }>((resolve, reject) => { + const request = http.request( + { + hostname: '127.0.0.1', + port, + path, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.once('end', () => + resolve({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + requestId: response.headers['x-request-id'] as string | undefined, + contentType: response.headers['content-type'], + }), + ); + }, + ); request.once('error', reject); request.end(body); }); diff --git a/api/src/http-body-limits.ts b/api/src/http-body-limits.ts index 8208d83..0decb3a 100644 --- a/api/src/http-body-limits.ts +++ b/api/src/http-body-limits.ts @@ -1,11 +1,6 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; - -const express = require('express') as { - json(options: { - limit: string; - verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void; - }): (...args: unknown[]) => unknown; -}; +import { openApiBodyErrorMiddleware } from './open-api/open-api-body-error.middleware'; +import express from 'express'; export const DEFAULT_JSON_BODY_LIMIT = '2mb'; export const IMPORT_JSON_BODY_LIMIT = '25mb'; @@ -14,12 +9,16 @@ export function configureHttpBodyParsers(app: NestExpressApplication) { // Import preview/confirmation temporarily carries the source CSV/TSV in // JSON. Give only these endpoints the larger boundary; keeping ordinary // JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint. - app.use('/api/client/send/imports', express.json({ - limit: IMPORT_JSON_BODY_LIMIT, - verify(request, _response, buffer) { - request.rawBody = buffer; - }, - })); + app.use( + '/api/client/send/imports', + express.json({ + limit: IMPORT_JSON_BODY_LIMIT, + verify(request, _response, buffer) { + (request as typeof request & { rawBody?: Buffer }).rawBody = buffer; + }, + }), + ); app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT }); app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true }); + app.use('/api/openapi/v1/sms', openApiBodyErrorMiddleware); } diff --git a/api/src/open-api/open-api-body-error.middleware.ts b/api/src/open-api/open-api-body-error.middleware.ts new file mode 100644 index 0000000..16aaa46 --- /dev/null +++ b/api/src/open-api/open-api-body-error.middleware.ts @@ -0,0 +1,21 @@ +import { randomUUID } from 'node:crypto'; +import { sendOpenApiProblem, type OpenApiProblemResponse } from './open-api.protocol'; + +/** Mounted after parsers, before routes; never expose parser errors containing raw input. */ +export function openApiBodyErrorMiddleware( + error: unknown, + request: { openApiRequestId?: string }, + response: OpenApiProblemResponse, + next: (error: unknown) => void, +) { + const type = error && typeof error === 'object' && 'type' in error ? error.type : undefined; + const failures: Record = { + 'entity.parse.failed': { status: 400, code: 'PARAMETER_INVALID', message: '请求体必须为有效的JSON对象' }, + 'entity.too.large': { status: 413, code: 'PAYLOAD_TOO_LARGE', message: '请求体超过大小限制' }, + 'charset.unsupported': { status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: '请求体字符集不受支持' }, + 'encoding.unsupported': { status: 415, code: 'UNSUPPORTED_MEDIA_TYPE', message: '请求体编码不受支持' }, + }; + const failure = typeof type === 'string' && Object.hasOwn(failures, type) ? failures[type] : undefined; + if (!failure) return next(error); + sendOpenApiProblem(response, (request.openApiRequestId ??= `req_${randomUUID()}`), failure); +} diff --git a/api/src/open-api/open-api-exception.filter.ts b/api/src/open-api/open-api-exception.filter.ts index 1c569a2..97ed83f 100644 --- a/api/src/open-api/open-api-exception.filter.ts +++ b/api/src/open-api/open-api-exception.filter.ts @@ -1,6 +1,6 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { publicOpenApiFailure } from './open-api.protocol'; +import { publicOpenApiFailure, sendOpenApiProblem } from './open-api.protocol'; import type { OpenApiRequestLike } from './open-api.types'; @Catch() @@ -31,17 +31,6 @@ export class OpenApiExceptionFilter implements ExceptionFilter { .join('\n') : undefined, }); - response.setHeader('X-Request-Id', requestId); - response - .status(failure.status) - .type('application/problem+json') - .send({ - type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`, - title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed', - status: failure.status, - code: failure.code, - detail: failure.message, - requestId, - }); + sendOpenApiProblem(response, requestId, failure); } } diff --git a/api/src/open-api/open-api-input-boundaries.spec.ts b/api/src/open-api/open-api-input-boundaries.spec.ts new file mode 100644 index 0000000..ad3d268 --- /dev/null +++ b/api/src/open-api/open-api-input-boundaries.spec.ts @@ -0,0 +1,78 @@ +import { BadRequestException } from '@nestjs/common'; +import * as dns from 'node:dns/promises'; +import { parseOpenApiDate } from './open-api.protocol'; +import { OpenApiService, resolveWebhookTarget } from './open-api.service'; + +jest.mock('node:dns/promises', () => ({ lookup: jest.fn() })); + +describe('public HTTP input boundaries', () => { + it.each([ + '2026-02-30', + '2025-02-29T00:00:00Z', + '2026-04-31T00:00:00+08:00', + '2026-01-01T24:00:00Z', + '2026-01-01T12:60:00Z', + '2026-01-01T00:00:00+24:00', + '2026-01-01T00:00:00', + '09/14/2026', + '', + '2026-00-01', + '2026-01-00', + ])('rejects invalid ISO calendar/time %s', (value) => { + expect(() => parseOpenApiDate(value)).toThrow(BadRequestException); + }); + it.each([ + ['2024-02-29', '2024-02-29T00:00:00.000Z'], + ['2026-09-14T08:00:00+08:00', '2026-09-14T00:00:00.000Z'], + ['2026-09-14T00:00Z', '2026-09-14T00:00:00.000Z'], + ['2000-02-29T00:00:00.123Z', '2000-02-29T00:00:00.123Z'], + ])('preserves valid calendar dates/timezones %s', (value, expected) => { + expect(parseOpenApiDate(value).toISOString()).toBe(expected); + }); + it('rejects an impossible cursor date before calling PostgreSQL', async () => { + const prisma = { smsUplinkMessage: { findMany: jest.fn() } }; + const service = new OpenApiService(prisma as never, {} as never); + const cursor = Buffer.from(JSON.stringify(['2026-02-30T00:00:00Z', 'id'])).toString('base64url'); + await expect( + service.listUplinks({ config: { uplinkQueryEnabled: true, maxQueryRangeDays: 31, maxPageSize: 100 } } as never, { + cursor, + }), + ).rejects.toMatchObject({ response: { code: 'CURSOR_INVALID' } }); + expect(prisma.smsUplinkMessage.findMany).not.toHaveBeenCalled(); + }); + it.each([ + '::1', + '::', + 'fd00::1', + 'fe80::1', + '::ffff:127.0.0.1', + '::ffff:7f00:1', + '::ffff:192.168.1.1', + '0:0:0:0:0:ffff:0a00:0001', + ])('rejects private IPv6 literal %s without DNS', async (address) => { + const lookup = jest.mocked(dns.lookup); + try { + await expect(resolveWebhookTarget(`https://[${address}]/hook`, true)).rejects.toThrow(BadRequestException); + expect(lookup).not.toHaveBeenCalled(); + } finally { + lookup.mockReset(); + } + }); + it('preserves public IPv6 addresses for TLS URLs and fixed-address connection', async () => { + await expect(resolveWebhookTarget('https://[2606:4700:4700::1111]/hook', true)).resolves.toMatchObject({ + address: '2606:4700:4700::1111', + family: 6, + }); + }); + it('returns a controlled 400 on DNS failure without exposing resolver diagnostics', async () => { + const lookup = jest.mocked(dns.lookup).mockRejectedValueOnce(new Error('ENOTFOUND internal-resolver-detail')); + try { + await expect(resolveWebhookTarget('https://unavailable.invalid/hook', true)).rejects.toMatchObject({ + status: 400, + message: 'Webhook域名未解析到可用地址', + }); + } finally { + lookup.mockReset(); + } + }); +}); diff --git a/api/src/open-api/open-api.controller.ts b/api/src/open-api/open-api.controller.ts index 1aa4aee..e5f4bf2 100644 --- a/api/src/open-api/open-api.controller.ts +++ b/api/src/open-api/open-api.controller.ts @@ -43,6 +43,8 @@ import { @ApiResponse({ status: 403, type: OpenApiProblemDto }) @ApiResponse({ status: 404, type: OpenApiProblemDto }) @ApiResponse({ status: 409, type: OpenApiProblemDto }) +@ApiResponse({ status: 413, type: OpenApiProblemDto }) +@ApiResponse({ status: 415, type: OpenApiProblemDto }) @ApiResponse({ status: 422, type: OpenApiProblemDto }) @ApiResponse({ status: 429, type: OpenApiProblemDto }) @ApiResponse({ status: 500, type: OpenApiProblemDto }) diff --git a/api/src/open-api/open-api.protocol.ts b/api/src/open-api/open-api.protocol.ts index 58df99d..5d7c6bc 100644 --- a/api/src/open-api/open-api.protocol.ts +++ b/api/src/open-api/open-api.protocol.ts @@ -1,5 +1,5 @@ import { createHash, createHmac } from 'node:crypto'; -import { HttpException } from '@nestjs/common'; +import { BadRequestException, HttpException } from '@nestjs/common'; import type { LookupFunction } from 'node:net'; /** Keep the validated address pinned while honoring Node's all-address lookup contract. */ @@ -10,6 +10,60 @@ export function pinnedWebhookLookup(address: string, family: number): LookupFunc }; } +/** Validate calendar components before Date can normalize an impossible day. */ +export function parseOpenApiDate(value: string): Date { + const parts = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2}))?$/.exec( + value, + ); + if (parts) { + const year = Number(parts[1]); + const month = Number(parts[2]); + const day = Number(parts[3]); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const zone = parts[8]; + const validZone = !zone || zone === 'Z' || (Number(zone.slice(1, 3)) < 24 && Number(zone.slice(4)) < 60); + const date = new Date(value); + if ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= days[month - 1] && + Number(parts[4] ?? 0) < 24 && + Number(parts[5] ?? 0) < 60 && + Number(parts[6] ?? 0) < 60 && + validZone && + Number.isFinite(date.getTime()) + ) + return date; + } + throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '时间必须为有效的ISO8601日期或带时区时间' }); +} + +export type OpenApiProblemResponse = { + setHeader(name: string, value: string): void; + status(code: number): { type(value: string): { send(body: unknown): void } }; +}; + +export function sendOpenApiProblem( + response: OpenApiProblemResponse, + requestId: string, + failure: { status: number; code: string; message: string }, +) { + response.setHeader('X-Request-Id', requestId); + response + .status(failure.status) + .type('application/problem+json') + .send({ + type: `https://cmpp-platform.local/problems/${failure.code.toLowerCase()}`, + title: failure.status >= 500 ? 'Internal Server Error' : 'Request failed', + status: failure.status, + code: failure.code, + detail: failure.message, + requestId, + }); +} + /** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */ export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) { return createHash('sha256') diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index 2869223..a7601f9 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 { pinnedWebhookLookup, publicOpenApiFailure, webhookJobId } from './open-api.protocol'; +import { parseOpenApiDate, pinnedWebhookLookup, publicOpenApiFailure, webhookJobId } from './open-api.protocol'; import { automaticDeliveryMode } from './delivery-mode'; export const OPEN_API_WEBHOOK_TRANSPORT = Symbol('open-api-webhook-transport'); @@ -449,8 +449,9 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { if (value !== undefined && typeof value !== 'string') throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '查询参数必须为单个字符串' }); } - const endTime = query.endTime ? new Date(query.endTime) : new Date(); - const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000); + const endTime = query.endTime !== undefined ? parseOpenApiDate(query.endTime) : new Date(); + const startTime = + query.startTime !== undefined ? parseOpenApiDate(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000); if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' }); if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) @@ -901,7 +902,7 @@ async function validateWebhookUrl(value: string, requireHttps: boolean) { return (await resolveWebhookTarget(value, requireHttps)).url.toString(); } -async function resolveWebhookTarget(value: string, requireHttps: boolean) { +export async function resolveWebhookTarget(value: string, requireHttps: boolean) { let url: URL; try { url = new URL(String(value ?? '').trim()); @@ -911,7 +912,13 @@ async function resolveWebhookTarget(value: string, requireHttps: boolean) { if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS'); if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS'); if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码'); - const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true }); + const hostname = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname; + let addresses: Array<{ address: string }>; + try { + addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true }); + } catch { + throw new BadRequestException('Webhook域名未解析到可用地址'); + } if (addresses.some(({ address }) => isPrivateAddress(address))) throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址'); const selected = addresses[0]; @@ -958,7 +965,14 @@ async function postWebhook( } function isPrivateAddress(address: string) { - const normalized = address.replace(/^::ffff:/, ''); + const canonical = isIP(address) === 6 ? new URL(`http://[${address}]`).hostname.slice(1, -1) : address; + const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/i.exec(canonical); + if (mapped) { + const high = parseInt(mapped[1], 16), + low = parseInt(mapped[2], 16); + return isPrivateAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); + } + const normalized = canonical.toLowerCase(); if ( normalized === '::1' || normalized === '::' || @@ -994,7 +1008,7 @@ function decodeCursor(value?: string) { if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string') throw new Error(); const [date, id] = parsed; - const receivedAt = new Date(date); + const receivedAt = parseOpenApiDate(date); if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error(); return { receivedAt, id }; } catch { diff --git a/docs/client-http-api-guide.md b/docs/client-http-api-guide.md index fe758c2..d6c985d 100644 --- a/docs/client-http-api-guide.md +++ b/docs/client-http-api-guide.md @@ -398,6 +398,8 @@ headers["Idempotency-Key"] = "sms-order-20260914-0001" 查询代码没有额外强制“只查最近 N 天”,但历史查询能否返回数据取决于实际留存、当前应用归属和筛选条件。startTime 必须不晚于 endTime,当前时间边界两端均包含。跨窗口同步可按上行 id 去重,避免公共边界重复计入。 +时间接受 `YYYY-MM-DD`(按UTC零点)或带 `Z` / `±HH:mm` 时区的ISO 8601时间;秒可省略,小数秒最多三位。日期必须在日历上真实存在,不接受2月30日、24:00、无时区日期时间或本地化日期格式;空字符串也不是省略参数。游标中的时间执行同样校验。 + ### 7.1 列表与筛选 第一页、空数据及下一页的完整报文见第 11.4~11.5 节。 @@ -609,6 +611,8 @@ def verify_callback(headers, raw_body, webhook_secret, now=None, | 400 | `MOBILE_INVALID` / `CONTENT_REQUIRED` | 检查单个手机号和正文 | | 400 | `IDEMPOTENCY_KEY_INVALID` | 补齐有效幂等键,检查长度与字符 | | 400 | `TIME_RANGE_INVALID` / `TIME_RANGE_TOO_LARGE` | 检查时间格式、先后顺序和跨度 | +| 413 | `PAYLOAD_TOO_LARGE` | JSON请求体超过2 MiB限制,减少内容后再提交 | +| 415 | `UNSUPPORTED_MEDIA_TYPE` | 检查请求体编码及字符集,使用UTF-8 JSON | | 400 | `CURSOR_INVALID` | 使用平台返回的 cursor,保持筛选条件一致 | | 401 | `AUTH_HEADERS_MISSING` | 补齐四个鉴权头 | | 401 | `CREDENTIAL_INVALID` | 核对应用、Access Key、有效期和吊销状态 | diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index ff4a4a3..1215543 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2283,3 +2283,8 @@ ### 2026-09-14 HTTP回调传输兼容验收补充 Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后的固定DNS地址必须同时符合lookup单地址和all-address回调契约,不得通过重新解析或取消私网限制解决投递问题。完整模拟链路验收及限制见 [HTTP全量验收](http-api-full-acceptance-20260914.md)。 + + +### HTTP公共参数错误边界补充(2026-09-14) + +上行日期须为有效ISO8601日历日期/带时区时间,query与cursor一致;IPv6和IPv4-mapped地址继续执行私网限制;畸形/超限/不支持编码的公开HTTP请求体返回稳定400/413/415及关联ID,不回显输入。详见HTTP整改方案最新修复节。 diff --git a/docs/http-api-assessment-20260910.md b/docs/http-api-assessment-20260910.md index 59975a1..3480ce4 100644 --- a/docs/http-api-assessment-20260910.md +++ b/docs/http-api-assessment-20260910.md @@ -291,3 +291,10 @@ R03不能仅通过“增加重试”关闭。后续详细设计至少说明: ## 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)。 + + +## 2026-09-14 HTTP全量验收后续修复与授权 + +用户已授权本轮全部已发现Bug的修复、复测、提交/推送、测试部署,以及仅测试机专用回调域名的临时hosts映射;映射有30分钟独立精确移除timer,验收结束主动移除。无默认网关/DNS服务/OpenWrt或预生产变更。12个既有本轮回调已真实HTTPS送达;200/204成功、400/302终止、429/503/超时重试、持续503上限及人工重试保护均通过,HMAC、eventId与60秒退避一致。 + +HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转稳定400;同时规范化IPv4-mapped IPv6后复用私网校验,避免修复引入私网绕过。B03:先校验ISO8601年月日/闰年/时分秒/时区,拒绝Date自动归一化;覆盖query与cursor,不改变查询跨度和归属。B04:只对/api/openapi/v1/sms的body-parser已知错误输出统一problem+json和X-Request-Id,400/413/415正文固定,不泄露原始输入;非公开路径和未知错误继续原处理链。无迁移、短信链路、计费或权限规则变更。线上边界复测及最终收尾待新候选发布。 diff --git a/docs/http-api-full-acceptance-20260914.md b/docs/http-api-full-acceptance-20260914.md index fadf49a..234bbb7 100644 --- a/docs/http-api-full-acceptance-20260914.md +++ b/docs/http-api-full-acceptance-20260914.md @@ -1,11 +1,50 @@ # HTTP接口全量真实模拟验收(2026-09-14) -状态:执行中,不表示全部通过。环境test,初始应用版本f0e843436c715010d3eaec72a5e0c81816a6e5bd;本轮专用标签bdd774bf。 +更新:2026-09-14T06:36:14.050Z。状态:**Webhook真实成功及异常重试已通过;三个参数边界修复正在回归和发布,最终收尾未完成。** -已覆盖正常短信三网、长短信、UTF8转义、鉴权/参数错误、上行分页筛选与字段投影、同租户跨应用/跨租户隔离、同键并发只产生一次Submit。测试证据位于 `%TEMP%/cmpp-http-full-20260914/`,不含鉴权秘密。 +## 环境与交付 -真实缺陷:Webhook在Node自动地址族选择时因lookup回调格式错误,未到HTTP接收端就报Invalid IP address: undefined。最小修复保持SSRF地址校验不变;API 73套783项及构建通过,已授权提交/推送/测试发布,发布及回调复验未完成。 +测试环境100.93.204.60:12026;初始版本f0e843436c715010d3eaec72a5e0c81816a6e5bd,当前部署及实际Git远端为92b112cc6e29a14d842e040a9d469fd717a95f96。仅测试环境,无预生产操作。现有metrics、发布工具、其他文档草稿保留,提交只含本轮代码/工具及文档精确追加。 -共享模拟接入号造成3条上行ambiguous,公开接口正确隐藏;独立接入号后的3条上行已正确匹配。首条正常请求因缺少批准模板返回422,配置测试模板后新请求送达;首个过期凭据受本机/服务器约4秒时差影响,超过服务器期限重测401。保留原结果,不计为服务端缺陷。 +## 已执行 -待完成:标准发布、Webhook成功和失败重试/超时、最终PG/Redis/计费对账、测试设施收尾;错误JSON的统一问题响应和未知字段处理须按契约单列评估。未触碰预生产、真实通道、原客户配置、余额或历史短信重投。 +共记录140项HTTP请求/业务断言,含失败原记录和测试前置问题,不将140当全部通过数。覆盖鉴权、nonce、过期/撤销、IP、开关、QPS、正文及查询边界、并发幂等、重复客户编号、三网、长短信、UTF8转义、上行筛选/分页/投影、歧义隔离、同企业跨应用/跨企业隔离和Webhook URL校验。 + +- 9条真实业务短信:8送达、1失败;成功10计费单位,净扣3250内部金额单位,余额1848101→1844851。失败短信在2条LGST模拟通道尝试后退款;冻结/解冻及最终扣退账相符。12个实际CMPP Submit报文(含长短信分段和失败短信的备用模拟路由),未连接真实运营商。 +- 6条真实CMPP上行:共享接入号3条ambiguous正确隐藏;独立接入号3条matched,可分页/查询/筛选,详情无通道/供应商/内部匹配字段,真实下游ACK后delivered。 +- 9条HTTP发送待办dispatched、10条Gateway待办published;未直接修改Redis、余额或历史短信。 +- 本地工作区API73套783项/构建通过;精确发布候选73套782项、类型、格式、lint通过。数量差来自其他会话受保护的metrics测试,不夹带。无前端/Gateway修改,未重复无关门禁。 + +## 真实问题 + +| 编号 | 结果 | 状态 | +|---|---|---| +| HTTP-FULL-B01 | Node all=true lookup旧回调格式导致Invalid IP address: undefined,初始11个Webhook在2次后失败且收件端无请求 | 最小修复已提交/推送/标准部署;保留SSRF检查,真实本机连接回归通过;公网业务成功仍受下述DNS路径阻塞 | +| HTTP-FULL-B02 | https://[::1]/hook配置返回500,旧配置未改变;URL.hostname含方括号进入DNS lookup | 待修,未绕过私网保护 | +| HTTP-FULL-B03 | 2026-02-30被Date自动换算,查询返回200 | 待修:应严格验证日历日期 | +| HTTP-FULL-B04 | 畸形JSON/非对象JSON在全局body-parser阶段返回普通400,无公开problem code/X-Request-Id | 待修:统一入口错误契约 | + +未知channelId字段的400预期与现有未禁止附加字段的契约不一致,send关闭时实际403且无发送,登记为契约观察而非确认缺陷。首条正常请求422是缺少批准模板的测试前置问题;首个到期凭据200受本机/服务器约4秒时差影响,超过服务器期限后重测401。原结果全部保留。 + +## Webhook网络阻塞与待完成 + +实际12个本轮回调事件(9回执、3上行)。修复后仅重试本轮11个旧失败事件,首次2秒超时;单事件10秒诊断随后报TLS建连前断开。测试机DNS把临时Cloudflare地址解析为198.18.1.78,备用webhook.site解析为198.18.1.79,两者普通HTTPS均约5秒后TLS断开。本机同域名正常200;测试机只读探针固定到本机解析的104.16.231.132/104.16.230.132且保留原TLS主机校验,分别2232/313ms返回200。不能把网络失败继续归因于已修复lookup格式,也不能宣称回调业务验收通过。 + +已提出待确认方案:只在测试机/etc/hosts临时加入104.16.230.132与本轮唯一域名映射,30分钟独立自动移除,测试后精确移除;**目前未授权、未执行**。默认网关、系统DNS及OpenWrt保持不变。备用WebHook.site一小时过期收件端已创建后删除(204),未收到业务事件。服务操作参考[官方Token文档](https://docs.webhook.site/api/tokens.html)。 + +待验证:真实收件HMAC/eventId、200/204 ACK、400终止、429/503/超时自动退避/上限、302不跟随、人工重试及完成后的专用测试设施停用。全共享环境的DB/Redis停机及进程崩溃注入未执行;不以隔离测试代替。暂保留本轮应用/凭据和模拟接收进程用于继续验收,未删除任何历史记录。 + +## 标准发布与容量 + +计划20260914T060412-92b112cc6e29-35305dbd;preflight、prepare、deploy含verify通过,verificationWarnings为空。独立恢复点 `/var/backups/cmpp-platform/20260914T060412-92b112cc6e29-35305dbd-attempt-1789366449273005177`,PG/Redis/配置/应用归档摘要与可读性通过,未做恢复演练。候选准备40.3秒、备份35.0秒、停止2.8秒、启动5.5秒、验证4.1秒;本地候选测试146.085秒(依赖安装另计),业务验收及网络/等待时间不算停机。 + +测试机无独立数据盘,当前应用、发布目录、备份均落/dev/sda2。可用22,837,911,552→21,341,405,184字节;发布目录新增994,070,528字节,备份新增496,861,184字节,当前应用新增36,864字节。当前版本92b112c,上一有效f0e8434及本次previous-api-dist/previous-api-node_modules/previous-source在本次/前次发布目录保留;逐目录清单见capacity-before/after.json。缓存/日志独立盘点,未擅自清理历史资产,容量治理未完成。 + +## 证据 + +本机 `%TEMP%/cmpp-http-full-20260914/`:results.json、cases.md、baseline.json、pre-fix-reconcile.json、negative-reconcile.json、mo-audit.json、remote-https.json、fallback-network.json、public-ip-probe.json、capacity-before/after.json、lookup-tests.log/api-full.log/api-build.log。发布证据 `.local-data/releases/20260914T060412-92b112cc6e29-35305dbd/`。不保存凭据、Cookie、回调密钥;对话工具中的受控密码提示不写入Git。 + + +## 后续进展(2026-09-14T07:11:33.208Z) + +用户已授权并执行唯一域名hosts临时映射,精确移除兜底timer已启用;上文网络阻塞/未授权描述是原阶段记录,现已解除。12个本轮历史失败回调真实送达,200/204、400/302、429/503/超时、持续503上限,以及人工重试成功/拒绝已成功重试/跨应用404均通过。HMAC、eventId、原始体摘要和至少60秒退避一致。B02/B03/B04最小修复及55项定向回归已通过,等待精确候选全量门禁与测试环境复验。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 26377df..7fed673 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5467,3 +5467,10 @@ TC-CHANNEL-WORD测试部署验收:应用8e4bc5a;新Tab/API200真实空词库 - TC-HTTP-FULL-CLOSE:新测试凭据停用、专用应用停止测试、本轮receiver/tunnel/simulator关闭;原配置和历史记录保留,不手工修改余额或清队列。 执行结果与限制见 [HTTP全量验收](http-api-full-acceptance-20260914.md),用例存在不表示全部通过。 + + +## 2026-09-14 HTTP全量验收后续修复与授权 + +用户已授权本轮全部已发现Bug的修复、复测、提交/推送、测试部署,以及仅测试机专用回调域名的临时hosts映射;映射有30分钟独立精确移除timer,验收结束主动移除。无默认网关/DNS服务/OpenWrt或预生产变更。12个既有本轮回调已真实HTTPS送达;200/204成功、400/302终止、429/503/超时重试、持续503上限及人工重试保护均通过,HMAC、eventId与60秒退避一致。 + +HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转稳定400;同时规范化IPv4-mapped IPv6后复用私网校验,避免修复引入私网绕过。B03:先校验ISO8601年月日/闰年/时分秒/时区,拒绝Date自动归一化;覆盖query与cursor,不改变查询跨度和归属。B04:只对/api/openapi/v1/sms的body-parser已知错误输出统一problem+json和X-Request-Id,400/413/415正文固定,不泄露原始输入;非公开路径和未知错误继续原处理链。无迁移、短信链路、计费或权限规则变更。线上边界复测及最终收尾待新候选发布。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index e811bc0..41e20e6 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -4941,3 +4941,10 @@ git diff --check ## 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)。 + + +## 2026-09-14 HTTP全量验收后续修复与授权 + +用户已授权本轮全部已发现Bug的修复、复测、提交/推送、测试部署,以及仅测试机专用回调域名的临时hosts映射;映射有30分钟独立精确移除timer,验收结束主动移除。无默认网关/DNS服务/OpenWrt或预生产变更。12个既有本轮回调已真实HTTPS送达;200/204成功、400/302终止、429/503/超时重试、持续503上限及人工重试保护均通过,HMAC、eventId与60秒退避一致。 + +HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转稳定400;同时规范化IPv4-mapped IPv6后复用私网校验,避免修复引入私网绕过。B03:先校验ISO8601年月日/闰年/时分秒/时区,拒绝Date自动归一化;覆盖query与cursor,不改变查询跨度和归属。B04:只对/api/openapi/v1/sms的body-parser已知错误输出统一problem+json和X-Request-Id,400/413/415正文固定,不泄露原始输入;非公开路径和未知错误继续原处理链。无迁移、短信链路、计费或权限规则变更。线上边界复测及最终收尾待新候选发布。