* * roundcube-defense is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * roundcube-defense is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with roundcube-defense. If not, see . */ class defense extends rcube_plugin { // Roundcube parent class private $rc; // Config variables private $whitelist, $blacklist, $fail_max, $fail_reset, $ban_period, $repeat_multiplier, $repeat_reset, $db_table, $db_expire, $log_pwd; // Remote client IP address private $ipaddr; // Previous ban data private $prevBan; // Debug log private $logfile = 'defense.log'; private $debugEnabled; /** * Output text to log file: $this->logfile * * @param string * text for log */ private function debug($string) { if (!$this->debugEnabled) { return; } write_log($this->logfile, $this->ipaddr . " # " . $string); } /** * Check if IP is matched against all IPs in array, * including CIDR matches * * @param string * ip address * @param array * ip/cidr addresses to match against * @return bool */ private function isIPinArray($ip, $array) { foreach ($array as $value) { // If no slash '/' then its not a CIDR address and we can just string match if ((strpos($value, '/') === false) && (strcmp($ip, $value) == 0)) { return true; } if (($this->isIPv6($ip)) && (!$this->isIPv6($value))) { return false; } if (($this->isIPv4($value)) && (!$this->isIPv4($ip))) { return false; } if (($this->isIPv4($ip) && ($this->isIPv4inCIDR($ip, $value)))) { return true; } if (($this->isIPv6($ip) && ($this->isIPv6inCIDR($ip, $value)))) { return true; } } return false; } /** * Check if IPv4 is within stated CIDR address * * @param string * ip address * @param string * cidr address * @return bool */ private function isIPv4inCIDR($ip, $cidr) { list($subnet, $mask) = explode('/', $cidr); return ((ip2long($ip) & ~((1 << (32 - $mask)) - 1) ) == ip2long($subnet)); } /** * Convert IPv6 mask to bytearray * * @param string * subnet mask * @return string */ private function IPv6MaskToByteArray($subnetMask) { $addr = str_repeat("f", $subnetMask / 4); switch ($subnetMask % 4) { case 0: break; case 1: $addr .= "8"; break; case 2: $addr .= "c"; break; case 3: $addr .= "e"; break; } $addr = str_pad($addr, 32, '0'); $addr = pack("H*" , $addr); return $addr; } /** * Check if IPv6 is within stated CIDR address * * @param string * subnet mask * @return bool */ private function isIPv6inCIDR($ip, $cidr) { list($subnet, $mask) = explode('/', $cidr); $binMask = $this->IPv6MaskToByteArray($mask); return ($ip & $binMask) == $subnet; } /** * Check string if it is IPv6 * * @param string * ip address * @return bool */ private function isIPv6($ip) { return (((!preg_match('/^[\.\/:0-9a-f]+$/', strtolower($ip))) || (substr_count($ip, ':') < 2)) ? true : false); } /** * Check string if it is IPv4 * * @param string * ip address * @return bool */ private function isIPv4($ip) { return ((preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}(\/[0-9]{1,2})?$/', $ip)) ? true : false); } /** * Write to log stating database error * */ private function dbError() { // I can't seem to try/catch database entries so I have no details regarding error $string = "Error communicating with database."; $this->debug($string); write_log('error', 'plugin::defense: ' . $string); } /** * Return true if IP matches config whitelist * * @param string * ip address * @return bool */ private function isWhitelisted($ip) { $this->whitelist = $this->rc->config->get('defense_whitelist', array('127.0.0.1')); // If IP is listed in whitelist, return true if ($this->isIPinArray($this->ipaddr, $this->whitelist)) { $this->debug("whitelisted"); return $true; } return false; } /** * Return true if IP matches config blacklist * * @param string * ip address * @return bool */ private function isBlacklisted($ip) { $this->blacklist = $this->rc->config->get('defense_blacklist', array()); // If IP is listed in blacklist, return true if ($this->isIPinArray($this->ipaddr, $this->blacklist)) { $this->debug("blacklisted"); return $true; } return false; } /** * Send forbidden (403) HTTP status header and quit * */ private function sendForbiddenHeader() { if (headers_sent()) { return; } // Already sent, can't do this now header('HTTP/1.1 403 Forbidden'); die(); } /** * Fetch and return last ban for $ip * * @param string * ip address * @return mixed * Return array with record matched, or bool(false) if no match */ private function getPreviousBanData($ip) { // Use cached data if available. if ($this->prevBan) { return $this->prevBan; } $query = sprintf("SELECT * FROM %s WHERE ipaddr = '%s' AND type = %d ORDER BY id DESC LIMIT 1", $this->db_table, $ip, 1); $result = $this->rc->db->query($query); if (!$result) { $this->dbError($query); return false; } $this->debug($query . " [" . $result->rowCount() . "]"); if ($result->rowCount() > 0) { // Set data to cache variable $this->prevBan = $result->fetch(); } return $this->prevBan; } /** * Convert seconds to human readable duration * * @param int * duration in seconds * @return string * human readable duration, ie: 3d7h13m25s */ private function secs2hr($secs) { $s = $secs%60; $m = floor(($secs%3600)/60); $h = floor(($secs%86400)/3600); $d = floor(($secs%604800)/86400); $w = floor(($secs%31449600)/604800); $y = floor($secs/31449600); return (($y > 0) ? $y . "y" : "") . (($w > 0) ? $w . "w" : "") . (($d > 0) ? $d . "d" : "") . (($h > 0) ? $h . "h" : "") . (($m > 0) ? $m . "m" : "") . (($s > 0) ? $s . "s" : ""); } /** * Constructor, initialization * */ public function init() { // create parent class $this->rc = rcube::get_instance(); // load configuration $this->load_config(); // Set localization (only for login pages) $this->add_texts('localization/', $this->rc->task == 'login'); // set config variables, set defaults $this->db_table = $this->rc->config->get('defense_db_table', 'defense'); $this->fail_max = $this->rc->config->get('defense_fail_max', 5); $this->fail_reset = $this->rc->config->get('defense_fail_reset', 600); $this->ban_period = $this->rc->config->get('defense_ban_period', 120); $this->ban_httpstatus = $this->rc->config->get('defense_ban_httpstatus', false); $this->repeat_multiplier = $this->rc->config->get('defense_repeat_multiplier', 4); $this->repeat_reset = $this->rc->config->get('defense_repeat_reset', 86400); $this->db_expire = $this->rc->config->get('defense_db_expire', 40); $this->log_pwd = $this->rc->config->get('defense_log_pwd', false); $this->debugEnabled = $this->rc->config->get('defense_debug_enabled', false); // set client ip $this->ipaddr = rcmail_remote_ip(); // Roundcube event hooks $this->add_hook('template_object_loginform', array($this, 'hookLoginForm')); $this->add_hook('authenticate', array($this, 'hookAuthenticate')); $this->add_hook('login_failed', array($this, 'hookLoginFailed')); } /** * Hooked function: template_login_form(string) * http://trac.roundcube.net/wiki/Plugin_Hooks#TemplateHooks * * Process whitelist and blacklist * * @param string * Login form HTML content * @return string * Login form HTML content */ public function hookLoginForm($content) { // If IP is listed in whitelist, return unmodified $content if ($this->isWhitelisted($this->ipaddr)) { return $content; } // If IP is listed in blacklist, deny access if ($this->isBlacklisted($this->ipaddr)) { header('HTTP/1.1 403 Forbidden'); die(); } if (($this->ban_httpstatus) && ($this->isBanned($this->ipaddr))) { $this->debug("IP already banned"); $this->sendForbiddenHeader(); } $this->debug("Sending login form."); return $content; } private function isBanned($ip) { $row = $this->getPreviousBanData($ip); if ($row) { $data = unserialize($row['data']); $duration = $data['duration']; $timeleft = (($duration + $row['epoch']) - time()); if ($timeleft > 0) { return $timeleft; } } return false; } /** * Hooked function: login_failed(array) * http://trac.roundcube.net/wiki/Plugin_Hooks#login_failed * * Log event to database * * @param array * [code, host, user, abort] * */ public function hookLoginFailed($args) { $this->debug("hookLoginFailed"); // Log failed login attempt $data = array('user' => $args['user']); $query = sprintf("INSERT INTO %s (epoch, type, ipaddr, data) VALUES (%d, %d, '%s', '%s')", $this->db_table, time(), 0, $this->ipaddr, serialize($data)); $result = $this->rc->db->query($query); if (!$result) { $this->dbError($query); return; } $this->debug($query . " [" . $result->rowCount() . "]"); // Check if banned now that above record has been updated $rTime = (time() - $this->fail_reset); // How far to look back for failed logins // Check if last ban lifted was within rTime $row = $this->getPreviousBanData($this->ipaddr); if ($row) { $data = unserialize($row['data']); $banLifted = $row['epoch'] + $data['duration']; if ($rTime < $banLifted) { // If IP was unbanned recently, only check since it was unbanned $rTime = $banLifted; } } $query = sprintf("SELECT count(*) AS n FROM %s WHERE ipaddr = '%s' AND epoch >= %d", $this->db_table, $this->ipaddr, $rTime); $result = $this->rc->db->query($query); if (!$result) { $this->dbError($query); return false; } $this->debug($query . " [" . $result->rowCount() . "]"); $row = $result->fetch(); if (!$row) { $this->debug("Warning, SQL result empty: $query"); return false; } // No rows? Strange, abort. $this->debug("Found " . $row['n'] . " failed attempts in last " . (time() - $rTime) . "s"); if (($row['n'] >= $this->fail_max)) { $this->debug("IP banned."); // This IP is now banned $repeat = 0; $row = $this->getPreviousBanData($this->ipaddr); if ($row) { // IP has been banned before, check if its a recent repeat offender $data = unserialize($row['data']); $this->debug("Previous ban data: " . $row['data']); // Classed as a repeat offender if IP is banned again after the previous ban duration // multiplied by $bTime = (($data['duration'] * $this->repeat_multiplier) + $row['epoch']); // Multiply previous bantime by multiplier if (time() <= ($bTime > $this->repeat_reset ? $bTime : $this->repeat_reset)) { // Repeat offender, increase repeat $repeat = $data['repeat'] +1; $this->debug("Repeat offender. Repeat set to " . $repeat); } } $duration = ($this->ban_period * ($repeat > 0 ? pow($this->repeat_multiplier,$repeat) : 1)); $data = array( 'duration' => $duration, // Ban duration based on history 'repeat' => $repeat ); $query = sprintf("INSERT INTO %s (epoch, type, ipaddr, data) VALUES (%d, %d, '%s', '%s')", $this->db_table, time(), 1, $this->ipaddr, serialize($data)); $result = $this->rc->db->query($query, time(), 1, $this->ipaddr, serialize($data)); if (!$result) { $this->dbError($query); return; } $this->debug($query . " [" . $result->rowCount() . "]"); return $args; } } /** * Hooked function: authenticate(array) * http://trac.roundcube.net/wiki/Plugin_Hooks#authenticate * * Login attempt intercepted if IP is banned. * * @param array * [host, user, cookiecheck, valid] * @return array */ public function hookAuthenticate($args) { // Check whitelist/blacklist again, in case login form was bypassed somehow // If IP is listed in whitelist, return unmodified $args if ($this->isWhitelisted($this->ipaddr)) { return $args; } // If IP is listed in blacklist, deny access if ($this->isBlacklisted($this->ipaddr)) { $this->sendForbiddenHeader(); } $isBanned = $this->isBanned($this->ipaddr); if ($isBanned) { if ($this->ban_httpstatus) { $this->sendForbiddenHeader(); } $this->rc->output->show_message(sprintf($this->gettext('ipbanned'), $this->secs2hr($isBanned)), 'error'); $this->rc->output->set_env('task', 'login'); $this->rc->output->send('login'); die(); } $this->debug("Login form submitted, username: " . $args['user']); return $args; } } ?>