UNPKG

@kanadi/core

Version:

Multi-Layer CAPTCHA Framework with customizable validators and challenge bundles

315 lines (268 loc) 8.55 kB
import { Injectable } from "@nestjs/common"; import type { Redis } from "ioredis"; import { sql } from "bun"; import { dbService } from "../models/database.service"; import type { IBanRule, BanContext, BanCheckResult, BanDecision, } from "./types"; import { createLogger } from "../utils/logger"; import { isIPInCIDR } from "./ip-utils"; @Injectable() export class BanRuleEngine { private readonly logger = createLogger(BanRuleEngine.name); private rules: Map<string, IBanRule> = new Map(); private redis: Redis; constructor() { this.redis = dbService.getRedis(); } registerRules(rules: IBanRule[]): void { for (const rule of rules) { const metadata = Reflect.getMetadata("ban:metadata", rule.constructor); if (metadata && metadata.enabled) { this.rules.set(metadata.id, rule); this.logger.log(`Registered ban rule: ${metadata.name} (priority: ${metadata.priority})`); } } } async checkBan(context: BanContext): Promise<BanCheckResult | null> { const cacheResult = await this.checkCache(context); if (cacheResult) { this.logger.log(`Cache hit for ban check: ${cacheResult.ruleId}`); return cacheResult; } const manualBan = await this.checkManualBans(context); if (manualBan) { this.logger.log(`Manual ban found: ${manualBan.ruleId}`); return manualBan; } const sortedRules = Array.from(this.rules.entries()) .map(([id, rule]) => ({ id, rule, metadata: Reflect.getMetadata("ban:metadata", rule.constructor), })) .filter(({ rule }) => rule.canExecute(context)) .sort((a, b) => b.metadata.priority - a.metadata.priority); for (const { rule, metadata } of sortedRules) { const result = await rule.check(context); if (result.decision !== "ALLOW") { this.logger.log(`Ban rule triggered: ${metadata.name} - ${result.decision}`); if (result.shouldCache) { await this.cacheResult(context, result); } await this.persistBan(context, result); return result; } } return null; } private async checkManualBans(context: BanContext): Promise<BanCheckResult | null> { const entities = [ { type: "ip", id: context.ip }, { type: "user", id: context.userId }, { type: "device", id: context.deviceId }, { type: "fingerprint", id: context.fingerprint }, ].filter((e) => e.id); for (const entity of entities) { if (entity.type === "ip" && entity.id) { const allIpBans = await sql` SELECT * FROM kanadi_bans WHERE entity_type = 'ip' AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY created_at DESC `; for (const ban of allIpBans) { const bannedIp = (ban as any).entity_id; const isMatch = bannedIp.includes("/") ? isIPInCIDR(entity.id, bannedIp) : bannedIp === entity.id; if (isMatch) { const evidence = (ban as any).evidence; return { decision: (ban as any).decision, ruleId: (ban as any).rule_id, ruleName: "Manual Ban", confidence: (ban as any).confidence || 1.0, reason: (ban as any).reason || "Manually banned", evidence: typeof evidence === "string" ? JSON.parse(evidence) : (evidence || {}), expiresAt: (ban as any).expires_at ? new Date((ban as any).expires_at) : undefined, shouldCache: true, }; } } } else if (entity.id) { const dbBan = await sql` SELECT * FROM kanadi_bans WHERE entity_type = ${entity.type} AND entity_id = ${entity.id} AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY created_at DESC LIMIT 1 `; if (dbBan.length > 0) { const ban = dbBan[0] as any; const evidence = ban.evidence; return { decision: ban.decision, ruleId: ban.rule_id, ruleName: "Manual Ban", confidence: ban.confidence || 1.0, reason: ban.reason || "Manually banned", evidence: typeof evidence === "string" ? JSON.parse(evidence) : (evidence || {}), expiresAt: ban.expires_at ? new Date(ban.expires_at) : undefined, shouldCache: true, }; } } } return null; } private async checkCache(context: BanContext): Promise<BanCheckResult | null> { const keys = this.generateCacheKeys(context); for (const key of keys) { const cached = await this.redis.get(key); if (cached) { return JSON.parse(cached) as BanCheckResult; } } return null; } private async cacheResult( context: BanContext, result: BanCheckResult, ): Promise<void> { const keys = this.generateCacheKeys(context); const ttl = result.expiresAt ? Math.floor((result.expiresAt.getTime() - Date.now()) / 1000) : 3600; if (ttl <= 0) return; const pipeline = this.redis.pipeline(); for (const key of keys) { pipeline.setex(key, ttl, JSON.stringify(result)); } await pipeline.exec(); this.logger.log(`Cached ban result with TTL ${ttl}s for keys: ${keys.join(", ")}`); } private generateCacheKeys(context: BanContext): string[] { const keys: string[] = []; if (context.ip) keys.push(`ban:ip:${context.ip}`); if (context.userId) keys.push(`ban:user:${context.userId}`); if (context.deviceId) keys.push(`ban:device:${context.deviceId}`); if (context.fingerprint) keys.push(`ban:fingerprint:${context.fingerprint}`); return keys; } private async persistBan( context: BanContext, result: BanCheckResult, ): Promise<void> { const entities = [ { type: "ip", id: context.ip }, { type: "user", id: context.userId }, { type: "device", id: context.deviceId }, { type: "fingerprint", id: context.fingerprint }, ].filter((e) => e.id); for (const entity of entities) { try { await sql` INSERT INTO kanadi_bans ( entity_type, entity_id, rule_id, decision, reason, confidence, evidence, expires_at ) VALUES ( ${entity.type}, ${entity.id}, ${result.ruleId}, ${result.decision}, ${result.reason}, ${result.confidence}, ${JSON.stringify(result.evidence)}, ${result.expiresAt?.toISOString() || null} ) `; } catch (error) { this.logger.error(`Failed to persist ban for ${entity.type}:${entity.id}`, error); } } } async removeBan(entityType: string, entityId: string): Promise<void> { await sql` DELETE FROM kanadi_bans WHERE entity_type = ${entityType} AND entity_id = ${entityId} AND expires_at > CURRENT_TIMESTAMP OR expires_at IS NULL `; const key = `ban:${entityType}:${entityId}`; await this.redis.del(key); this.logger.log(`Removed ban for ${entityType}:${entityId}`); } async getBanHistory( entityType: string, entityId: string, limit: number = 50, ) { return await sql` SELECT * FROM kanadi_bans WHERE entity_type = ${entityType} AND entity_id = ${entityId} ORDER BY created_at DESC LIMIT ${limit} `; } async getActiveBans(limit: number = 100) { return await sql` SELECT * FROM kanadi_bans WHERE expires_at > CURRENT_TIMESTAMP OR expires_at IS NULL ORDER BY created_at DESC LIMIT ${limit} `; } async getStats(): Promise<{ total: number; active: number; byType: Record<string, number>; byDecision: Record<string, number>; }> { const totalResult = await sql`SELECT COUNT(*) as count FROM kanadi_bans`; const activeResult = await sql` SELECT COUNT(*) as count FROM kanadi_bans WHERE expires_at > CURRENT_TIMESTAMP OR expires_at IS NULL `; const byTypeResult = await sql` SELECT entity_type, COUNT(*) as count FROM kanadi_bans WHERE expires_at > CURRENT_TIMESTAMP OR expires_at IS NULL GROUP BY entity_type `; const byDecisionResult = await sql` SELECT decision, COUNT(*) as count FROM kanadi_bans WHERE expires_at > CURRENT_TIMESTAMP OR expires_at IS NULL GROUP BY decision `; const byType: Record<string, number> = {}; for (const row of byTypeResult) { byType[(row as any).entity_type] = Number((row as any).count || 0); } const byDecision: Record<string, number> = {}; for (const row of byDecisionResult) { byDecision[(row as any).decision] = Number((row as any).count || 0); } return { total: Number(totalResult[0]?.count || 0), active: Number(activeResult[0]?.count || 0), byType, byDecision, }; } async expireBans(): Promise<number> { const result = await sql` DELETE FROM kanadi_bans WHERE expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP RETURNING id `; if (result.length > 0) { this.logger.log(`Expired ${result.length} bans`); } return result.length; } }