2013-02-21 00:54:15 +04:00
|
|
|
<?php
|
|
|
|
/*
|
|
|
|
* This file is part of the Roundcube plugin: roundcube-defense.
|
|
|
|
*
|
|
|
|
* @author Steve Allison <roundcube-defense@nooblet.org>
|
|
|
|
*
|
|
|
|
* 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 <http://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
2013-02-21 00:59:40 +04:00
|
|
|
class defense extends rcube_plugin {
|
2013-02-21 00:54:15 +04:00
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
2013-02-21 17:17:40 +04:00
|
|
|
// Logfile
|
|
|
|
private $logfile = 'defense.log';
|
2013-02-21 18:02:37 +04:00
|
|
|
private $debugEnabled;
|
2013-02-21 17:17:40 +04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Output text to log file: $this->logfile
|
|
|
|
*
|
|
|
|
* @param string text for log
|
|
|
|
*/
|
|
|
|
private function debug($string) {
|
|
|
|
write_log($this->logfile, $this->ipaddr . " # " . $string);
|
|
|
|
}
|
2013-02-21 00:54:15 +04:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2013-02-21 01:05:21 +04:00
|
|
|
private function isIPinArray($ip, $array) {
|
2013-02-21 00:54:15 +04:00
|
|
|
foreach ($array as $value) {
|
2013-02-21 16:39:10 +04:00
|
|
|
// 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; }
|
2013-02-21 18:02:37 +04:00
|
|
|
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; }
|
2013-02-21 00:54:15 +04:00
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
/**
|
2013-02-21 16:39:54 +04:00
|
|
|
* Check if IPv4 is within stated CIDR address
|
2013-02-21 00:54:15 +04:00
|
|
|
*
|
|
|
|
* @param string ip address
|
|
|
|
* @param string cidr address
|
|
|
|
* @return bool
|
|
|
|
*/
|
2013-02-21 16:38:38 +04:00
|
|
|
private function isIPv4inCIDR($ip, $cidr) {
|
2013-02-21 00:54:15 +04:00
|
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
|
|
return ((ip2long($ip) & ~((1 << (32 - $mask)) - 1) ) == ip2long($subnet));
|
|
|
|
}
|
2013-02-21 16:51:12 +04:00
|
|
|
/**
|
|
|
|
* Convert IPv6 mask to bytearray
|
|
|
|
*
|
|
|
|
* @param string subnet mask
|
|
|
|
* @return string byte array
|
|
|
|
*/
|
|
|
|
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 string byte array
|
|
|
|
*/
|
2013-02-21 18:02:37 +04:00
|
|
|
private function isIPv6inCIDR($ip, $cidr) {
|
|
|
|
list($subnet, $mask) = explode('/', $cidr);
|
|
|
|
$binMask = $this->IPv6MaskToByteArray($mask);
|
|
|
|
return ($ip & $binMask) == $subnet;
|
2013-02-21 16:51:12 +04:00
|
|
|
}
|
2013-02-21 16:38:38 +04:00
|
|
|
/**
|
2013-02-21 16:39:54 +04:00
|
|
|
* Check string if it is IPv6
|
2013-02-21 16:38:38 +04:00
|
|
|
*
|
|
|
|
* @param string ip address
|
|
|
|
* @return bool
|
|
|
|
*/
|
|
|
|
private function isIPv6($ip) {
|
2013-02-21 18:02:37 +04:00
|
|
|
return (((!preg_match('/^[\.\/:0-9a-f]+$/', strtolower($ip))) || (substr_count($ip, ':') < 2)) ? true : false);
|
2013-02-21 16:38:38 +04:00
|
|
|
}
|
2013-02-21 17:17:40 +04:00
|
|
|
/**
|
|
|
|
* Check string if it is IPv6
|
|
|
|
*
|
|
|
|
* @param string ip address
|
|
|
|
* @return bool
|
|
|
|
*/
|
|
|
|
private function isIPv4($ip) {
|
2013-02-21 18:02:37 +04:00
|
|
|
return ((preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}(\/[0-9]{1,2})?$/', $ip)) ? true : false);
|
2013-02-21 17:17:40 +04:00
|
|
|
}
|
2013-02-21 17:27:27 +04:00
|
|
|
/**
|
|
|
|
* 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);
|
|
|
|
}
|
2013-02-21 00:54:15 +04:00
|
|
|
/**
|
2013-02-21 16:38:38 +04:00
|
|
|
* Constructor, initialization
|
2013-02-21 00:54:15 +04:00
|
|
|
*
|
|
|
|
*/
|
|
|
|
public function init() {
|
|
|
|
|
|
|
|
// create parent class
|
|
|
|
$this->rc = rcube::get_instance();
|
|
|
|
|
|
|
|
// load configuration
|
|
|
|
$this->load_config();
|
|
|
|
|
2013-02-21 00:56:44 +04:00
|
|
|
// set config variables, set defaults
|
2013-02-21 01:49:54 +04:00
|
|
|
$this->db_table = $this->rc->config->get('defense_db_table', 'defense');
|
|
|
|
|
2013-02-21 00:56:44 +04:00
|
|
|
$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);
|
2013-02-21 01:20:42 +04:00
|
|
|
$this->ban_httpstatus = $this->rc->config->get('defense_ban_httpstatus', false);
|
2013-02-21 00:56:44 +04:00
|
|
|
$this->repeat_multiplier = $this->rc->config->get('defense_repeat_multiplier', 4);
|
|
|
|
$this->repeat_reset = $this->rc->config->get('defense_repeat_reset', 86400);
|
2013-02-21 01:49:54 +04:00
|
|
|
|
2013-02-21 00:56:44 +04:00
|
|
|
$this->db_expire = $this->rc->config->get('defense_db_expire', 40);
|
|
|
|
$this->log_pwd = $this->rc->config->get('defense_log_pwd', false);
|
2013-02-21 00:54:15 +04:00
|
|
|
|
2013-02-21 18:02:37 +04:00
|
|
|
$this->debug_enabled = $this->rc->config->get('defense_debug_enabled', false);
|
|
|
|
|
2013-02-21 00:54:15 +04:00
|
|
|
// set client ip
|
|
|
|
$this->ipaddr = rcmail_remote_ip();
|
|
|
|
|
|
|
|
// Roundcube event hooks
|
|
|
|
$this->add_hook('template_object_loginform', array($this, 'hookLoginForm'));
|
2013-02-21 01:49:54 +04:00
|
|
|
$this->add_hook('authenticate', array($this, 'hookAuthenticate'));
|
|
|
|
$this->add_hook('login_failed', array($this, 'hookLoginFailed'));
|
2013-02-21 17:27:27 +04:00
|
|
|
|
|
|
|
$this->debug("init() complete");
|
2013-02-21 00:54:15 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2013-02-21 01:49:54 +04:00
|
|
|
* Hooked function: login_form($content)
|
2013-02-21 00:54:15 +04:00
|
|
|
* Process whitelist and blacklist
|
|
|
|
*
|
|
|
|
* @param string Login form HTML
|
|
|
|
* @return string Login form HTML
|
|
|
|
*/
|
|
|
|
public function hookLoginForm($content) {
|
2013-02-21 01:29:16 +04:00
|
|
|
|
|
|
|
// set config variables, set defaults
|
|
|
|
$this->whitelist = $this->rc->config->get('defense_whitelist', array('127.0.0.1'));
|
|
|
|
$this->blacklist = $this->rc->config->get('defense_blacklist', array());
|
|
|
|
|
2013-02-21 00:54:15 +04:00
|
|
|
// If IP is listed in whitelist, return unmodified $content
|
2013-02-21 01:10:57 +04:00
|
|
|
if ($this->isIPinArray($this->ipaddr, $this->whitelist)) {
|
2013-02-21 17:27:27 +04:00
|
|
|
$this->debug("whitelisted");
|
2013-02-21 00:54:15 +04:00
|
|
|
return $content;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If IP is listed in blacklist, deny access
|
2013-02-21 01:10:57 +04:00
|
|
|
if ($this->isIPinArray($this->ipaddr, $this->blacklist)) {
|
2013-02-21 17:27:27 +04:00
|
|
|
$this->debug("blacklisted");
|
2013-02-21 00:54:15 +04:00
|
|
|
header('HTTP/1.1 403 Forbidden');
|
|
|
|
die();
|
|
|
|
}
|
2013-02-21 18:02:37 +04:00
|
|
|
$this->debug("send login form");
|
2013-02-21 00:54:15 +04:00
|
|
|
}
|
2013-02-21 01:49:54 +04:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Hooked function: authenticate($host, $user, $cookiecheck, $valid)
|
|
|
|
* Login attempt intercepted if IP is banned.
|
|
|
|
*
|
|
|
|
* @param var (untouched)
|
|
|
|
* @return var (untouched)
|
|
|
|
*/
|
|
|
|
public function hookAuthenticate($args) {
|
2013-02-21 01:54:00 +04:00
|
|
|
return $args;
|
2013-02-21 01:49:54 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Hooked function: login_failed($host, $user, $code)
|
|
|
|
* Log event to database
|
|
|
|
*
|
2013-02-21 06:49:05 +04:00
|
|
|
* @param array args [ code, host, user, abort ]
|
2013-02-21 01:49:54 +04:00
|
|
|
* @param int code
|
|
|
|
*
|
|
|
|
*/
|
2013-02-21 06:49:05 +04:00
|
|
|
public function hookLoginFailed($args) {
|
|
|
|
|
|
|
|
// Log failed login attempt
|
|
|
|
$data = array('user' => $args['user']);
|
2013-02-21 18:02:37 +04:00
|
|
|
$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() . "]");
|
2013-02-21 06:49:05 +04:00
|
|
|
// Get number of failed attempts in <fail_reset> seconds
|
|
|
|
$rTime = (time() - $this->fail_reset); // How far to look back for failed logins
|
2013-02-21 18:02:37 +04:00
|
|
|
$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; }
|
|
|
|
$this->debug($query . " [" . $result->rowCount() . "]");
|
2013-02-21 06:49:05 +04:00
|
|
|
$row = $result->fetch();
|
2013-02-21 18:02:37 +04:00
|
|
|
if (!$row) { $this->debug("Warning, SQL result empty: $query"); return; } // No rows? Strange, abort.
|
|
|
|
$this->debug("Found " . $row['n'] . " failed attempts");
|
2013-02-21 06:49:05 +04:00
|
|
|
// Check if we have too many failures
|
|
|
|
if ($row['n'] >= $this->fail_max) {
|
2013-02-21 18:02:37 +04:00
|
|
|
$this->debug("IP banned.");
|
2013-02-21 06:49:05 +04:00
|
|
|
// This IP is now banned
|
|
|
|
$repeat = 0;
|
|
|
|
|
|
|
|
// Check if its been banned before
|
2013-02-21 18:02:37 +04:00
|
|
|
$query = sprintf("SELECT epoch, data FROM %s WHERE ipaddr = '%s' AND type = %d ORDER BY id DESC LIMIT 1", $this->db_table, $this->ipaddr, 1);
|
|
|
|
$result = $this->rc->db->query($query);
|
|
|
|
if (!$result) { $this->dbError($query); return; }
|
|
|
|
$this->debug($query . " [" . $result->rowCount() . "]");
|
2013-02-21 06:49:05 +04:00
|
|
|
if ($result->rowCount() > 0) {
|
|
|
|
// IP has been banned before, check if its a recent repeat offender
|
|
|
|
$row = $result->fetch();
|
|
|
|
$data = unserialize($row['data']);
|
2013-02-21 18:02:37 +04:00
|
|
|
$this->debug("IP previous ban data: " . $row['data']);
|
2013-02-21 06:49:05 +04:00
|
|
|
// Classed as a repeate offender if IP is banned again after the previous ban duration
|
|
|
|
// multiplied by <repeat_multiplier>
|
|
|
|
if (time() <= (($data['duration'] * $this->repeat_multiplier) + $row['epoch'])) {
|
|
|
|
// Repeat offender, increase repeat
|
|
|
|
$repeat = $data['repeat'] +1;
|
2013-02-21 18:02:37 +04:00
|
|
|
$this->debug("Repeat offender. Repeat set to " . $repeat);
|
2013-02-21 06:49:05 +04:00
|
|
|
}
|
|
|
|
}
|
2013-02-21 18:02:37 +04:00
|
|
|
$duration = ($this->ban_period * ($repeat > 0 ? pow($this->repeat_multiplier,$repeat) : 1));
|
2013-02-21 06:49:05 +04:00
|
|
|
$data = array(
|
2013-02-21 18:02:37 +04:00
|
|
|
'duration' => $duration, // Ban duration based on history
|
2013-02-21 06:49:05 +04:00
|
|
|
'repeat' => $repeat
|
|
|
|
);
|
2013-02-21 18:02:37 +04:00
|
|
|
$query = sprintf("INSERT INTO %s (epoch, type, ipaddr, data) VALUES (%d, %d, '%s', '%s')", $this->db_table, time(), 1, $this->ipaddr, serialize($data));
|
2013-02-21 06:49:05 +04:00
|
|
|
$result = $this->rc->db->query($query, time(), 1, $this->ipaddr, serialize($data));
|
2013-02-21 18:02:37 +04:00
|
|
|
if (!$result) { $this->dbError($query); return; }
|
|
|
|
$this->debug($query . " [" . $result->rowCount() . "]");
|
|
|
|
$this->debug("Ban set to: " . $duration . "s");
|
2013-02-21 06:49:05 +04:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return true if logs indicate given IP is banned
|
|
|
|
*
|
|
|
|
* @param string ip
|
|
|
|
* @return bool
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
public function isBanned($ip) {
|
|
|
|
$query = "SELECT count(id) FROM " . $this->db_table . " WHERE ip = ? AND ";
|
2013-02-21 01:49:54 +04:00
|
|
|
}
|
2013-02-21 00:54:15 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
?>
|