diff --git a/app-rails/app/controllers/users/passwords_controller.rb b/app-rails/app/controllers/users/passwords_controller.rb index 3f262b6..7068486 100644 --- a/app-rails/app/controllers/users/passwords_controller.rb +++ b/app-rails/app/controllers/users/passwords_controller.rb @@ -9,7 +9,8 @@ def forgot def send_reset_password_instructions email = params[:users_forgot_password_form][:email] - @form = Users::ForgotPasswordForm.new(email: email) + spam_trap = params[:users_forgot_password_form][:spam_trap] + @form = Users::ForgotPasswordForm.new(email: email, spam_trap: spam_trap) if @form.invalid? flash.now[:errors] = @form.errors.full_messages @@ -59,6 +60,6 @@ def auth_service end def reset_password_params - params.require(:users_reset_password_form).permit(:email, :code, :password) + params.require(:users_reset_password_form).permit(:email, :code, :password, :spam_trap) end end diff --git a/app-rails/app/controllers/users/registrations_controller.rb b/app-rails/app/controllers/users/registrations_controller.rb index 03746ab..5d1785f 100644 --- a/app-rails/app/controllers/users/registrations_controller.rb +++ b/app-rails/app/controllers/users/registrations_controller.rb @@ -77,7 +77,7 @@ def auth_service end def registration_params - params.require(:users_registration_form).permit(:email, :password, :password_confirmation, :role) + params.require(:users_registration_form).permit(:email, :password, :password_confirmation, :role, :spam_trap) end def verify_account_params diff --git a/app-rails/app/controllers/users/sessions_controller.rb b/app-rails/app/controllers/users/sessions_controller.rb index 556f146..8f4a51c 100644 --- a/app-rails/app/controllers/users/sessions_controller.rb +++ b/app-rails/app/controllers/users/sessions_controller.rb @@ -94,7 +94,7 @@ def auth_service def new_session_params # If :users_new_session_form is renamed, make sure to also update it in # cognito_authenticatable.rb otherwise login will not work. - params.require(:users_new_session_form).permit(:email, :password) + params.require(:users_new_session_form).permit(:email, :password, :spam_trap) end # This is similar to the default Devise SessionController implementation diff --git a/app-rails/app/forms/users/forgot_password_form.rb b/app-rails/app/forms/users/forgot_password_form.rb index 3b26d30..10f262d 100644 --- a/app-rails/app/forms/users/forgot_password_form.rb +++ b/app-rails/app/forms/users/forgot_password_form.rb @@ -1,7 +1,9 @@ class Users::ForgotPasswordForm include ActiveModel::Model - attr_accessor :email + attr_accessor :email, :spam_trap validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } + + validates :spam_trap, absence: true end diff --git a/app-rails/app/forms/users/new_session_form.rb b/app-rails/app/forms/users/new_session_form.rb index 6135b62..ccdd82c 100644 --- a/app-rails/app/forms/users/new_session_form.rb +++ b/app-rails/app/forms/users/new_session_form.rb @@ -3,8 +3,10 @@ class Users::NewSessionForm include ActiveModel::Model - attr_accessor :email, :password + attr_accessor :email, :password, :spam_trap validates :email, :password, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, if: -> { email.present? } + + validates :spam_trap, absence: true end diff --git a/app-rails/app/forms/users/registration_form.rb b/app-rails/app/forms/users/registration_form.rb index 250ac6d..414a901 100644 --- a/app-rails/app/forms/users/registration_form.rb +++ b/app-rails/app/forms/users/registration_form.rb @@ -3,10 +3,12 @@ class Users::RegistrationForm include ActiveModel::Model - attr_accessor :email, :password, :password_confirmation, :role + attr_accessor :email, :password, :password_confirmation, :role, :spam_trap validates :email, :password, :role, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, if: -> { email.present? } validates :password, confirmation: true, if: -> { password.present? } + + validates :spam_trap, absence: true end diff --git a/app-rails/app/forms/users/reset_password_form.rb b/app-rails/app/forms/users/reset_password_form.rb index 5fdab55..3131b21 100644 --- a/app-rails/app/forms/users/reset_password_form.rb +++ b/app-rails/app/forms/users/reset_password_form.rb @@ -3,9 +3,11 @@ class Users::ResetPasswordForm include ActiveModel::Model - attr_accessor :email, :password, :code + attr_accessor :email, :password, :code, :spam_trap validates :email, :password, :code, presence: true validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, if: -> { email.present? } validates :code, length: { is: 6 }, if: -> { code.present? } + + validates :spam_trap, absence: true end diff --git a/app-rails/app/helpers/uswds_form_builder.rb b/app-rails/app/helpers/uswds_form_builder.rb index 8d9ff9b..4a56053 100644 --- a/app-rails/app/helpers/uswds_form_builder.rb +++ b/app-rails/app/helpers/uswds_form_builder.rb @@ -78,6 +78,16 @@ def submit(value = nil, options = {}) super(value, options) end + def honeypot_field + spam_trap_classes = "opacity-0 position-absolute z-bottom top-0 left-0 height-0 width-0" + label_text = "Do not fill in this field. It is an anti-spam measure." + + @template.content_tag(:div, class: "usa-form-group #{spam_trap_classes}") do + label(:spam_trap, label_text, { tabindex: -1, class: "usa-label #{spam_trap_classes}" }) + + @template.text_field(@object_name, :spam_trap, { autocomplete: "false", tabindex: -1, class: "usa-input #{spam_trap_classes}" }) + end + end + ######################################## # Custom helpers ######################################## diff --git a/app-rails/app/views/users/passwords/forgot.html.erb b/app-rails/app/views/users/passwords/forgot.html.erb index 6efd207..286b880 100644 --- a/app-rails/app/views/users/passwords/forgot.html.erb +++ b/app-rails/app/views/users/passwords/forgot.html.erb @@ -5,6 +5,7 @@ <%= us_form_with model: @form, url: users_forgot_password_path, local: true do |f| %> <%= f.email_field :email, { autocomplete: "username" } %> + <%= f.honeypot_field %> <%= f.submit t(".submit") %> <% end %> \ No newline at end of file diff --git a/app-rails/app/views/users/passwords/reset.html.erb b/app-rails/app/views/users/passwords/reset.html.erb index cf187f2..8cf3003 100644 --- a/app-rails/app/views/users/passwords/reset.html.erb +++ b/app-rails/app/views/users/passwords/reset.html.erb @@ -12,6 +12,7 @@

