From a15d877ba8e12ba6659aad69d63b8b73256144ad Mon Sep 17 00:00:00 2001
From: Aleksander Machniak <alec@alec.pl>
Date: Wed, 11 Nov 2015 12:53:43 -0500
Subject: [PATCH] Added brute-force attack prevention via login rate limit (#1490566)

---
 CHANGELOG                               |    1 
 SQL/mysql/2015111100.sql                |    3 +
 SQL/postgres/2015111100.sql             |    2 
 SQL/sqlite.initial.sql                  |    4 +
 SQL/oracle/2015111100.sql               |    2 
 SQL/oracle.initial.sql                  |    4 +
 SQL/sqlite/2015111100.sql               |   35 +++++++++++
 index.php                               |    1 
 program/include/rcmail.php              |   11 +++
 program/localization/en_US/messages.inc |    1 
 program/lib/Roundcube/rcube_user.php    |   59 ++++++++++++++++++-
 SQL/postgres.initial.sql                |    4 +
 SQL/mysql.initial.sql                   |    4 +
 SQL/mssql.initial.sql                   |    4 +
 SQL/mssql/2015111100.sql                |    4 +
 config/defaults.inc.php                 |    4 +
 16 files changed, 134 insertions(+), 9 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG
index d450dfe..a53afbf 100644
--- a/CHANGELOG
+++ b/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)
diff --git a/SQL/mssql.initial.sql b/SQL/mssql.initial.sql
index 8002170..181d61e 100644
--- a/SQL/mssql.initial.sql
+++ b/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
 
\ No newline at end of file
diff --git a/SQL/mssql/2015111100.sql b/SQL/mssql/2015111100.sql
new file mode 100644
index 0000000..11c9da3
--- /dev/null
+++ b/SQL/mssql/2015111100.sql
@@ -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
diff --git a/SQL/mysql.initial.sql b/SQL/mysql.initial.sql
index 721c839..df1d015 100644
--- a/SQL/mysql.initial.sql
+++ b/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');
diff --git a/SQL/mysql/2015111100.sql b/SQL/mysql/2015111100.sql
new file mode 100644
index 0000000..08ea8fc
--- /dev/null
+++ b/SQL/mysql/2015111100.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `users`
+  ADD `failed_login` datetime DEFAULT NULL,
+  ADD `failed_login_counter` int(10) UNSIGNED DEFAULT NULL;
diff --git a/SQL/oracle.initial.sql b/SQL/oracle.initial.sql
index 0110abf..82ee1d8 100644
--- a/SQL/oracle.initial.sql
+++ b/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');
diff --git a/SQL/oracle/2015111100.sql b/SQL/oracle/2015111100.sql
new file mode 100644
index 0000000..010203f
--- /dev/null
+++ b/SQL/oracle/2015111100.sql
@@ -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;
diff --git a/SQL/postgres.initial.sql b/SQL/postgres.initial.sql
index ba53112..2fc85c3 100644
--- a/SQL/postgres.initial.sql
+++ b/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');
diff --git a/SQL/postgres/2015111100.sql b/SQL/postgres/2015111100.sql
new file mode 100644
index 0000000..3eea6d5
--- /dev/null
+++ b/SQL/postgres/2015111100.sql
@@ -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;
diff --git a/SQL/sqlite.initial.sql b/SQL/sqlite.initial.sql
index a7426c1..4c1b779 100644
--- a/SQL/sqlite.initial.sql
+++ b/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');
diff --git a/SQL/sqlite/2015111100.sql b/SQL/sqlite/2015111100.sql
new file mode 100644
index 0000000..34df0ea
--- /dev/null
+++ b/SQL/sqlite/2015111100.sql
@@ -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;
diff --git a/config/defaults.inc.php b/config/defaults.inc.php
index 40486c5..4339523 100644
--- a/config/defaults.inc.php
+++ b/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;
 
diff --git a/index.php b/index.php
index 1ebbdca..9dcf773 100644
--- a/index.php
+++ b/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');
diff --git a/program/include/rcmail.php b/program/include/rcmail.php
index 21ba812..bdf9405 100644
--- a/program/include/rcmail.php
+++ b/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;
diff --git a/program/lib/Roundcube/rcube_user.php b/program/lib/Roundcube/rcube_user.php
index ac245d3..bda6e54 100644
--- a/program/lib/Roundcube/rcube_user.php
+++ b/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;
     }
 
     /**
diff --git a/program/localization/en_US/messages.inc b/program/localization/en_US/messages.inc
index 313d6ae..88db211 100644
--- a/program/localization/en_US/messages.inc
+++ b/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!';

--
Gitblit v1.9.1