Aleksander Machniak
2015-11-11 a15d877ba8e12ba6659aad69d63b8b73256144ad
Added brute-force attack prevention via login rate limit (#1490566)
5 files added
11 files modified
143 ■■■■■ changed files
CHANGELOG 1 ●●●● patch | view | raw | blame | history
SQL/mssql.initial.sql 4 ●●● patch | view | raw | blame | history
SQL/mssql/2015111100.sql 4 ●●●● patch | view | raw | blame | history
SQL/mysql.initial.sql 4 ●●● patch | view | raw | blame | history
SQL/mysql/2015111100.sql 3 ●●●●● patch | view | raw | blame | history
SQL/oracle.initial.sql 4 ●●● patch | view | raw | blame | history
SQL/oracle/2015111100.sql 2 ●●●●● patch | view | raw | blame | history
SQL/postgres.initial.sql 4 ●●● patch | view | raw | blame | history
SQL/postgres/2015111100.sql 2 ●●●●● patch | view | raw | blame | history
SQL/sqlite.initial.sql 4 ●●● patch | view | raw | blame | history
SQL/sqlite/2015111100.sql 35 ●●●●● patch | view | raw | blame | history
config/defaults.inc.php 4 ●●●● patch | view | raw | blame | history
index.php 1 ●●●● patch | view | raw | blame | history
program/include/rcmail.php 11 ●●●●● patch | view | raw | blame | history
program/lib/Roundcube/rcube_user.php 59 ●●●●● patch | view | raw | blame | history
program/localization/en_US/messages.inc 1 ●●●● patch | view | raw | blame | history
CHANGELOG
@@ -10,6 +10,7 @@
- PGP encryption support via Mailvelope integration
- PGP encryption support via Enigma plugin
- PHP7 compatibility fixes (#1490416)
- Security: Added brute-force attack prevention via login rate limit (#1490566)
- Security: Added options to validate username/password on logon (#1490500)
- Security: Improve randomness of security tokens (#1490529)
- Security: Use random security tokens instead of hashes based on encryption key (#1490404)
SQL/mssql.initial.sql
@@ -103,6 +103,8 @@
    [mail_host] [varchar] (128) COLLATE Latin1_General_CI_AI NOT NULL ,
    [created] [datetime] NOT NULL ,
    [last_login] [datetime] NULL ,
    [failed_login] [datetime] NULL ,
    [failed_login_counter] [int] NULL ,
    [language] [varchar] (5) COLLATE Latin1_General_CI_AI NULL ,
    [preferences] [text] COLLATE Latin1_General_CI_AI NULL 
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
@@ -393,6 +395,6 @@
    WHERE [contact_id] IN (SELECT [contact_id] FROM deleted)
GO
INSERT INTO [dbo].[system] ([name], [value]) VALUES ('roundcube-version', '2015030800')
INSERT INTO [dbo].[system] ([name], [value]) VALUES ('roundcube-version', '2015111100')
GO
SQL/mssql/2015111100.sql
New file
@@ -0,0 +1,4 @@
ALTER TABLE [dbo].[users] ADD [failed_login] [datetime] NULL
GO
ALTER TABLE [dbo].[users] ADD [failed_login_counter] [int] NULL
GO
SQL/mysql.initial.sql
@@ -24,6 +24,8 @@
 `mail_host` varchar(128) NOT NULL,
 `created` datetime NOT NULL DEFAULT '1000-01-01 00:00:00',
 `last_login` datetime DEFAULT NULL,
 `failed_login` datetime DEFAULT NULL,
 `failed_login_counter` int(10) UNSIGNED DEFAULT NULL,
 `language` varchar(5),
 `preferences` longtext,
 PRIMARY KEY(`user_id`),
@@ -209,4 +211,4 @@
/*!40014 SET FOREIGN_KEY_CHECKS=1 */;
INSERT INTO system (name, value) VALUES ('roundcube-version', '2015030800');
INSERT INTO system (name, value) VALUES ('roundcube-version', '2015111100');
SQL/mysql/2015111100.sql
New file
@@ -0,0 +1,3 @@
ALTER TABLE `users`
  ADD `failed_login` datetime DEFAULT NULL,
  ADD `failed_login_counter` int(10) UNSIGNED DEFAULT NULL;
SQL/oracle.initial.sql
@@ -7,6 +7,8 @@
    "mail_host" varchar(128) NOT NULL,
    "created" timestamp with time zone DEFAULT current_timestamp NOT NULL,
    "last_login" timestamp with time zone DEFAULT NULL,
    "failed_login" timestamp with time zone DEFAULT NULL,
    "failed_login_counter" integer DEFAULT NULL,
    "language" varchar(5),
    "preferences" long DEFAULT NULL,
    CONSTRAINT "users_username_key" UNIQUE ("username", "mail_host")
@@ -218,4 +220,4 @@
    "value" long
);
INSERT INTO "system" ("name", "value") VALUES ('roundcube-version', '2015030800');
INSERT INTO "system" ("name", "value") VALUES ('roundcube-version', '2015111100');
SQL/oracle/2015111100.sql
New file
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD "failed_login" timestamp with time zone DEFAULT NULL;
ALTER TABLE "users" ADD "failed_login_counter" integer DEFAULT NULL;
SQL/postgres.initial.sql
@@ -22,6 +22,8 @@
    mail_host varchar(128) DEFAULT '' NOT NULL,
    created timestamp with time zone DEFAULT now() NOT NULL,
    last_login timestamp with time zone DEFAULT NULL,
    failed_login timestamp with time zone DEFAULT NULL,
    failed_login_counter integer DEFAULT NULL,
    "language" varchar(5),
    preferences text DEFAULT ''::text NOT NULL,
    CONSTRAINT users_username_key UNIQUE (username, mail_host)
@@ -290,4 +292,4 @@
    value text
);
INSERT INTO system (name, value) VALUES ('roundcube-version', '2015030800');
INSERT INTO system (name, value) VALUES ('roundcube-version', '2015111100');
SQL/postgres/2015111100.sql
New file
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD failed_login timestamp with time zone DEFAULT NULL;
ALTER TABLE "users" ADD failed_login_counter integer DEFAULT NULL;
SQL/sqlite.initial.sql
@@ -72,6 +72,8 @@
  mail_host varchar(128) NOT NULL default '',
  created datetime NOT NULL default '0000-00-00 00:00:00',
  last_login datetime DEFAULT NULL,
  failed_login datetime DEFAULT NULL,
  failed_login_counter integer DEFAULT NULL,
  language varchar(5),
  preferences text NOT NULL default ''
);
@@ -201,4 +203,4 @@
  value text NOT NULL
);
INSERT INTO system (name, value) VALUES ('roundcube-version', '2015030800');
INSERT INTO system (name, value) VALUES ('roundcube-version', '2015111100');
SQL/sqlite/2015111100.sql
New file
@@ -0,0 +1,35 @@
CREATE TABLE tmp_users (
  user_id integer NOT NULL PRIMARY KEY,
  username varchar(128) NOT NULL default '',
  mail_host varchar(128) NOT NULL default '',
  created datetime NOT NULL default '0000-00-00 00:00:00',
  last_login datetime DEFAULT NULL,
  failed_login datetime DEFAULT NULL,
  failed_login_counter integer DEFAULT NULL,
  language varchar(5),
  preferences text NOT NULL default ''
);
INSERT INTO tmp_users (user_id, username, mail_host, created, last_login, language, preferences)
    SELECT user_id, username, mail_host, created, last_login, language, preferences FROM users;
DROP TABLE users;
CREATE TABLE users (
  user_id integer NOT NULL PRIMARY KEY,
  username varchar(128) NOT NULL default '',
  mail_host varchar(128) NOT NULL default '',
  created datetime NOT NULL default '0000-00-00 00:00:00',
  last_login datetime DEFAULT NULL,
  failed_login datetime DEFAULT NULL,
  failed_login_counter integer DEFAULT NULL,
  language varchar(5),
  preferences text NOT NULL default ''
);
INSERT INTO users (user_id, username, mail_host, created, last_login, language, preferences)
    SELECT user_id, username, mail_host, created, last_login, language, preferences FROM tmp_users;
CREATE UNIQUE INDEX ix_users_username ON users(username, mail_host);
DROP TABLE tmp_users;
config/defaults.inc.php
@@ -386,6 +386,10 @@
// Example: '/^[a-z0-9_@.-]+$/'
$config['login_username_filter'] = null;
// Brute-force attacks prevention.
// The value specifies maximum number of failed logon attempts per minute.
$config['login_rate_limit'] = 3;
// Includes should be interpreted as PHP files
$config['skin_include_php'] = false;
index.php
@@ -155,6 +155,7 @@
            RCMAIL::ERROR_COOKIES_DISABLED => 'cookiesdisabled',
            RCMAIL::ERROR_INVALID_REQUEST  => 'invalidrequest',
            RCMAIL::ERROR_INVALID_HOST     => 'invalidhost',
            RCMAIL::ERROR_RATE_LIMIT       => 'accountlocked',
        );
        $error_message = !empty($auth['error']) && !is_numeric($auth['error']) ? $auth['error'] : ($error_labels[$error_code] ?: 'loginfailed');
program/include/rcmail.php
@@ -60,6 +60,7 @@
    const ERROR_INVALID_REQUEST  = 1;
    const ERROR_INVALID_HOST     = 2;
    const ERROR_COOKIES_DISABLED = 3;
    const ERROR_RATE_LIMIT       = 4;
    /**
@@ -602,12 +603,22 @@
        // user already registered -> overwrite username
        if ($user = rcube_user::query($username, $host)) {
            $username = $user->data['username'];
            // Brute-force prevention
            if ($user->is_locked()) {
                $this->login_error = self::ERROR_RATE_LIMIT;
                return false;
            }
        }
        $storage = $this->get_storage();
        // try to log in
        if (!$storage->connect($host, $username, $password, $port, $ssl)) {
            if ($user) {
                $user->failed_login();
            }
            // Wait a second to slow down brute-force attacks (#1490549)
            sleep(1);
            return false;
program/lib/Roundcube/rcube_user.php
@@ -482,11 +482,61 @@
    }
    /**
     * Update user's failed_login timestamp and counter
     */
    function failed_login()
    {
        if ($this->ID && ($rate = (int) $this->rc->config->get('login_rate_limit', 3))) {
            if (empty($this->data['failed_login'])) {
                $failed_login = new DateTime('now');
                $counter      = 1;
            }
            else {
                $failed_login = new DateTime($this->data['failed_login']);
                $threshold    = new DateTime('- 60 seconds');
                if ($failed_login < $threshold) {
                    $failed_login = new DateTime('now');
                    $counter      = 1;
                }
            }
            $this->db->query(
                "UPDATE " . $this->db->table_name('users', true)
                    . " SET `failed_login` = " . $this->db->fromunixtime($failed_login->format('U'))
                    . ", `failed_login_counter` = " . ($counter ?: "`failed_login_counter` + 1")
                . " WHERE `user_id` = ?",
                $this->ID);
        }
    }
    /**
     * Checks if the account is locked, e.g. as a result of brute-force prevention
     */
    function is_locked()
    {
        if (empty($this->data['failed_login'])) {
            return false;
        }
        if ($rate = (int) $this->rc->config->get('login_rate_limit', 3)) {
            $last_failed = new DateTime($this->data['failed_login']);
            $threshold   = new DateTime('- 60 seconds');
            if ($last_failed > $threshold && $this->data['failed_login_counter'] >= $rate) {
                return true;
            }
        }
        return false;
    }
    /**
     * Clear the saved object state
     */
    function reset()
    {
        $this->ID = null;
        $this->ID   = null;
        $this->data = null;
    }
@@ -519,10 +569,11 @@
        }
        // user already registered -> overwrite username
        if ($sql_arr)
        if ($sql_arr) {
            return new rcube_user($sql_arr['user_id'], $sql_arr);
        else
            return false;
        }
        return false;
    }
    /**
program/localization/en_US/messages.inc
@@ -24,6 +24,7 @@
$messages['storageerror'] = 'Connection to storage server failed.';
$messages['servererror'] = 'Server Error!';
$messages['servererrormsg'] = 'Server Error: $msg';
$messages['accountlocked'] = 'Too many failed login attempts. Try again later.';
$messages['connerror'] = 'Connection Error (Failed to reach the server)!';
$messages['dberror'] = 'Database Error!';
$messages['windowopenerror'] = 'The popup window was blocked!';