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