feat: remediate HTTP API reliability and developer documentation
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -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:/, '');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user