import { Context } from 'hono'; import { getJsonSetting } from './utils'; import { CONSTANTS } from './constants'; /** * IP Blacklist Settings stored in database */ export type IpBlacklistSettings = { enabled?: boolean; blacklist?: string[]; // Array of regex patterns or plain strings asnBlacklist?: string[]; // Array of ASN organization patterns (e.g., "Google LLC", "Amazon") fingerprintBlacklist?: string[]; // Array of browser fingerprint patterns enableWhitelist?: boolean; // Enable IP whitelist (strict allowlist mode) whitelist?: string[]; // Array of exact IPs or anchored regex; only matching IPs are allowed enableDailyLimit?: boolean; // Enable daily request limit per IP dailyRequestLimit?: number; // Maximum requests per IP per day } /** * Check if a string is a valid regex pattern * Heuristic: contains regex special characters */ function looksLikeRegex(pattern: string): boolean { // Check if pattern contains common regex metacharacters // eslint-disable-next-line no-useless-escape return /[\^$.*+?\[\]{}()|\\]/.test(pattern); } /** * Check if a value matches any blacklist pattern * Supports both regex patterns and plain string matching * * @param value - The value to check (e.g., IP address, ASN organization) * @param blacklist - Array of patterns (regex or plain strings) * @param caseSensitive - Whether to use case-sensitive matching for plain strings (default: true for IP, false for ASN) * @returns true if value is blacklisted, false otherwise * * @example * // IP address matching (case-sensitive): * isBlacklisted("192.168.1.100", ["192.168.1"], true) // true (substring match) * isBlacklisted("10.0.0.5", ["^10\\.0\\.0\\.5$"], true) // true (regex match) * * // ASN organization matching (case-insensitive): * isBlacklisted("Google LLC", ["google"], false) // true (case-insensitive) * isBlacklisted("Amazon.com, Inc.", ["amazon"], false) // true */ function isBlacklisted(value: string | null | undefined, blacklist: string[], caseSensitive: boolean = true): boolean { if (!value || !blacklist || blacklist.length === 0) { return false; } const normalizedValue = value.trim(); return blacklist.some(pattern => { const normalizedPattern = pattern.trim(); if (!normalizedPattern) { return false; } try { if (looksLikeRegex(normalizedPattern)) { // For regex patterns, add 'i' flag if case-insensitive matching is needed const flags = caseSensitive ? '' : 'i'; const regex = new RegExp(normalizedPattern, flags); return regex.test(normalizedValue); } // Plain string mode: substring matching if (caseSensitive) { return normalizedValue.includes(normalizedPattern); } return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase()); } catch (error) { console.warn(`Pattern "${normalizedPattern}" failed regex parsing, using plain matching`); if (caseSensitive) { return normalizedValue.includes(normalizedPattern); } return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase()); } }); } /** * Whitelist-style match: strict allowlist, independent from blacklist semantics. * Plain IPv4/IPv6 entries are matched EXACTLY (not as regex) to avoid unintended matches. * Only explicit regex patterns (containing metacharacters beyond dots/colons) are treated as regex. * * Examples: * "1.2.3.4" → exact match only (NOT treated as regex /1.2.3.4/) * "2001:db8::1" → exact match only * "^192\\.168\\.1\\.\\d+$" → regex (contains anchors/escapes) */ function isWhitelisted(value: string | null | undefined, whitelist: string[] | undefined): boolean { if (!value || !whitelist || whitelist.length === 0) { return false; } const normalizedValue = value.trim(); return whitelist.some(pattern => { const normalizedPattern = pattern.trim(); if (!normalizedPattern) { return false; } // IPv4 detection: digits and dots only → exact match (bypass regex heuristic) if (/^\d+\.\d+\.\d+\.\d+$/.test(normalizedPattern)) { return normalizedValue === normalizedPattern; } // IPv4-mapped IPv6: ::ffff:1.2.3.4 → exact match if (/^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(normalizedPattern)) { return normalizedValue === normalizedPattern; } // IPv6 detection: hex digits and colons → exact match if (/^[0-9a-fA-F:]+$/.test(normalizedPattern) && normalizedPattern.includes(':')) { return normalizedValue === normalizedPattern; } // Regex detection: contains metacharacters beyond dots/colons if (looksLikeRegex(normalizedPattern)) { try { const regex = new RegExp(normalizedPattern); return regex.test(normalizedValue); } catch (error) { // Invalid regex in a whitelist = never match (fail closed) console.warn(`Whitelist regex "${normalizedPattern}" failed to parse: ${(error as Error).message}, treating as no-match`); return false; } } // Fallback: other plain strings → exact match return normalizedValue === normalizedPattern; }); } /** * Get IP blacklist settings from database * * @param c - Hono context * @returns IP blacklist settings (may be null or have undefined fields) */ export async function getIpBlacklistSettings( c: Context ): Promise { return await getJsonSetting( c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY ); } /** * Layer 1 — IP whitelist check (strict allowlist mode). * Independent from blacklist. Fails closed when client IP is missing. * * Returns: * - { response } — request is blocked (403) * - { hit: true } — whitelist active and the IP matched (trusted, skip blacklist) * - { hit: false } — whitelist not active or list empty (proceed normally) */ function checkIpWhitelist( c: Context, settings: IpBlacklistSettings, reqIp: string | null ): { response?: Response; hit: boolean } { const active = !!(settings.enableWhitelist && settings.whitelist && settings.whitelist.length > 0); if (!active) return { hit: false }; if (!reqIp) { console.warn(`Blocked request without cf-connecting-ip under whitelist mode for path: ${c.req.path}`); return { response: c.text(`Access denied: client IP unavailable`, 403), hit: false }; } if (isWhitelisted(reqIp, settings.whitelist)) { return { hit: true }; } console.warn(`Blocked non-whitelisted IP: ${reqIp} for path: ${c.req.path}`); return { response: c.text(`Access denied: IP ${reqIp} is not whitelisted`, 403), hit: false }; } /** * Layer 2a — Fingerprint blacklist check. Does NOT require a client IP. * Must run before the IP-based early-return so fingerprint bans cannot be bypassed. */ function checkFingerprintBlacklist( c: Context, settings: IpBlacklistSettings, ): Response | null { if (!settings.enabled) return null; if (!settings.fingerprintBlacklist || settings.fingerprintBlacklist.length === 0) return null; const fingerprint = c.req.raw.headers.get("x-fingerprint"); if (fingerprint && isBlacklisted(fingerprint, settings.fingerprintBlacklist, true)) { console.warn(`Blocked blacklisted fingerprint: ${fingerprint} for path: ${c.req.path}`); return c.text(`Access denied: Browser fingerprint is blacklisted`, 403); } return null; } /** * Layer 2b — IP + ASN blacklist check. Requires a client IP. */ function checkIpAsnBlacklist( c: Context, settings: IpBlacklistSettings, reqIp: string ): Response | null { if (!settings.enabled) return null; if (settings.blacklist && settings.blacklist.length > 0) { if (isBlacklisted(reqIp, settings.blacklist, true)) { console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`); return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403); } } if (settings.asnBlacklist && settings.asnBlacklist.length > 0) { const asOrganization = c.req.raw.cf?.asOrganization; if (asOrganization && isBlacklisted(asOrganization as string, settings.asnBlacklist, false)) { console.warn(`Blocked blacklisted ASN: ${asOrganization} (IP: ${reqIp}) for path: ${c.req.path}`); return c.text(`Access denied: ASN organization is blacklisted`, 403); } } return null; } /** * Layer 3 — Daily request limit per IP. Always runs (protects backend resources). */ async function checkDailyLimit( c: Context, settings: IpBlacklistSettings, reqIp: string ): Promise { if (!settings.enableDailyLimit || !settings.dailyRequestLimit || !c.env.KV) { return null; } const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`; const dailyLimit = settings.dailyRequestLimit; const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10); if (current_count && current_count >= dailyLimit) { console.warn(`Blocked IP ${reqIp} exceeded daily limit of ${dailyLimit} requests for path: ${c.req.path}`); return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429); } // Increment counter with 24-hour expiration await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 }); return null; } /** * Middleware to check access control for rate-limited endpoints. * Composes three independent layers in order: * Layer 1 — IP whitelist (strict allowlist; hit = trust, skip blacklist) * Layer 2 — Blacklist (IP / ASN / fingerprint) * Layer 3 — Daily request limit * * Returns 403/429 response if blocked, null if allowed or any error occurs. */ export async function checkAccessControl( c: Context ): Promise { try { const settings = await getIpBlacklistSettings(c); if (!settings) return null; const reqIp = c.req.raw.headers.get("cf-connecting-ip"); // Layer 1: whitelist const whitelistResult = checkIpWhitelist(c, settings, reqIp); if (whitelistResult.response) return whitelistResult.response; // Layer 2a: fingerprint blacklist (does not require IP) if (!whitelistResult.hit) { const fingerprintResp = checkFingerprintBlacklist(c, settings); if (fingerprintResp) return fingerprintResp; } // Without a client IP, skip IP-keyed layers below if (!reqIp) return null; // Layer 2b: IP + ASN blacklist (skipped when whitelist trusted the IP) if (!whitelistResult.hit) { const ipAsnResp = checkIpAsnBlacklist(c, settings, reqIp); if (ipAsnResp) return ipAsnResp; } // Layer 3: daily limit (always enforced) return await checkDailyLimit(c, settings, reqIp); } catch (error) { // Log error but don't block request console.error('Error checking IP blacklist and rate limit:', error); return null; } }