From 49eb4d4ddf61e25c5aaab89aa630ddd3c7f3c23d Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 10 Dec 2020 06:27:26 +0100 Subject: [PATCH] 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 --- app/controllers/about_controller.rb | 5 ++++- app/controllers/auth/registrations_controller.rb | 11 +++++++---- app/controllers/concerns/registration_spam_concern.rb | 9 +++++++++ app/javascript/packs/public.js | 11 +++++++++++ app/javascript/styles/mastodon/forms.scss | 8 ++++++++ app/models/user.rb | 7 +++++++ app/validators/registration_form_time_validator.rb | 9 +++++++++ app/views/about/_registration.html.haml | 3 +++ app/views/auth/registrations/new.html.haml | 3 +++ app/views/shared/_error_messages.html.haml | 3 +++ config/locales/en.yml | 1 + config/locales/simple_form.en.yml | 1 + .../controllers/auth/registrations_controller_spec.rb | 4 ++++ 13 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 app/controllers/concerns/registration_spam_concern.rb create mode 100644 app/validators/registration_form_time_validator.rb diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index abd1ec0cb64..dcad5d3b44e 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true class AboutController < ApplicationController + include RegistrationSpamConcern + layout 'public' before_action :require_open_federation!, only: [:show, :more] before_action :set_body_classes, only: :show 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] diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index eb092419020..a3114ab2532 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -2,6 +2,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController include Devise::Controllers::Rememberable + include RegistrationSpamConcern layout :determine_layout @@ -13,6 +14,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [: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] @@ -45,16 +47,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale - resource.invite_code = params[:invite_code] if resource.invite_code.blank? - resource.sign_up_ip = request.remote_ip + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.registration_form_time = session[:registration_form_time] + resource.sign_up_ip = request.remote_ip resource.build_account if resource.account.nil? end def configure_sign_up_params 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 diff --git a/app/controllers/concerns/registration_spam_concern.rb b/app/controllers/concerns/registration_spam_concern.rb new file mode 100644 index 00000000000..af434c985a0 --- /dev/null +++ b/app/controllers/concerns/registration_spam_concern.rb @@ -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 diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 39defa7ae9e..8c5c15b8f4c 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -280,6 +280,17 @@ function main() { 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() diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index a54a5fdedf4..92d89e6f242 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -354,6 +354,7 @@ code { input[type=number], input[type=email], input[type=password], + input[type=url], textarea { box-sizing: border-box; font-size: 16px; @@ -994,3 +995,10 @@ code { flex-direction: row; } } + +.input.user_confirm_password, +.input.user_website { + &:not(.field_with_errors) { + display: none; + } +} diff --git a/app/models/user.rb b/app/models/user.rb index 94fee999ab3..981dc6d47cf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,6 +89,13 @@ class User < ApplicationRecord validates_with EmailMxValidator, if: :validate_email_dns? 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 :pending, -> { where(approved: false) } scope :approved, -> { where(approved: true) } diff --git a/app/validators/registration_form_time_validator.rb b/app/validators/registration_form_time_validator.rb new file mode 100644 index 00000000000..ba7c7e6c646 --- /dev/null +++ b/app/validators/registration_form_time_validator.rb @@ -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 diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index 5d159e9e690..6160ca4d40f 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -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_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? .fields-group = f.simple_fields_for :invite_request do |invite_request_fields| diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index cc72b87ce36..de541847f2a 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -24,6 +24,9 @@ .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 :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? .fields-group diff --git a/app/views/shared/_error_messages.html.haml b/app/views/shared/_error_messages.html.haml index 28becd6c448..4916bd424e8 100644 --- a/app/views/shared/_error_messages.html.haml +++ b/app/views/shared/_error_messages.html.haml @@ -1,3 +1,6 @@ - if object.errors.any? .flash-message.alert#error_explanation %strong= t('generic.validation_errors', count: object.errors.count) +- object.errors[:base].each do |error| + .flash-message.alert + %strong= error diff --git a/config/locales/en.yml b/config/locales/en.yml index 263ffcdc772..59f561aa337 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -751,6 +751,7 @@ en: 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. 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? use_security_key: Use security key authorize_follow: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 46a4759a856..20c91656028 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -126,6 +126,7 @@ en: expires_in: Expire after fields: Profile metadata header: Header + honeypot: "%{label} (do not fill in)" inbox_url: URL of the relay inbox irreversible: Drop instead of hide locale: Interface language diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index bef82276372..c701a3b8bc8 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -82,6 +82,10 @@ RSpec.describe Auth::RegistrationsController, type: :controller do describe 'POST #create' do 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| current_locale = I18n.locale example.run