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