Files
lislgosms/api/src/open-api/open-api.protocol.ts
T
hectorzhao a420d61b23
CSS quality / css-quality (push) Has been cancelled
fix: validate HTTP dates IPv6 URLs and parser errors
2026-09-14 15:15:58 +08:00

104 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, createHmac } from 'node:crypto';
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. */
export function pinnedWebhookLookup(address: string, family: number): LookupFunction {
return (_hostname, options, callback) => {
if (options.all) callback(null, [{ address, family }]);
else callback(null, address, family);
};
}
/** 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')
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
.digest('hex');
}
export function openApiSignature(
secret: string,
method: string,
path: string,
timestamp: string,
nonce: string,
bodyHash: string,
) {
return createHmac('sha256', secret)
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
.digest('hex');
}
export function publicOpenApiFailure(error: unknown) {
if (!(error instanceof HttpException) || error.getStatus() >= 500) {
return { status: 500, code: 'INTERNAL_ERROR', message: 'Internal server error' };
}
const value = error.getResponse();
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const message = object.message ?? value;
return {
status: error.getStatus(),
code: String(object.code ?? 'REQUEST_FAILED'),
message: Array.isArray(message) ? message.join('') : String(message),
};
}
export function webhookJobId(deliveryId: string, attemptNo: number) {
return `webhook-${createHash('sha256').update(deliveryId).digest('hex')}-${attemptNo}`;
}