Add honeypot fields and minimum fill-out time for sign-up form (#15276)

* Add honeypot fields to limit non-specialized spam

Add two honeypot fields: a fake website input and a fake password confirmation
one. The label/placeholder/aria-label tells not to fill them, and they are
hidden in CSS, so legitimate users should not fall into these.

This should cut down on some non-Mastodon-specific spambots.

* Require a 3 seconds delay before submitting the registration form

* Fix tests

* Move registration form time check to model validation

* Give people a chance to clear the honeypot fields

* Refactor honeypot translation strings

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
ThibG 2020-12-10 06:27:26 +01:00 committed by GitHub
parent 9669167aae
commit 49eb4d4ddf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 70 additions and 5 deletions

View file

@ -1,12 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class AboutController < ApplicationController class AboutController < ApplicationController
include RegistrationSpamConcern
layout 'public' layout 'public'
before_action :require_open_federation!, only: [:show, :more] before_action :require_open_federation!, only: [:show, :more]
before_action :set_body_classes, only: :show before_action :set_body_classes, only: :show
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_expires_in, only: [:show, :more, :terms] before_action :set_expires_in, only: [:more, :terms]
before_action :set_registration_form_time, only: :show
skip_before_action :require_functional!, only: [:more, :terms] skip_before_action :require_functional!, only: [:more, :terms]

View file

@ -2,6 +2,7 @@
class Auth::RegistrationsController < Devise::RegistrationsController class Auth::RegistrationsController < Devise::RegistrationsController
include Devise::Controllers::Rememberable include Devise::Controllers::Rememberable
include RegistrationSpamConcern
layout :determine_layout layout :determine_layout
@ -13,6 +14,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update] before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update] before_action :set_cache_headers, only: [:edit, :update]
before_action :set_registration_form_time, only: :new
skip_before_action :require_functional!, only: [:edit, :update] skip_before_action :require_functional!, only: [:edit, :update]
@ -45,16 +47,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def build_resource(hash = nil) def build_resource(hash = nil)
super(hash) super(hash)
resource.locale = I18n.locale resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank? resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.sign_up_ip = request.remote_ip resource.registration_form_time = session[:registration_form_time]
resource.sign_up_ip = request.remote_ip
resource.build_account if resource.account.nil? resource.build_account if resource.account.nil?
end end
def configure_sign_up_params def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |u| devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement) u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
end end
end end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
module RegistrationSpamConcern
extend ActiveSupport::Concern
def set_registration_form_time
session[:registration_form_time] = Time.now.utc
end
end

View file

@ -280,6 +280,17 @@ function main() {
target.style.display = 'block'; target.style.display = 'block';
} }
}); });
// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
delegate(document, '#registration_new_user,#new_user', 'submit', () => {
['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
const field = document.getElementById(id);
if (field) {
field.value = '';
}
});
});
} }
loadPolyfills() loadPolyfills()

View file

@ -354,6 +354,7 @@ code {
input[type=number], input[type=number],
input[type=email], input[type=email],
input[type=password], input[type=password],
input[type=url],
textarea { textarea {
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 16px;
@ -994,3 +995,10 @@ code {
flex-direction: row; flex-direction: row;
} }
} }
.input.user_confirm_password,
.input.user_website {
&:not(.field_with_errors) {
display: none;
}
}

View file

@ -89,6 +89,13 @@ class User < ApplicationRecord
validates_with EmailMxValidator, if: :validate_email_dns? validates_with EmailMxValidator, if: :validate_email_dns?
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
# Those are honeypot/antispam fields
attr_accessor :registration_form_time, :website, :confirm_password
validates_with RegistrationFormTimeValidator, on: :create
validates :website, absence: true, on: :create
validates :confirm_password, absence: true, on: :create
scope :recent, -> { order(id: :desc) } scope :recent, -> { order(id: :desc) }
scope :pending, -> { where(approved: false) } scope :pending, -> { where(approved: false) }
scope :approved, -> { where(approved: true) } scope :approved, -> { where(approved: true) }

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class RegistrationFormTimeValidator < ActiveModel::Validator
REGISTRATION_FORM_MIN_TIME = 3.seconds.freeze
def validate(user)
user.errors.add(:base, I18n.t('auth.too_fast')) if user.registration_form_time.present? && user.registration_form_time > REGISTRATION_FORM_MIN_TIME.ago
end
end

View file

@ -10,6 +10,9 @@
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations? = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations?
= f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
= f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
= f.input :website, as: :url, placeholder: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
- if approved_registrations? - if approved_registrations?
.fields-group .fields-group
= f.simple_fields_for :invite_request do |invite_request_fields| = f.simple_fields_for :invite_request do |invite_request_fields|

View file

@ -24,6 +24,9 @@
.fields-group .fields-group
= f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
= f.input :confirm_password, as: :string, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }
= f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }
- if approved_registrations? && !@invite.present? - if approved_registrations? && !@invite.present?
.fields-group .fields-group

View file

@ -1,3 +1,6 @@
- if object.errors.any? - if object.errors.any?
.flash-message.alert#error_explanation .flash-message.alert#error_explanation
%strong= t('generic.validation_errors', count: object.errors.count) %strong= t('generic.validation_errors', count: object.errors.count)
- object.errors[:base].each do |error|
.flash-message.alert
%strong= error

View file

@ -751,6 +751,7 @@ en:
functional: Your account is fully operational. functional: Your account is fully operational.
pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved. pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved.
redirecting_to: Your account is inactive because it is currently redirecting to %{acct}. redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
too_fast: Form submitted too fast, try again.
trouble_logging_in: Trouble logging in? trouble_logging_in: Trouble logging in?
use_security_key: Use security key use_security_key: Use security key
authorize_follow: authorize_follow:

View file

@ -126,6 +126,7 @@ en:
expires_in: Expire after expires_in: Expire after
fields: Profile metadata fields: Profile metadata
header: Header header: Header
honeypot: "%{label} (do not fill in)"
inbox_url: URL of the relay inbox inbox_url: URL of the relay inbox
irreversible: Drop instead of hide irreversible: Drop instead of hide
locale: Interface language locale: Interface language

View file

@ -82,6 +82,10 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
describe 'POST #create' do describe 'POST #create' do
let(:accept_language) { Rails.application.config.i18n.available_locales.sample.to_s } let(:accept_language) { Rails.application.config.i18n.available_locales.sample.to_s }
before do
session[:registration_form_time] = 5.seconds.ago
end
around do |example| around do |example|
current_locale = I18n.locale current_locale = I18n.locale
example.run example.run