UNPKG

@usehall/bot-gate

Version:

React/Vue/Svelte components for conditionally rendering content based on bot detection with IP validation

397 lines (318 loc) 12.7 kB
'use strict'; var fs = require('fs'); var path = require('path'); var url = require('url'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function isServerSide() { return typeof window === 'undefined' && typeof process !== 'undefined' && process.versions && process.versions.node; } function warnIfClientSide() { if (!isServerSide()) { console.warn('bot-gate: This library is designed for server-side use only. Bot detection requires access to request headers and IP addresses that are not available in the browser.'); } } function sanitizeIpAddress(ipAddress) { if (!ipAddress || typeof ipAddress !== 'string') return null; let sanitized = ipAddress.trim() .split(',')[0] .trim(); // Remove IPv4-mapped IPv6 prefix if present if (sanitized.startsWith('::ffff:')) { sanitized = sanitized.replace(/^::ffff:/, ''); } // Handle IPv6 addresses enclosed in brackets (common in HTTP headers) if (sanitized.startsWith('[') && sanitized.endsWith(']')) { sanitized = sanitized.slice(1, -1); } return sanitized; } const __dirname$2 = path.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)))); let userAgentPatterns = null; let compiledPatterns = null; function loadUserAgentPatterns() { if (!userAgentPatterns) { try { const dataPath = path.join(__dirname$2, '../../data/user-agents.json'); if (!fs.existsSync(dataPath)) { console.warn('bot-gate: User agent patterns file not found. Bot detection will be disabled.'); userAgentPatterns = { bots: [] }; return userAgentPatterns; } const data = fs.readFileSync(dataPath, 'utf8'); const parsed = JSON.parse(data); // Validate structure if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.bots)) { console.warn('bot-gate: Invalid user agent patterns format. Expected { bots: [...] }'); userAgentPatterns = { bots: [] }; return userAgentPatterns; } // Validate each bot entry const validBots = parsed.bots.filter(bot => { if (!bot || typeof bot !== 'object' || !bot.name || !Array.isArray(bot.patterns)) { console.warn(`bot-gate: Invalid bot entry skipped:`, bot); return false; } return true; }); userAgentPatterns = { bots: validBots }; if (validBots.length === 0) { console.warn('bot-gate: No valid bot patterns found. Bot detection will be disabled.'); } // Pre-compile regex patterns for better performance compilePatterns(); } catch (error) { console.warn('bot-gate: Failed to load user agent patterns:', error.message); userAgentPatterns = { bots: [] }; } } return userAgentPatterns; } function compilePatterns() { if (!userAgentPatterns || !userAgentPatterns.bots) { compiledPatterns = []; return; } compiledPatterns = userAgentPatterns.bots.map(bot => { const regexPatterns = bot.patterns.map(pattern => { try { return new RegExp(pattern, 'i'); } catch (error) { console.warn(`bot-gate: Invalid regex pattern "${pattern}" for bot ${bot.name}:`, error.message); return null; } }).filter(Boolean); return { name: bot.name, type: bot.type, regexPatterns }; }).filter(bot => bot.regexPatterns.length > 0); } function detectBot(userAgent) { if (!userAgent || typeof userAgent !== 'string') { return null; } // Ensure patterns are loaded and compiled if (!compiledPatterns) { loadUserAgentPatterns(); if (!compiledPatterns) { compilePatterns(); } } if (!compiledPatterns || compiledPatterns.length === 0) { return null; } for (const bot of compiledPatterns) { for (const regex of bot.regexPatterns) { if (regex.test(userAgent)) { return { name: bot.name, type: bot.type, detected: true, pattern: regex.source }; } } } return null; } const __dirname$1 = path.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)))); const ipRangeCache = new Map(); // Cache configuration const CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds const MAX_CACHE_SIZE = 50; // Maximum number of cached bot entries function cleanupCache() { const now = Date.now(); const entries = Array.from(ipRangeCache.entries()); // Remove expired entries for (const [botName, data] of entries) { if (data.loadedAt && (now - data.loadedAt) > CACHE_TTL) { ipRangeCache.delete(botName); } } // If still over size limit, remove oldest entries if (ipRangeCache.size > MAX_CACHE_SIZE) { const sortedEntries = Array.from(ipRangeCache.entries()) .sort(([,a], [,b]) => (a.loadedAt || 0) - (b.loadedAt || 0)); const toRemove = sortedEntries.slice(0, ipRangeCache.size - MAX_CACHE_SIZE); for (const [botName] of toRemove) { ipRangeCache.delete(botName); } } } if (typeof setInterval !== 'undefined') { setInterval(cleanupCache, 10 * 60 * 1000); } function loadBotIpRanges(botName) { if (!ipRangeCache.has(botName)) { // Cleanup cache before adding new entries cleanupCache(); try { // Handle applebot-extended using the same IP ranges as applebot let actualBotName = botName.toLowerCase(); if (actualBotName === 'applebot-extended') { actualBotName = 'applebot'; } const dataPath = path.join(__dirname$1, `../../data/bots/${actualBotName}.json`); if (!fs.existsSync(dataPath)) { console.warn(`bot-gate: IP ranges file not found for ${botName}. Bot validation will fail for this bot.`); ipRangeCache.set(botName, { ranges: [], error: 'file_not_found' }); return ipRangeCache.get(botName); } const data = fs.readFileSync(dataPath, 'utf8'); let rawData; try { rawData = JSON.parse(data); } catch (parseError) { console.warn(`bot-gate: Invalid JSON in IP ranges file for ${botName}:`, parseError.message); ipRangeCache.set(botName, { ranges: [], error: 'invalid_json' }); return ipRangeCache.get(botName); } // Validate structure if (!rawData || typeof rawData !== 'object') { console.warn(`bot-gate: Invalid IP ranges data structure for ${botName}. Expected object.`); ipRangeCache.set(botName, { ranges: [], error: 'invalid_structure' }); return ipRangeCache.get(botName); } // Official API format: { creationTime, prefixes: [{ ipv4Prefix: "..." }] } const ranges = rawData.prefixes && Array.isArray(rawData.prefixes) ? rawData.prefixes.map(prefix => prefix.ipv4Prefix || prefix.ipv6Prefix).filter(Boolean) : []; if (ranges.length === 0) { console.warn(`bot-gate: No valid IP ranges found for ${botName}. Bot validation will fail for this bot.`); } ipRangeCache.set(botName, { ranges, loadedAt: Date.now(), error: ranges.length === 0 ? 'no_ranges' : null }); } catch (error) { console.warn(`bot-gate: Failed to load IP ranges for ${botName}:`, error.message); ipRangeCache.set(botName, { ranges: [], error: 'load_failed', errorMessage: error.message }); } } return ipRangeCache.get(botName); } function ipToInt(ip) { return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; } function cidrToRange(cidr) { const [ip, prefixLength] = cidr.split('/'); const ipInt = ipToInt(ip); const mask = (-1 << (32 - parseInt(prefixLength, 10))) >>> 0; const networkInt = ipInt & mask; const broadcastInt = networkInt | (~mask >>> 0); return { start: networkInt, end: broadcastInt }; } function expandIpv6(ip) { // Expand IPv6 address to full form if (ip.includes('::')) { const parts = ip.split('::'); const left = parts[0] ? parts[0].split(':') : []; const right = parts[1] ? parts[1].split(':') : []; const missing = 8 - left.length - right.length; const middle = Array(missing).fill('0000'); return [...left, ...middle, ...right].map(part => part.padStart(4, '0')).join(':'); } return ip.split(':').map(part => part.padStart(4, '0')).join(':'); } function ipv6ToInts(ip) { const expanded = expandIpv6(ip); const parts = expanded.split(':'); return parts.map(part => parseInt(part, 16)); } function isIpv6InRange(ip, range) { if (!ip.includes(':')) return false; if (range.includes('/')) { const [rangeIp, prefixLength] = range.split('/'); const prefix = parseInt(prefixLength, 10); if (!rangeIp.includes(':')) return false; const ipParts = ipv6ToInts(ip); const rangeParts = ipv6ToInts(rangeIp); // Check how many complete 16-bit blocks we need to compare const completeBlocks = Math.floor(prefix / 16); const remainingBits = prefix % 16; // Compare complete blocks for (let i = 0; i < completeBlocks; i++) { if (ipParts[i] !== rangeParts[i]) { return false; } } // Compare remaining bits in the partial block if (remainingBits > 0 && completeBlocks < 8) { const mask = (0xFFFF << (16 - remainingBits)) & 0xFFFF; if ((ipParts[completeBlocks] & mask) !== (rangeParts[completeBlocks] & mask)) { return false; } } return true; } // Direct IPv6 comparison if (range.includes(':')) { return expandIpv6(ip) === expandIpv6(range); } return false; } function isIpInRange(ip, range) { // Handle IPv6 ranges if (ip.includes(':') || range.includes(':')) { return isIpv6InRange(ip, range); } // Handle IPv4 ranges const ipInt = ipToInt(ip); if (range.includes('/')) { const { start, end } = cidrToRange(range); return ipInt >= start && ipInt <= end; } if (range.includes('-')) { const [startIp, endIp] = range.split('-'); const startInt = ipToInt(startIp.trim()); const endInt = ipToInt(endIp.trim()); return ipInt >= startInt && ipInt <= endInt; } return ipInt === ipToInt(range); } function isValidIpAddress(ip) { const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::([0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:)+::([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:)+::[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:)*::([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^::$|^([0-9a-fA-F]{1,4}:){1,7}:$|^:(:([0-9a-fA-F]{1,4})){1,7}$|^([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}$|^([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}$|^([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}$|^([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})$/; return ipv4Regex.test(ip) || ipv6Regex.test(ip); } function validateBotByName(botName, ipAddress) { if (!botName || !ipAddress) { return false; } const cleanIp = sanitizeIpAddress(ipAddress); if (!cleanIp || !isValidIpAddress(cleanIp)) { return false; } const botData = loadBotIpRanges(botName); if (!botData.ranges || botData.ranges.length === 0) { return false; } return botData.ranges.some(range => isIpInRange(cleanIp, range)); } function validateBot(userAgent, ipAddress) { if (!userAgent || !ipAddress) { return false; } const cleanIp = sanitizeIpAddress(ipAddress); if (!cleanIp) { console.warn(`bot-gate: Failed to sanitize IP address: ${ipAddress}`); return false; } const botInfo = detectBot(userAgent); if (!botInfo) { return false; } const isValid = validateBotByName(botInfo.name, cleanIp); if (!isValid) { console.warn(`bot-gate: Bot validation failed for ${botInfo.name} with IP ${cleanIp}. This may indicate a spoofed bot or outdated IP ranges.`); } return isValid; } exports.validateBot = validateBot; exports.warnIfClientSide = warnIfClientSide; //# sourceMappingURL=index.cjs.map