<%= t(".title") %>

<%= us_form_with model: @form, url: users_reset_password_path, local: true do |f| %> + <%= f.honeypot_field %> <%= f.text_field :code, { autocomplete: "off", label: t('.code'), width: "md" } %> <%= f.email_field :email, { autocomplete: "username" } %> diff --git a/app-rails/app/views/users/registrations/new.html.erb b/app-rails/app/views/users/registrations/new.html.erb index 292d383..5361e66 100644 --- a/app-rails/app/views/users/registrations/new.html.erb +++ b/app-rails/app/views/users/registrations/new.html.erb @@ -13,6 +13,7 @@ <%= us_form_with model: @form, url: users_registrations_path, local: true do |f| %> <%= f.hidden_field :role %> + <%= f.honeypot_field %> <%= f.email_field :email %> <%= f.password_field :password, autocomplete: "new-password", id: "new-password", hint: t("users.password_hint") %> diff --git a/app-rails/app/views/users/sessions/new.html.erb b/app-rails/app/views/users/sessions/new.html.erb index bdafacd..ba29478 100644 --- a/app-rails/app/views/users/sessions/new.html.erb +++ b/app-rails/app/views/users/sessions/new.html.erb @@ -6,6 +6,7 @@ <%= us_form_with model: @form, url: new_user_session_path, local: true do |f| %> + <%= f.honeypot_field %> <%= f.email_field :email, { autocomplete: "username" } %> <%= f.password_field :password, {id: "password", autocomplete: "current-password"} %> diff --git a/app-rails/config/locales/defaults/en.yml b/app-rails/config/locales/defaults/en.yml index 8dad02d..7c588c6 100644 --- a/app-rails/config/locales/defaults/en.yml +++ b/app-rails/config/locales/defaults/en.yml @@ -46,6 +46,10 @@ en: default: "%m/%d/%Y" short: "%b %d" long: "%B %d, %Y" + errors: + attributes: + spam_trap: + present: "This field should be left empty. It is intended to prevent bots from submitting spam using this form." helpers: submit: create: "Submit" diff --git a/app-rails/spec/controllers/users/passwords_controller_spec.rb b/app-rails/spec/controllers/users/passwords_controller_spec.rb index ce0895c..1238866 100644 --- a/app-rails/spec/controllers/users/passwords_controller_spec.rb +++ b/app-rails/spec/controllers/users/passwords_controller_spec.rb @@ -44,6 +44,15 @@ expect(response.status).to eq(422) end + + it "handles submission by bots" do + post :send_reset_password_instructions, params: { + users_forgot_password_form: { email: "UsernameExists@example.com", spam_trap: "I am a bot" }, + locale: "en" + } + + expect(response.status).to eq(422) + end end describe "GET reset" do @@ -95,5 +104,19 @@ expect(response.status).to eq(422) end + + it "handles submission by bots" do + post :confirm_reset, params: { + users_reset_password_form: { + email: "test@example.com", + code: "123456", + password: "password", + spam_trap: "I am a bot" + }, + locale: "en" + } + + expect(response.status).to eq(422) + end end end diff --git a/app-rails/spec/controllers/users/registrations_controller_spec.rb b/app-rails/spec/controllers/users/registrations_controller_spec.rb index 1043e88..b7c1cd6 100644 --- a/app-rails/spec/controllers/users/registrations_controller_spec.rb +++ b/app-rails/spec/controllers/users/registrations_controller_spec.rb @@ -71,6 +71,22 @@ expect(response.status).to eq(422) end + + it "handles submission by bots" do + email = "test@example.com" + + post :create, params: { + users_registration_form: { + email: email, + password: "password", + role: "employer", + spam_trap: "I am a bot" + }, + locale: "en" + } + + expect(response.status).to eq(422) + end end describe "GET new_account_verification" do diff --git a/app-rails/spec/controllers/users/sessions_controller_spec.rb b/app-rails/spec/controllers/users/sessions_controller_spec.rb index 94bf528..e05124a 100644 --- a/app-rails/spec/controllers/users/sessions_controller_spec.rb +++ b/app-rails/spec/controllers/users/sessions_controller_spec.rb @@ -91,6 +91,21 @@ expect(session[:challenge_email]).to eq("mfa@example.com") expect(response).to redirect_to(session_challenge_path) end + + it "handles submission by bots" do + create(:user, uid: uid) + + post :create, params: { + users_new_session_form: { + email: "test@example.com", + password: "password", + spam_trap: "I am a bot" + }, + locale: "en" + } + + expect(response.status).to eq(422) + end end describe "GET challenge" do diff --git a/app-rails/spec/forms/users/new_session_form_spec.rb b/app-rails/spec/forms/users/new_session_form_spec.rb index f9b6107..80c6f55 100644 --- a/app-rails/spec/forms/users/new_session_form_spec.rb +++ b/app-rails/spec/forms/users/new_session_form_spec.rb @@ -29,4 +29,15 @@ expect(form).not_to be_valid expect(form.errors.of_kind?(:email, :invalid)).to be_truthy end + + it "requires the honeypot field to be empty" do + form = Users::NewSessionForm.new( + email: "test@example.com", + password: "password", + spam_trap: "I am a bot" + ) + + expect(form).not_to be_valid + expect(form.errors.of_kind?(:spam_trap, :present)).to be_truthy + end end diff --git a/app-rails/spec/forms/users/registration_form_spec.rb b/app-rails/spec/forms/users/registration_form_spec.rb index 33a6365..c2921b0 100644 --- a/app-rails/spec/forms/users/registration_form_spec.rb +++ b/app-rails/spec/forms/users/registration_form_spec.rb @@ -36,4 +36,13 @@ expect(form).not_to be_valid expect(form.errors.of_kind?(:email, :invalid)).to be_truthy end + + it "requires the honeypot field is empty" do + form.email = "test@example.com" + form.password = valid_password + form.spam_trap = "I am a bot" + + expect(form).not_to be_valid + expect(form.errors.of_kind?(:spam_trap, :present)).to be_truthy + end end diff --git a/docs/app-rails/application-security.md b/docs/app-rails/application-security.md index eec0102..389f723 100644 --- a/docs/app-rails/application-security.md +++ b/docs/app-rails/application-security.md @@ -49,7 +49,7 @@ There is currently no file upload or download functionality at this time, so ple - [x] Use a secondary verification when users change their password - Note: Change password requires 6 digit code from email sent to user's email address. - [ ] Require user's password when changing email. -- [ ] Include honeypot fields and logic on Non logged in forms to catch bots that spam all fields (good resource: https://nedbatchelder.com/text/stopbots.html). +- [x] Include honeypot fields and logic on Non logged in forms to catch bots that spam all fields (good resource: https://nedbatchelder.com/text/stopbots.html). - [ ] Consider using Captcha on account creation, login, change password, and change email forms. - Note: Captchas are often not accessible to screen readers and their use should be part of a UX discussion. - [x] Filter log entries so they do not include passwords or secrets