fix: validate HTTP dates IPv6 URLs and parser errors
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
Generated
+89
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user