FEATURE: improve honeypot and challenge logic

This feature amends it so instead of using one challenge and honeypot
statically per site we have a rotating honeypot and challenge value which
changes every hour.

This means you must grab a fresh copy of honeypot and challenge value once
an hour or account registration will be rejected.

We also now cycle the value of the challenge when after successful account
registration forcing an extra call to hp.json between account registrations

Client has been made aware of these changes.

Additionally this contains a JavaScript workaround for:
https://bugs.chromium.org/p/chromium/issues/detail?id=987293

This is client side code that is specific to Chrome user agent and swaps
a PASSWORD type honeypot with a TEXT type honeypot.
This commit is contained in:
Sam Saffron 2019-10-16 16:53:31 +11:00
parent 645faa847b
commit d5d8db7fa8
5 changed files with 198 additions and 98 deletions

View File

@ -24,7 +24,6 @@ export default Ember.Controller.extend(
login: Ember.inject.controller(), login: Ember.inject.controller(),
complete: false, complete: false,
accountPasswordConfirm: 0,
accountChallenge: 0, accountChallenge: 0,
formSubmitted: false, formSubmitted: false,
rejectedEmails: Ember.A([]), rejectedEmails: Ember.A([]),
@ -191,8 +190,36 @@ export default Ember.Controller.extend(
@on("init") @on("init")
fetchConfirmationValue() { fetchConfirmationValue() {
return ajax(userPath("hp.json")).then(json => { return ajax(userPath("hp.json")).then(json => {
this._challengeDate = new Date();
// remove 30 seconds for jitter, make sure this works for at least
// 30 seconds so we don't have hard loops
this._challengeExpiry = parseInt(json.expires_in, 10) - 30;
if (this._challengeExpiry < 30) {
this._challengeExpiry = 30;
}
const confirmation = document.getElementById(
"new-account-confirmation"
);
if (confirmation) {
confirmation.value = json.value;
}
// Chrome autocomplete is buggy per:
// https://bugs.chromium.org/p/chromium/issues/detail?id=987293
// work around issue while leaving a semi useable honeypot for
// bots that are running full Chrome
if (confirmation && navigator.userAgent.indexOf("Chrome") > 0) {
const newConfirmation = document.createElement("input");
newConfirmation.type = "text";
newConfirmation.id = "new-account-confirmation";
newConfirmation.value = json.value;
confirmation.parentNode.replaceChild(newConfirmation, confirmation);
}
this.setProperties({ this.setProperties({
accountPasswordConfirm: json.value,
accountChallenge: json.challenge accountChallenge: json.challenge
.split("") .split("")
.reverse() .reverse()
@ -201,85 +228,102 @@ export default Ember.Controller.extend(
}); });
}, },
performAccountCreation() {
const attrs = this.getProperties(
"accountName",
"accountEmail",
"accountPassword",
"accountUsername",
"accountChallenge"
);
attrs["accountPasswordConfirm"] = document.getElementById(
"new-account-confirmation"
).value;
const userFields = this.userFields;
const destinationUrl = this.get("authOptions.destination_url");
if (!Ember.isEmpty(destinationUrl)) {
$.cookie("destination_url", destinationUrl, { path: "/" });
}
// Add the userfields to the data
if (!Ember.isEmpty(userFields)) {
attrs.userFields = {};
userFields.forEach(
f => (attrs.userFields[f.get("field.id")] = f.get("value"))
);
}
this.set("formSubmitted", true);
return Discourse.User.createAccount(attrs).then(
result => {
this.set("isDeveloper", false);
if (result.success) {
// invalidate honeypot
this._challengeExpiry = 1;
// Trigger the browser's password manager using the hidden static login form:
const $hidden_login_form = $("#hidden-login-form");
$hidden_login_form
.find("input[name=username]")
.val(attrs.accountUsername);
$hidden_login_form
.find("input[name=password]")
.val(attrs.accountPassword);
$hidden_login_form
.find("input[name=redirect]")
.val(userPath("account-created"));
$hidden_login_form.submit();
} else {
this.flash(
result.message || I18n.t("create_account.failed"),
"error"
);
if (result.is_developer) {
this.set("isDeveloper", true);
}
if (
result.errors &&
result.errors.email &&
result.errors.email.length > 0 &&
result.values
) {
this.rejectedEmails.pushObject(result.values.email);
}
if (
result.errors &&
result.errors.password &&
result.errors.password.length > 0
) {
this.rejectedPasswords.pushObject(attrs.accountPassword);
}
this.set("formSubmitted", false);
$.removeCookie("destination_url");
}
},
() => {
this.set("formSubmitted", false);
$.removeCookie("destination_url");
return this.flash(I18n.t("create_account.failed"), "error");
}
);
},
actions: { actions: {
externalLogin(provider) { externalLogin(provider) {
this.login.send("externalLogin", provider); this.login.send("externalLogin", provider);
}, },
createAccount() { createAccount() {
const attrs = this.getProperties( if (new Date() - this._challengeDate > 1000 * this._challengeExpiry) {
"accountName", this.fetchConfirmationValue().then(() =>
"accountEmail", this.performAccountCreation()
"accountPassword",
"accountUsername",
"accountPasswordConfirm",
"accountChallenge"
);
const userFields = this.userFields;
const destinationUrl = this.get("authOptions.destination_url");
if (!Ember.isEmpty(destinationUrl)) {
$.cookie("destination_url", destinationUrl, { path: "/" });
}
// Add the userfields to the data
if (!Ember.isEmpty(userFields)) {
attrs.userFields = {};
userFields.forEach(
f => (attrs.userFields[f.get("field.id")] = f.get("value"))
); );
} else {
this.performAccountCreation();
} }
this.set("formSubmitted", true);
return Discourse.User.createAccount(attrs).then(
result => {
this.set("isDeveloper", false);
if (result.success) {
// Trigger the browser's password manager using the hidden static login form:
const $hidden_login_form = $("#hidden-login-form");
$hidden_login_form
.find("input[name=username]")
.val(attrs.accountUsername);
$hidden_login_form
.find("input[name=password]")
.val(attrs.accountPassword);
$hidden_login_form
.find("input[name=redirect]")
.val(userPath("account-created"));
$hidden_login_form.submit();
} else {
this.flash(
result.message || I18n.t("create_account.failed"),
"error"
);
if (result.is_developer) {
this.set("isDeveloper", true);
}
if (
result.errors &&
result.errors.email &&
result.errors.email.length > 0 &&
result.values
) {
this.rejectedEmails.pushObject(result.values.email);
}
if (
result.errors &&
result.errors.password &&
result.errors.password.length > 0
) {
this.rejectedPasswords.pushObject(attrs.accountPassword);
}
this.set("formSubmitted", false);
$.removeCookie("destination_url");
}
},
() => {
this.set("formSubmitted", false);
$.removeCookie("destination_url");
return this.flash(I18n.t("create_account.failed"), "error");
}
);
} }
} }
} }

View File

@ -83,7 +83,7 @@
<tr class="password-confirmation"> <tr class="password-confirmation">
<td><label for='new-account-password-confirmation'>{{i18n 'user.password_confirmation.title'}}</label></td> <td><label for='new-account-password-confirmation'>{{i18n 'user.password_confirmation.title'}}</label></td>
<td> <td>
{{input type="password" value=accountPasswordConfirm id="new-account-confirmation" autocomplete="new-password"}} <input autocomplete="new-password" id="new-account-confirmation" type="password">
{{input value=accountChallenge id="new-account-challenge"}} {{input value=accountChallenge id="new-account-challenge"}}
</td> </td>
</tr> </tr>

View File

@ -414,6 +414,9 @@ class UsersController < ApplicationController
authentication.finish authentication.finish
activation.finish activation.finish
secure_session[HONEYPOT_KEY] = nil
secure_session[CHALLENGE_KEY] = nil
# save user email in session, to show on account-created page # save user email in session, to show on account-created page
session["user_created_message"] = activation.message session["user_created_message"] = activation.message
session[SessionController::ACTIVATE_USER_KEY] = user.id session[SessionController::ACTIVATE_USER_KEY] = user.id
@ -467,7 +470,14 @@ class UsersController < ApplicationController
end end
def get_honeypot_value def get_honeypot_value
render json: { value: honeypot_value, challenge: challenge_value } secure_session.set(HONEYPOT_KEY, honeypot_value, expires: 1.hour)
secure_session.set(CHALLENGE_KEY, challenge_value, expires: 1.hour)
render json: {
value: honeypot_value,
challenge: challenge_value,
expires_in: SecureSession.expiry
}
end end
def password_reset def password_reset
@ -660,7 +670,6 @@ class UsersController < ApplicationController
security_keys_enabled = email_token_user&.security_keys_enabled? security_keys_enabled = email_token_user&.security_keys_enabled?
second_factor_token = params[:second_factor_token] second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
confirm_email = false confirm_email = false
@security_key_required = security_keys_enabled @security_key_required = security_keys_enabled
@ -1368,22 +1377,21 @@ class UsersController < ApplicationController
render json: success_json render json: success_json
end end
private HONEYPOT_KEY ||= 'HONEYPOT_KEY'
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
protected
def honeypot_value def honeypot_value
Digest::SHA1::hexdigest("#{Discourse.current_hostname}:#{GlobalSetting.safe_secret_key_base}")[0, 15] secure_session[HONEYPOT_KEY] ||= SecureRandom.hex
end end
def challenge_value def challenge_value
challenge = $redis.get('SECRET_CHALLENGE') secure_session[CHALLENGE_KEY] ||= SecureRandom.hex
unless challenge && challenge.length == 16 * 2
challenge = SecureRandom.hex(16)
$redis.set('SECRET_CHALLENGE', challenge)
end
challenge
end end
private
def respond_to_suspicious_request def respond_to_suspicious_request
if suspicious?(params) if suspicious?(params)
render json: { render json: {

View File

@ -6,15 +6,36 @@ class SecureSession
@prefix = prefix @prefix = prefix
end end
def self.expiry
@expiry ||= 1.hour.to_i
end
def self.expiry=(val)
@expiry = val
end
def set(key, val, expires: nil)
expires ||= SecureSession.expiry
$redis.setex(prefixed_key(key), SecureSession.expiry.to_i, val.to_s)
true
end
def [](key) def [](key)
$redis.get("#{@prefix}#{key}") $redis.get(prefixed_key(key))
end end
def []=(key, val) def []=(key, val)
if val == nil if val == nil
$redis.del("#{@prefix}#{key}") $redis.del(prefixed_key(key))
else else
$redis.setex("#{@prefix}#{key}", 1.hour, val.to_s) $redis.setex(prefixed_key(key), SecureSession.expiry.to_i, val.to_s)
end end
val
end
private
def prefixed_key(key)
"#{@prefix}#{key}"
end end
end end

View File

@ -5,6 +5,41 @@ require 'rails_helper'
describe UsersController do describe UsersController do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
describe "#full account registration flow" do
it "will correctly handle honeypot and challenge" do
get '/u/hp.json'
expect(response.status).to eq(200)
json = JSON.parse(response.body)
params = {
email: 'jane@jane.com',
name: 'jane',
username: 'jane',
password_confirmation: json['value'],
challenge: json['challenge'].reverse,
password: SecureRandom.hex
}
secure_session = SecureSession.new(session["secure_session_id"])
expect(secure_session[UsersController::HONEYPOT_KEY]).to eq(json["value"])
expect(secure_session[UsersController::CHALLENGE_KEY]).to eq(json["challenge"])
post '/u.json', params: params
expect(response.status).to eq(200)
jane = User.find_by(username: 'jane')
expect(jane.email).to eq('jane@jane.com')
expect(secure_session[UsersController::HONEYPOT_KEY]).to eq(nil)
expect(secure_session[UsersController::CHALLENGE_KEY]).to eq(nil)
end
end
describe '#perform_account_activation' do describe '#perform_account_activation' do
let(:token) do let(:token) do
return @token if @token.present? return @token if @token.present?
@ -1020,22 +1055,15 @@ describe UsersController do
shared_examples 'honeypot fails' do shared_examples 'honeypot fails' do
it 'should not create a new user' do it 'should not create a new user' do
User.any_instance.expects(:enqueue_welcome_message).never
expect { expect {
post "/u.json", params: create_params post "/u.json", params: create_params
}.to_not change { User.count } }.to_not change { User.count }
expect(response.status).to eq(200)
end
it 'should not send an email' do
User.any_instance.expects(:enqueue_welcome_message).never
post "/u.json", params: create_params
expect(response.status).to eq(200) expect(response.status).to eq(200)
end
it 'should say it was successful' do
post "/u.json", params: create_params
json = JSON::parse(response.body) json = JSON::parse(response.body)
expect(response.status).to eq(200)
expect(json["success"]).to eq(true) expect(json["success"]).to eq(true)
# should not change the session # should not change the session
@ -3361,7 +3389,6 @@ describe UsersController do
end end
it 'succeeds on correct password' do it 'succeeds on correct password' do
session = {}
ApplicationController.any_instance.stubs(:secure_session).returns("confirmed-password-#{user.id}" => "true") ApplicationController.any_instance.stubs(:secure_session).returns("confirmed-password-#{user.id}" => "true")
post "/users/create_second_factor_totp.json" post "/users/create_second_factor_totp.json"