feat: add Fail2ban security detection console

This commit is contained in:
hectorzhao
2026-08-14 10:58:18 +08:00
parent b78faa1aa2
commit d30d9ea4d0
45 changed files with 1967 additions and 18 deletions
+18 -2
View File
@@ -5,12 +5,13 @@ import IORedis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { decryptSecret } from './open-api.crypto';
import type { OpenApiRequestLike } from './open-api.types';
import { SecurityDetectionService } from '../security-detection/security-detection.service';
@Injectable()
export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
private redis?: IORedis;
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<OpenApiRequestLike>();
@@ -19,9 +20,11 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
const nonce = header(request, 'x-nonce');
const suppliedSignature = header(request, 'x-signature')?.replace(/^sha256=/i, '');
if (!accessKey || !timestampText || !nonce || !suppliedSignature) {
await this.recordFailure('http_signature_failure', request, undefined, 'AUTH_HEADERS_MISSING');
throw new UnauthorizedException({ code: 'AUTH_HEADERS_MISSING', message: '缺少HTTP接口鉴权请求头' });
}
if (!/^[A-Za-z0-9_-]{8,128}$/.test(nonce)) {
await this.recordFailure('http_signature_failure', request, accessKey, 'NONCE_INVALID');
throw new UnauthorizedException({ code: 'NONCE_INVALID', message: 'X-Nonce 格式非法' });
}
const credential = await this.prisma.httpApiCredential.findUnique({
@@ -29,6 +32,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
include: { application: { include: { httpConfig: true, httpIpAllowlist: true } } },
});
if (!credential || credential.status !== 'active' || (credential.expiresAt && credential.expiresAt <= new Date())) {
await this.recordFailure('http_invalid_api_key', request, accessKey, 'CREDENTIAL_INVALID');
throw new UnauthorizedException({ code: 'CREDENTIAL_INVALID', message: '访问凭据无效或已失效' });
}
const config = credential.application.httpConfig;
@@ -37,6 +41,7 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
}
const timestamp = Number(timestampText);
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);
@@ -50,11 +55,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
const expectedBuffer = Buffer.from(expected, 'hex');
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');
if (nonceAccepted !== 'OK') {
await this.recordFailure('http_replay_attempt', request, accessKey, 'NONCE_REPLAYED');
throw new UnauthorizedException({ code: 'NONCE_REPLAYED', message: 'X-Nonce 已使用' });
}
const second = Math.floor(Date.now() / 1000);
@@ -81,6 +88,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
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) {
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);
}
}
function header(request: OpenApiRequestLike, name: string) {
@@ -90,7 +104,9 @@ function header(request: OpenApiRequestLike, name: string) {
function requestIp(request: OpenApiRequestLike) {
const forwarded = header(request, 'x-forwarded-for')?.split(',')[0]?.trim();
return (forwarded ?? request.socket?.remoteAddress)?.replace(/^::ffff:/, '');
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));
return (remoteAddress && trustedProxies.has(remoteAddress) ? forwarded : remoteAddress)?.replace(/^::ffff:/, '');
}
function ipMatches(ip: string, rule: string) {
+2 -1
View File
@@ -6,9 +6,10 @@ import { ClientOpenApiController } from './client-open-api.controller';
import { OpenApiAuthGuard } from './open-api-auth.guard';
import { OpenApiController } from './open-api.controller';
import { OpenApiService } from './open-api.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({
imports: [PrismaModule, forwardRef(() => SendChainModule)],
imports: [PrismaModule, forwardRef(() => SendChainModule), SecurityDetectionModule],
controllers: [OpenApiController, AdminOpenApiController, ClientOpenApiController],
providers: [OpenApiService, OpenApiAuthGuard],
exports: [OpenApiService],