@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
JavaScript
;
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