Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 98 additions & 146 deletions app/controllers/submissions_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# frozen_string_literal: true

require 'uri'

class SubmissionsController < ApplicationController
before_action :set_form, only: %i[new create]
append_before_action :verify_authenticity_token, if: :form_requires_verification
Expand All @@ -26,112 +24,112 @@ def create
headers['Access-Control-Request-Method'] = '*'
headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization'

# Catch SPAMMERS
if @form && submission_params[:fba_directive].present?
ActiveSupport::Notifications.instrument('spam_subverted') do |payload|
payload[:request] = request
end

head :ok and return
end

# Check referer for unauthorized submissions
# Use submission_params[:page] to identify admin preview pages even when session is not available via AJAX
submission_referer = request.referer.presence || submission_params[:referer].presence
is_admin_preview = submission_params[:page]&.start_with?('/admin/forms/') && submission_params[:page].include?('/example')

if @form && current_user.blank? && !is_admin_preview && submission_referer.present? && !allowed_submission_referer?(submission_referer)
error_options = {
custom_params: {
referer: submission_referer,
},
expected: true,
}
NewRelic::Agent.notice_error(ArgumentError, error_options)

render json: {
status: :unprocessable_content,
messages: { submission: [t('errors.request.unauthorized_host')] },
}, status: :unprocessable_content and return
end

# debug logging removed
@submission = Submission.new(submission_params)
@submission.form = @form
@submission.user_agent = request.user_agent
@submission.referer = submission_params[:referer]
@submission.page = submission_params[:page]

@submission.ip_address = request.remote_ip if @form.organization.enable_ip_address?
create_in_local_database(@submission)
end

private

def create_in_local_database(submission)
if submission.form.enable_turnstile?
if verify_turnstile(params[:cf_turnstile_response])
submission.spam_prevention_mechanism = :turnstile
spam_result = SpamChecker.new.call(@submission, spam_context)
if spam_result.verdict == :reject
if Rails.configuration.x.silently_reject_spam
skip_save = true
@submission.uuid = SecureRandom.uuid
else
submission.errors.add(:base, 'Turnstile verification failed')
# Loud rejection is intended for dev/test/CI only (see config.x.silently_reject_spam),
# so the response is deliberately explicit to aid debugging and test assertions.
respond_to do |format|
format.html { head :unprocessable_content }
format.json do
render json: {
status: :unprocessable_content,
messages: { submission: ['Submission rejected as spam'] },
}, status: :unprocessable_content
end
end
return
end
elsif spam_result.verdict == :surface
# Likely automated but potentially a false positive the user can correct
# (e.g. a missing/expired Turnstile token). Return a recoverable error so
# the client can prompt them to try again rather than silently dropping it.
respond_to do |format|
format.html { head :unprocessable_content }
format.json do
render json: {
status: :unprocessable_content,
messages: { submission: [t('errors.request.spam_surface')] },
}, status: :unprocessable_content
end
end
return
elsif spam_result.verdict == :flag
@submission.spam = true
@submission.spam_determination =
{
'source' => 'automated',
'reasons' => spam_result.flagged,
}
end

@submission.spam_prevention_mechanism = spam_result.applicable.join(', ')

respond_to do |format|
if submission.errors.empty? && submission.save
if skip_save || @submission.save
format.html do
redirect_to submit_touchpoint_path(submission.form),
redirect_to submit_touchpoint_path(@submission.form),
notice: 'Thank You. Response was submitted successfully.'
end
format.json do
form_success_text = if submission.form.append_id_to_success_text?
submission.form.success_text + "<br><br> Your Response ID is: <strong>#{submission.uuid[-12..-1]}</strong>"
form_success_text = if @submission.form.append_id_to_success_text?
(@submission.form.success_text || '') + "<br><br> Your Response ID is: <strong>#{@submission.uuid[-12..-1]}</strong>"
else
submission.form.success_text
@submission.form.success_text
end

