UNPKG

@kanadi/core

Version:

Multi-Layer CAPTCHA Framework with customizable validators and challenge bundles

312 lines (270 loc) 7.62 kB
import { sql } from "bun"; import type { Redis } from "ioredis"; import { dbService } from "../models/database.service"; import { parseCIDR, isIPInCIDR, fetchASNPrefixes } from "./ip-utils"; import { createLogger } from "../utils/logger"; export interface BanOptions { reason: string; durationMinutes?: number; evidence?: Record<string, any>; } export interface BanInfo { entity_type: string; entity_id: string; rule_id: string; decision: string; reason: string; confidence: number; evidence?: any; expires_at?: Date; created_at: Date; updated_at: Date; } export class BanClient { private readonly logger = createLogger(BanClient.name); private redis: Redis; constructor() { this.redis = dbService.getRedis(); } async banIP(ip: string, options: BanOptions): Promise<void> { if (ip.includes("/")) { const { network, prefix } = parseCIDR(ip); await this.createBan("ip", ip, { ...options, evidence: { ...options.evidence, cidr: true, network, prefix, }, }); } else { await this.createBan("ip", ip, options); } } async banASN(asn: string, options: BanOptions): Promise<{ banned: number; prefixes: string[] }> { this.logger.log(`Fetching IP prefixes for ${asn}...`); const prefixes = await fetchASNPrefixes(asn); this.logger.log(`Found ${prefixes.length} IPv4 prefixes for ${asn}`); const bannedPrefixes: string[] = []; for (const prefix of prefixes) { const cidr = prefix.prefix; await this.banIP(cidr, { ...options, reason: `${options.reason} (ASN: ${asn})`, evidence: { ...options.evidence, asn, asnName: prefix.name, asnDescription: prefix.description, prefix: cidr, }, }); bannedPrefixes.push(cidr); this.logger.log(`Banned ${cidr}`); } return { banned: bannedPrefixes.length, prefixes: bannedPrefixes, }; } async banDevice(deviceId: string, options: BanOptions): Promise<void> { await this.createBan("device", deviceId, options); } async banUser(userId: string, options: BanOptions): Promise<void> { await this.createBan("user", userId, options); } async banFingerprint(fingerprint: string, options: BanOptions): Promise<void> { await this.createBan("fingerprint", fingerprint, options); } async banSession(sessionId: string, options: BanOptions): Promise<void> { const device = await sql` SELECT d.*, u.id as user_id FROM kanadi_devices d LEFT JOIN kanadi_users u ON d.user_id = u.id WHERE d.session_id = ${sessionId} LIMIT 1 `; if (device.length === 0) { throw new Error(`No device found for session ${sessionId}`); } const deviceData = device[0] as any; const sessionEvidence = { ...options.evidence, sessionId, deviceId: deviceData.id, userId: deviceData.user_id, ip: deviceData.ip, userAgent: deviceData.user_agent, }; if (deviceData.id) { await this.banDevice(deviceData.id, { ...options, evidence: sessionEvidence, }); } if (deviceData.ip) { await this.banIP(deviceData.ip, { ...options, reason: `${options.reason} (from session ${sessionId})`, evidence: sessionEvidence, }); } if (deviceData.fingerprint) { await this.banFingerprint(deviceData.fingerprint, { ...options, reason: `${options.reason} (from session ${sessionId})`, evidence: sessionEvidence, }); } if (deviceData.user_id) { await this.banUser(deviceData.user_id, { ...options, evidence: sessionEvidence, }); } } async checkBan( entityType: string, entityId: string, ): Promise<{ banned: boolean; cache?: any; database?: BanInfo }> { const cacheKey = `ban:${entityType}:${entityId}`; const cached = await this.redis.get(cacheKey); if (entityType === "ip") { 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; if (bannedIp.includes("/")) { if (isIPInCIDR(entityId, bannedIp)) { return { banned: true, cache: cached ? JSON.parse(cached) : undefined, database: ban as BanInfo, }; } } else if (bannedIp === entityId) { return { banned: true, cache: cached ? JSON.parse(cached) : undefined, database: ban as BanInfo, }; } } return { banned: !!cached, cache: cached ? JSON.parse(cached) : undefined, database: undefined, }; } const dbBan = await sql` SELECT * FROM kanadi_bans WHERE entity_type = ${entityType} AND entity_id = ${entityId} AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY created_at DESC LIMIT 1 `; return { banned: !!cached || dbBan.length > 0, cache: cached ? JSON.parse(cached) : undefined, database: dbBan.length > 0 ? (dbBan[0] as BanInfo) : undefined, }; } async removeBan(entityType: string, entityId: string): Promise<void> { await sql` DELETE FROM kanadi_bans WHERE entity_type = ${entityType} AND entity_id = ${entityId} `; const cacheKey = `ban:${entityType}:${entityId}`; await this.redis.del(cacheKey); } async listActiveBans(limit: number = 100): Promise<BanInfo[]> { const bans = await sql` SELECT * FROM kanadi_bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP ORDER BY created_at DESC LIMIT ${limit} `; return bans as BanInfo[]; } async getStats(): Promise<{ total: number; byType: Record<string, number>; byDecision: Record<string, number>; }> { const totalResult = await sql` SELECT COUNT(*) as count FROM kanadi_bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP `; const byTypeResult = await sql` SELECT entity_type, COUNT(*) as count FROM kanadi_bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP GROUP BY entity_type `; const byDecisionResult = await sql` SELECT decision, COUNT(*) as count FROM kanadi_bans WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP 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), byType, byDecision, }; } private async createBan( entityType: string, entityId: string, options: BanOptions, ): Promise<void> { const expiresAt = options.durationMinutes ? new Date(Date.now() + options.durationMinutes * 60000) : null; await sql` INSERT INTO kanadi_bans ( entity_type, entity_id, rule_id, decision, reason, confidence, evidence, expires_at ) VALUES ( ${entityType}, ${entityId}, 'manual_ban', 'BAN', ${options.reason}, 1.0, ${options.evidence ? JSON.stringify(options.evidence) : JSON.stringify({ manual: true })}, ${expiresAt?.toISOString() || null} ) `; const banData = { decision: "BAN", ruleId: "manual_ban", ruleName: "Manual Ban", confidence: 1.0, reason: options.reason, evidence: options.evidence || { manual: true }, expiresAt, shouldCache: true, }; const ttl = options.durationMinutes ? options.durationMinutes * 60 : 86400; const cacheKey = `ban:${entityType}:${entityId}`; await this.redis.setex(cacheKey, ttl, JSON.stringify(banData)); } }