feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 12:48:10 +08:00
parent d13ca0713a
commit f0e843436c
33 changed files with 3332 additions and 452 deletions
+101 -15
View File
@@ -1,19 +1,61 @@
import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable, OnModuleDestroy, UnauthorizedException } from '@nestjs/common';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import {
CanActivate,
ExecutionContext,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
OnModuleDestroy,
Optional,
UnauthorizedException,
} from '@nestjs/common';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net';
import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol';
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis;
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
@Optional() private readonly protocolLogs?: ProtocolLogsService,
) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
request.openApiRequestId = 'req_' + randomUUID();
context
.switchToHttp()
.getResponse<{ setHeader: (name: string, value: string) => void }>()
.setHeader('X-Request-Id', request.openApiRequestId);
const startedAt = Date.now();
try {
return await this.authenticate(context);
} catch (error) {
const failure = publicOpenApiFailure(error);
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
eventType: 'authentication',
status: 'failed',
requestId: request.openApiRequestId,
resultCode: failure.code,
durationMs: Date.now() - startedAt,
detail: { method: request.method, path: '/api/openapi/v1/sms' },
});
throw error;
}
}
private async authenticate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
const accessKey = header(request, 'x-app-key');
const timestampText = header(request, 'x-timestamp');
@@ -40,26 +82,46 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
throw new ForbiddenException({ code: 'HTTP_API_DISABLED', message: '该企业应用未开通HTTP接口' });
}
const timestamp = Number(timestampText);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000) {
if (
!Number.isFinite(timestamp) ||
Math.abs(Date.now() - timestamp * 1000) > config.timestampToleranceSeconds * 1000
) {
await this.recordFailure('http_signature_failure', request, accessKey, 'TIMESTAMP_EXPIRED');
throw new UnauthorizedException({ code: 'TIMESTAMP_EXPIRED', message: '请求时间戳已过期' });
}
const sourceIp = requestIp(request);
if (credential.application.httpIpAllowlist.length > 0 && (!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))) {
if (
credential.application.httpIpAllowlist.length > 0 &&
(!sourceIp || !credential.application.httpIpAllowlist.some((item) => ipMatches(sourceIp, item.ipCidr)))
) {
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
}
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
const bodyHash = createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(request.body ?? {}))).digest('hex');
const signatureSource = [request.method.toUpperCase(), path, timestampText, nonce, bodyHash].join('\n');
const expected = createHmac('sha256', decryptSecret(credential.secretEncrypted)).update(signatureSource).digest('hex');
const bodyHash = openApiBodyHash(request.rawBody, request.body);
const expected = openApiSignature(
decryptSecret(credential.secretEncrypted),
request.method,
path,
timestampText,
nonce,
bodyHash,
);
const expectedBuffer = Buffer.from(expected, 'hex');
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature) ? Buffer.from(suppliedSignature, 'hex') : Buffer.alloc(0);
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
? Buffer.from(suppliedSignature, 'hex')
: Buffer.alloc(0);
if (expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'SIGNATURE_INVALID');
throw new UnauthorizedException({ code: 'SIGNATURE_INVALID', message: '请求签名校验失败' });
}
const redis = this.getRedis();
const nonceAccepted = await redis.set(`openapi:nonce:${credential.id}:${nonce}`, '1', 'EX', config.timestampToleranceSeconds * 2, 'NX');
const nonceAccepted = await redis.set(
`openapi:nonce:${credential.id}:${nonce}`,
'1',
'EX',
config.timestampToleranceSeconds * 2,
'NX',
);
if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
@@ -78,22 +140,41 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
accessKey,
sourceIp,
};
await this.prisma.httpApiCredential.update({ where: { id: credential.id }, data: { lastUsedAt: new Date(), lastUsedIp: sourceIp } });
await this.prisma.httpApiCredential.update({
where: { id: credential.id },
data: { lastUsedAt: new Date(), lastUsedIp: sourceIp },
});
return true;
}
onModuleDestroy() { this.redis?.disconnect(); }
onModuleDestroy() {
this.redis?.disconnect();
}
private getRedis() {
this.redis ??= new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', { maxRetriesPerRequest: 1 });
return this.redis;
}
private async recordFailure(ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt', request: OpenApiRequestLike, account: string | undefined, resultCode: string) {
private async recordFailure(
ruleCode: 'http_invalid_api_key' | 'http_signature_failure' | 'http_replay_attempt',
request: OpenApiRequestLike,
account: string | undefined,
resultCode: string,
) {
const sourceIp = requestIp(request);
if (!sourceIp) return;
// 检测记录失败不能改变原鉴权响应,避免安全辅助链路放大为业务可用性事故。
await this.security.recordEvent({ ruleCode, sourceIp, account, resultCode, protocol: 'http', path: (request.originalUrl ?? request.url ?? '').split('?')[0] }).catch(() => undefined);
await this.security
.recordEvent({
ruleCode,
sourceIp,
account,
resultCode,
protocol: 'http',
path: (request.originalUrl ?? request.url ?? '').split('?')[0],
})
.catch(() => undefined);
}
}
@@ -105,7 +186,12 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
const remoteAddress = request.socket?.remoteAddress?.replace(/^::ffff:/, '');
const trustedProxies = new Set((process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1').split(',').map((item) => item.trim()).filter(Boolean));
const trustedProxies = new Set(
(process.env.TRUSTED_PROXY_IPS ?? '127.0.0.1,::1')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
);
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
}