render json: {
submission: {
id: submission.uuid,
answer_01: submission.answer_01,
answer_02: submission.answer_02,
answer_03: submission.answer_03,
answer_04: submission.answer_04,
answer_05: submission.answer_05,
answer_06: submission.answer_06,
answer_07: submission.answer_07,
answer_08: submission.answer_08,
answer_09: submission.answer_09,
answer_10: submission.answer_10,
answer_11: submission.answer_11,
answer_12: submission.answer_12,
answer_13: submission.answer_13,
answer_14: submission.answer_14,
answer_15: submission.answer_15,
answer_16: submission.answer_16,
answer_17: submission.answer_17,
answer_18: submission.answer_18,
answer_19: submission.answer_19,
answer_20: submission.answer_20,
answer_21: submission.answer_21,
answer_22: submission.answer_22,
answer_23: submission.answer_23,
answer_24: submission.answer_24,
answer_25: submission.answer_25,
answer_26: submission.answer_26,
answer_27: submission.answer_27,
answer_28: submission.answer_28,
answer_29: submission.answer_29,
answer_30: submission.answer_30,
form: {
id: submission.form.uuid,
name: submission.form.name,
organization_name: submission.organization_name,
success_text_heading: submission.form.success_text_heading,
success_text: form_success_text,
},
},
},
submission: {
id: @submission.uuid,
answer_01: @submission.answer_01,
answer_02: @submission.answer_02,
answer_03: @submission.answer_03,
answer_04: @submission.answer_04,
answer_05: @submission.answer_05,
answer_06: @submission.answer_06,
answer_07: @submission.answer_07,
answer_08: @submission.answer_08,
answer_09: @submission.answer_09,
answer_10: @submission.answer_10,
answer_11: @submission.answer_11,
answer_12: @submission.answer_12,
answer_13: @submission.answer_13,
answer_14: @submission.answer_14,
answer_15: @submission.answer_15,
answer_16: @submission.answer_16,
answer_17: @submission.answer_17,
answer_18: @submission.answer_18,
answer_19: @submission.answer_19,
answer_20: @submission.answer_20,
answer_21: @submission.answer_21,
answer_22: @submission.answer_22,
answer_23: @submission.answer_23,
answer_24: @submission.answer_24,
answer_25: @submission.answer_25,
answer_26: @submission.answer_26,
answer_27: @submission.answer_27,
answer_28: @submission.answer_28,
answer_29: @submission.answer_29,
answer_30: @submission.answer_30,
form: {
id: @submission.form.uuid,
name: @submission.form.name,
organization_name: @submission.organization_name,
success_text_heading: @submission.form.success_text_heading,
success_text: form_success_text,
},
},
},
status: :created
end
else
Expand All @@ -140,13 +138,15 @@ def create_in_local_database(submission)
format.json do
render json: {
status: :unprocessable_content,
messages: submission.errors,
messages: @submission.errors,
}, status: :unprocessable_content
end
end
end
end

private

def set_form
if params[:form]
@short_uuid = params[:id].to_s
Expand All @@ -165,67 +165,19 @@ def set_form
def submission_params
permitted_fields = @form.questions.collect(&:answer_field)
permitted_fields << %i[language location_code referer hostname page query_string fba_directive]
permitted_fields << %i[cf_turnstile_response]
params.require(:submission).permit(permitted_fields)
end

def form_requires_verification
@form.verify_csrf?
end

def allowed_submission_referer?(referer)
allowlisted_prefixes = submission_whitelist_prefixes.compact

return true if allowlisted_prefixes.any? { |prefix| referer.start_with?(prefix) }

referer_host_matches_application?(referer)
def spam_context
{
referer: request.referer,
remote_ip: request.remote_ip,
cf_turnstile_response: params[:cf_turnstile_response],
root_url: root_url,
}
end

def submission_whitelist_prefixes
whitelist_attributes = %i[
whitelist_url
whitelist_url_1
whitelist_url_2
whitelist_url_3
whitelist_url_4
whitelist_url_5
whitelist_url_6
whitelist_url_7
whitelist_url_8
whitelist_url_9
whitelist_test_url
]

prefixes = whitelist_attributes.filter_map do |attr|
value = @form.public_send(attr)
value.presence
end
prefixes << root_url
prefixes << request.base_url if request.base_url.present?
prefixes << @form.organization&.url
# Allow submissions from admin preview page for authorized users
prefixes << "#{request.base_url}/admin/forms/" if current_user.present?
prefixes
end

def referer_host_matches_application?(referer)
uri = URI.parse(referer)
uri.host == request.host
rescue URI::InvalidURIError
false
end

def verify_turnstile(response_token)
secret_key = ENV.fetch('TURNSTILE_SECRET_KEY', nil)
uri = URI('https://challenges.cloudflare.com/turnstile/v0/siteverify')

response = Net::HTTP.post_form(uri, {
'secret' => secret_key,
'response' => response_token,
'remoteip' => request.remote_ip,
})

json = JSON.parse(response.body)
json['success'] == true
def form_requires_verification
@form.verify_csrf?
end
end
4 changes: 4 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -244,5 +244,9 @@ def form_integrity_checksum(form:)
Digest::SHA256.base64digest(data_to_encode)
end

def boolean_to_text(value)
value ? 'Yes' : 'No'
end

delegate :fiscal_year_and_quarter, to: :FiscalYear
end
17 changes: 17 additions & 0 deletions app/helpers/spam_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

# View helpers for presenting spam-check results in the admin interface.
#
# The human-readable copy lives with each check in app/services/spam_checks/;
# these helpers just look up the check by its stored reason identifier (as
# found in submission.spam_determination["reasons"]).
module SpamHelper
def spam_reason_label(reason)
check = SpamChecks::Base.for(reason)
check ? check.label : reason.to_s.humanize
end

def spam_reason_description(reason)
SpamChecks::Base.for(reason)&.description
end
end
Loading