@kanadi/core
Version:
Multi-Layer CAPTCHA Framework with customizable validators and challenge bundles
315 lines (268 loc) • 8.55 kB
text/typescript
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";
()
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;
}
}