From 8fd0c00941191685e7777d78345d4dee81e9c6bf Mon Sep 17 00:00:00 2001 From: O K Date: Sun, 22 Mar 2026 09:23:41 +0200 Subject: [PATCH] add admin cookie check --- botlimiter.php | 26 +++- classes/BotLogger.php | 249 ++++++++++++++++++++++++++++++ classes/RuleManager.php | 3 + classes/rules/FilterTrapRule.php | 14 +- classes/rules/HeadRequestRule.php | 10 +- classes/rules/RateLimitRule.php | 68 ++++++++ classes/rules/ScanBotsRule.php | 200 ++++++++++++++++++++++++ config_uk.xml | 11 ++ controllers/front/verify.php | 5 +- 9 files changed, 570 insertions(+), 16 deletions(-) create mode 100644 classes/rules/RateLimitRule.php create mode 100644 classes/rules/ScanBotsRule.php create mode 100644 config_uk.xml diff --git a/botlimiter.php b/botlimiter.php index 4e935cb..3607e66 100644 --- a/botlimiter.php +++ b/botlimiter.php @@ -1,4 +1,5 @@ registerHook('actionFrontControllerInitBefore'); } public function hookActionFrontControllerInitBefore($params) { + if (!empty((new Cookie('psAdmin'))->id_employee)) { + return; // Don't display the tag for logged-in administrators + } // 1. Skip if we are currently ON the verification page to avoid loops if ($this->context->controller instanceof BotLimiterVerifyModuleFrontController) { return; @@ -47,10 +60,11 @@ class BotLimiter extends Module // 3. Register Rules (Add more here in future) $manager->addRule(new HeadRequestRule()); $manager->addRule(new FilterTrapRule()); - + $manager->addRule(new ScanBotsRule()); + $manager->addRule(new RateLimitRule()); // 4. Execute Rules // If a rule returns FALSE, it means traffic is blocked or redirected. // The rule is responsible for the redirect/exit. $manager->process(); } -} \ No newline at end of file +} diff --git a/classes/BotLogger.php b/classes/BotLogger.php index 438b547..2a59a2a 100644 --- a/classes/BotLogger.php +++ b/classes/BotLogger.php @@ -3,10 +3,20 @@ class BotLogger { const LOG_FILE = _PS_ROOT_DIR_ . '/var/logs/botlimiter_ban.log'; + const WHITELIST_FILE = _PS_CACHE_DIR_ . DIRECTORY_SEPARATOR . 'botlimiter_whitelist.php'; const MAX_SIZE = 10485760; // 10 MB + const UPDATE_INTERVAL = 86400; // 24 hours (in seconds) + + // Store in memory during execution so we don't read the file multiple times per request + private static $cachedWhitelist = null; public static function logBan($ip, $reason) { + // 0. Check if IP is a known good bot (Google, Bing, etc.) + if (self::isWhitelisted($ip)) { + return; // Exit silently, do not log or ban + } + // 1. Check file size before writing (The Safety Valve) if (file_exists(self::LOG_FILE) && filesize(self::LOG_FILE) > self::MAX_SIZE) { self::rotateLog(); @@ -19,6 +29,173 @@ class BotLogger file_put_contents(self::LOG_FILE, $message, FILE_APPEND | LOCK_EX); } + /** + * Checks if the given IP belongs to the whitelisted networks. + */ + public static function isWhitelisted($ip) + { + + // 1. FASTEST CHECK: Is it the server itself? (Localhost or Server IP) + $serverIps = ['127.0.0.1', '::1']; + if (!empty($_SERVER['SERVER_ADDR'])) { + $serverIps[] = $_SERVER['SERVER_ADDR']; + } + if (in_array($ip, $serverIps, true)) { + return true; + } + + // 2. FAST CHECK: PrestaShop Maintenance IPs + $maintenance_ips = Configuration::get('PS_MAINTENANCE_IP'); + if ($maintenance_ips) { + $admin_ips = array_filter(array_map('trim', explode(',', $maintenance_ips))); + if (in_array($ip, $admin_ips, true)) { + return true; + } + } + + self::updateWhitelistIfNeeded(); + + // Load whitelist into memory if not already done + if (self::$cachedWhitelist === null) { + if (file_exists(self::WHITELIST_FILE)) { + // FASTEST: OPcache will serve this directly from RAM + self::$cachedWhitelist = include(self::WHITELIST_FILE); + } else { + self::$cachedWhitelist = []; + } + } + + // Check against CIDR blocks + foreach (self::$cachedWhitelist as $cidr) { + if (self::ipMatch($ip, $cidr)) { + return true; + } + } + + return false; + } + + /** + * Updates the whitelist once a day by fetching JSON lists from Google and Bing. + */ + private static function updateWhitelistIfNeeded() + { + $needsUpdate = true; + + if (file_exists(self::WHITELIST_FILE)) { + $lastModified = filemtime(self::WHITELIST_FILE); + if ((time() - $lastModified) < self::UPDATE_INTERVAL) { + $needsUpdate = false; + } + } + + if (!$needsUpdate) { + return; + } + + + + $cidrs = []; + + + $urls = [ + 'https://developers.google.com/search/apis/ipranges/googlebot.json', + 'https://www.bing.com/toolbox/bingbot.json', + 'https://openai.com/chatgpt-user.json', + 'https://openai.com/searchbot.json', + 'https://openai.com/gptbot.json' + + ]; + foreach ($urls as $url) { + $cidrs = array_merge($cidrs, self::extractIpPrefix($url)); + } + + // If we successfully fetched networks, update the file + if (!empty($cidrs)) { + $cidrs = array_values(array_unique($cidrs)); + + // Generate valid PHP code + $phpCode = " 0 && substr($ip_bin, 0, $bytes) !== substr($subnet_bin, 0, $bytes)) { + return false; + } + + if ($bits > 0) { + $ip_byte = ord($ip_bin[$bytes]); + $subnet_byte = ord($subnet_bin[$bytes]); + $bitmask = ~((1 << (8 - $bits)) - 1) & 0xFF; + if (($ip_byte & $bitmask) !== ($subnet_byte & $bitmask)) { + return false; + } + } + return true; + } + + return false; + } + /** * Rotates the log: * 1. Deletes the .old file @@ -37,4 +214,76 @@ class BotLogger // Rename current to backup @rename(self::LOG_FILE, $backup_file); } + + /** + * Safely gets the real client IP, completely immune to Header Spoofing. + */ + public static function getRealIp() + { + // 1. Get the actual, un-spoofable TCP connection IP + $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? ''; + + // 2. Is the connection physically coming from Cloudflare? + if (self::isCloudflareIp($remoteAddr)) { + + // ONLY trust these headers because Cloudflare guaranteed them + if (!empty($_SERVER['HTTP_CF_CONNECTING_IP']) && filter_var($_SERVER['HTTP_CF_CONNECTING_IP'], FILTER_VALIDATE_IP)) { + return $_SERVER['HTTP_CF_CONNECTING_IP']; + } + + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); + $ip = trim($ips[0]); + if (filter_var($ip, FILTER_VALIDATE_IP)) { + return $ip; + } + } + } + + // 3. Fallback: If it's NOT Cloudflare, it's either a direct user or a hacker. + // We MUST ignore their headers and use the raw TCP connection IP. + return $remoteAddr; + } + + /** + * Checks if the IP belongs to official Cloudflare Networks. + * (These ranges rarely change, standard practice is to hardcode them) + */ + private static function isCloudflareIp($ip) + { + static $cf_ips = [ + // IPv4 + '173.245.48.0/20', + '103.21.244.0/22', + '103.22.200.0/22', + '103.31.4.0/22', + '141.101.64.0/18', + '108.162.192.0/18', + '190.93.240.0/20', + '188.114.96.0/20', + '197.234.240.0/22', + '198.41.128.0/17', + '162.158.0.0/15', + '104.16.0.0/13', + '104.24.0.0/14', + '172.64.0.0/13', + '131.0.72.0/22', + // IPv6 + '2400:cb00::/32', + '2606:4700::/32', + '2803:f800::/32', + '2405:b500::/32', + '2405:8100::/32', + '2a06:98c0::/29', + '2c0f:f248::/32' + ]; + + foreach ($cf_ips as $cidr) { + if (self::ipMatch($ip, $cidr)) { + return true; + } + } + + return false; + } } diff --git a/classes/RuleManager.php b/classes/RuleManager.php index 2fc4587..c154037 100644 --- a/classes/RuleManager.php +++ b/classes/RuleManager.php @@ -2,6 +2,9 @@ require_once dirname(__FILE__) . '/rules/RuleInterface.php'; require_once dirname(__FILE__) . '/rules/HeadRequestRule.php'; require_once dirname(__FILE__) . '/rules/FilterTrapRule.php'; +require_once dirname(__FILE__) . '/rules/ScanBotsRule.php'; +require_once dirname(__FILE__) . '/rules/RateLimitRule.php'; + class RuleManager { diff --git a/classes/rules/FilterTrapRule.php b/classes/rules/FilterTrapRule.php index b257f2c..2cc4346 100644 --- a/classes/rules/FilterTrapRule.php +++ b/classes/rules/FilterTrapRule.php @@ -4,13 +4,17 @@ class FilterTrapRule implements RuleInterface { public function execute() { + $ip = BotLogger::getRealIp(); + if (BotLogger::isWhitelisted($ip)) { + return true; + } // 1. Only analyze heavy requests (filters/sorting) if (!Tools::getIsset('q') && !Tools::getIsset('order')) { return true; } $context = Context::getContext(); - $ip = $_SERVER['REMOTE_ADDR']; + // 2. Allow whitelisted Bots (Google/Bing) // We trust them not to spam. If they do, use robots.txt. @@ -52,17 +56,17 @@ class FilterTrapRule implements RuleInterface // 5. If we are here: Heavy request + No Cookie + No Token. // Redirect to the Trap (Verify Controller) - + $current_url = $_SERVER['REQUEST_URI']; // Remove existing bot_token if present to avoid loops $current_url = preg_replace('/([?&])bot_token=[^&]+(&|$)/', '$1', $current_url); // Encode target URL safely $target = urlencode($current_url); - + $link = $context->link->getModuleLink('botlimiter', 'verify', ['return_url' => $target]); - + Tools::redirect($link); exit; } -} \ No newline at end of file +} diff --git a/classes/rules/HeadRequestRule.php b/classes/rules/HeadRequestRule.php index d5b45c9..c2190ec 100644 --- a/classes/rules/HeadRequestRule.php +++ b/classes/rules/HeadRequestRule.php @@ -4,15 +4,19 @@ class HeadRequestRule implements RuleInterface { public function execute() { + $ip = BotLogger::getRealIp(); + if (BotLogger::isWhitelisted($ip)) { + return true; + } // Detect HEAD request with Filter parameters if ($_SERVER['REQUEST_METHOD'] === 'HEAD' && (Tools::getIsset('q') || Tools::getIsset('order'))) { - + // Log for Fail2Ban - BotLogger::logBan($_SERVER['REMOTE_ADDR'], 'HEAD_REQUEST_SPAM'); + BotLogger::logBan($ip, 'HEAD_REQUEST_SPAM'); header('HTTP/1.1 405 Method Not Allowed'); die('Method Not Allowed'); } return true; } -} \ No newline at end of file +} diff --git a/classes/rules/RateLimitRule.php b/classes/rules/RateLimitRule.php new file mode 100644 index 0000000..bb16cc7 --- /dev/null +++ b/classes/rules/RateLimitRule.php @@ -0,0 +1,68 @@ +controller instanceof PageNotFoundController)) { + return true; + } + + + + + // 2. Safely check if the Cache module is installed and enabled + if (!Module::isInstalled('dbmemorycache') || !Module::isEnabled('dbmemorycache')) { + return true; // Pass gracefully if the module is missing or disabled + } + + /** @var DbMemoryCache $cache */ + $cache = Module::getInstanceByName('dbmemorycache'); + if (!$cache) { + return true; + } + + // 3. Generate a unique hash for this specific IP's 404 traffic + $cacheKey = hash('sha256', '404_spam_limiter_' . $ip); + + // 4. Lookup their current 404 hit count in the memory table + $currentCount = 0; + if ($cache->existsValue($cacheKey)) { + $currentCount = (int) $cache->getValue($cacheKey); + } + + // 5. Increment their strike counter + $currentCount++; + + // 6. Have they triggered too many 404s? + if ($currentCount > self::MAX_404_REQUESTS) { + + // Log it so Fail2ban can block them at the server firewall level + BotLogger::logBan($ip, '404_RATE_LIMIT_EXCEEDED'); + + // Drop the connection instantly to save server resources + header('HTTP/1.1 429 Too Many Requests'); + die('429 Too Many Requests - Stop Scanning'); + } + + // 7. Save the new strike count back to the cache + // Note: Because we overwrite it here, this creates a "Sliding Window". + // If a bot keeps spamming, the 300s timer resets every time, keeping them trapped! + $cache->setValue($cacheKey, $currentCount, self::TIME_WINDOW); + + return true; + } +} diff --git a/classes/rules/ScanBotsRule.php b/classes/rules/ScanBotsRule.php new file mode 100644 index 0000000..86444a3 --- /dev/null +++ b/classes/rules/ScanBotsRule.php @@ -0,0 +1,200 @@ + true, + '/acp.php' => true, + '/gettest.php' => true, + '/4h.php' => true, + '/flower.php' => true, + '/styll.php' => true, + '/re.php' => true, + '/alfashell.php' => true, + '/axx.php' => true, + '/X57.php' => true, + '/erty.php' => true, + '/miansha.php' => true, + '/bengi.php' => true, + '/bs1.php' => true, + '/motu.php' => true, + '/gssdd.php' => true, + '/in.php' => true, + '/bal.php' => true, + '/dev.php' => true, + '/k.php' => true, + '/prv8.php' => true, + '/lb.php' => true, + '/hi.php' => true, + '/f35.php' => true, + '/update/f35.php' => true, + '/a1.php' => true, + '/fi.php' => true, + '/init.php' => true, + '/abcd.php' => true, + '/av.php' => true, + '/kj.php' => true, + '/fe5.php' => true, + '/about.php' => true, + '/ok.php' => true, + '/w4.php' => true, + '/assets/css/index.php' => true, + '/wp.php' => true, + '/BDKR28WP.php' => true, + '/wp-the.php' => true, + '/wp-michan.php' => true, + '/makeasmtp.php' => true, + '/alpha.php' => true, + '/we.php' => true, + '/155.php' => true, + '/goat.php' => true, + '/fff.php' => true, + '/ff1.php' => true, + '/cgi-bin/index.php' => true, + '/plugins.php' => true, + '/222.php' => true, + '/ms-edit.php' => true, + '/goods.php' => true, + '/adminfuns.php' => true, + '/166.php' => true, + '/test1.php' => true, + '/wp-blog.php' => true, + '/sbhu.php' => true, + '/wp-update.php' => true, + '/ms.php' => true, + '/x.php' => true, + '/tinyfilemanager.php' => true, + '/classwithtostring.php' => true, + '/aaa.php' => true, + '/plss3.php' => true, + '/06.php' => true, + '/a.php' => true, + '/xqq.php' => true, + '/class-t.api.php' => true, + '/wp-act.php' => true, + '/wp9.php' => true, + '/bless.php' => true, + '/file59.php' => true, + '/file.php' => true, + '/sc.php' => true, + '/1.php' => true, + '/aa.php' => true, + '/bgymj.php' => true, + '/style.php' => true, + '/inputs.php' => true, + '/f6.php' => true, + '/ol.php' => true, + '/xmlrpc.php' => true, + '/gifclass.php' => true, + '/66.php' => true, + '/ioxi-o.php' => true, + '/edit.php' => true, + '/3.php' => true, + '/wsvvs.php' => true, + '/pass2.php' => true, + '/maxro.php' => true, + '/mga.php' => true, + '/2.php' => true, + '/wdf.php' => true, + '/path.php' => true, + '/txets.php' => true, + '/sys.php' => true, + '/pp.php' => true, + '/g.php' => true, + '/h.php' => true, + '/xxxx.php' => true, + '/sty.php' => true, + '/a2.php' => true, + '/fvvff.php' => true, + '/claw.php' => true, + '/swallowable.php' => true, + '/foxr.php' => true, + '/w2025.php' => true, + '/cs.php' => true, + '/kk.php' => true, + '/rithin.php' => true, + '/h2h.php' => true, + '/wo.php' => true, + '/jocundly.php' => true, + '/rere.php' => true, + '/bafFz.php' => true, + '/elabel.php' => true, + '/teee.php' => true, + '/no1.php' => true, + '/akses.php' => true, + '/lp6.php' => true, + '/eee.php' => true, + '/asw.php' => true, + '/sf.php' => true, + '/by.php' => true, + '/x12.php' => true, + '/uuu.php' => true, + '/fsgdjkl.php' => true, + '/settings.php' => true, + '/utky.php' => true, + '/yos.php' => true, + '/albin.php' => true, + '/invisi.php' => true, + '/ty.php' => true, + '/wziar1.php' => true, + '/742.php' => true, + '/wp-p2r3q9c8k4.php' => true, + '/cash.php' => true, + '/nw_ok.php' => true, + '/filefuns.php' => true, + '/leon.php' => true, + '/199.php' => true, + '/aifa.php' => true, + '/gptsh.php' => true, + ]; + + // 2. Prefix Targets (Folders/Directories) + // ANY traffic accessing these folders immediately triggers the ban. + private static $prefix_targets = [ + '/wp-content/', + '/wp-includes/', + '/wp-admin/', + '/x/' + ]; + + public function execute() + { + if (empty($_SERVER['REQUEST_URI'])) { + return true; + } + $ip = BotLogger::getRealIp(); + + if (BotLogger::isWhitelisted($ip)) { + return true; + } + // 1. Strip Query Strings (e.g. ?id=1) so bots cannot bypass the exact match + $path = strtok($_SERVER['REQUEST_URI'], '?'); + + // 2. O(1) Instant RAM-speed check for exact files + if (isset(self::$exact_targets[$path])) { + $this->blockRequest($ip); + } + + // 3. Prefix Check for WordPress & Malicious Directories + foreach (self::$prefix_targets as $prefix) { + if (strpos($path, $prefix) === 0) { + $this->blockRequest($ip); + } + } + + return true; + } + + /** + * Reusable trigger to log and drop connection + */ + private function blockRequest($ip) + { + BotLogger::logBan($ip, 'SCAN_BOT'); + + header('HTTP/1.1 405 Method Not Allowed'); + die('Method Not Allowed'); + } +} diff --git a/config_uk.xml b/config_uk.xml new file mode 100644 index 0000000..222c4e0 --- /dev/null +++ b/config_uk.xml @@ -0,0 +1,11 @@ + + + botlimiter + + + + + + 0 + 0 + \ No newline at end of file diff --git a/controllers/front/verify.php b/controllers/front/verify.php index b0a1bba..4262af5 100644 --- a/controllers/front/verify.php +++ b/controllers/front/verify.php @@ -1,5 +1,6 @@ setTemplate('module:botlimiter/views/templates/front/verify.tpl'); } -} \ No newline at end of file +}