fix: validate HTTP dates IPv6 URLs and parser errors
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 15:15:58 +08:00
parent 92b112cc6e
commit a420d61b23
16 changed files with 403 additions and 58 deletions
+89
View File
@@ -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",
+1
View File
@@ -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",
+48 -19
View File
@@ -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<ReturnType<typeof serverApp.listen>>((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<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await new Promise<void>((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);
});
+12 -13
View File
@@ -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);
}
@@ -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<string, { status: number; code: string; message: string }> = {
'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);
}
+2 -13
View File
@@ -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);
}
}
@@ -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();
}
});
});
+2
View File
@@ -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 })
+55 -1
View File
@@ -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')
+21 -7
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 { 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 {