From 8b0d5c52e831278f10f8323df3befe73c2dc1511 Mon Sep 17 00:00:00 2001 From: Shelley Nason Date: Fri, 7 Aug 2026 12:38:38 -0400 Subject: [PATCH] Temp commit message --- app/controllers/submissions_controller.rb | 244 +++++++----------- app/helpers/application_helper.rb | 4 + app/helpers/spam_helper.rb | 17 ++ app/services/spam_checker.rb | 78 ++++++ app/services/spam_checks/base.rb | 88 +++++++ app/services/spam_checks/honeypot.rb | 22 ++ app/services/spam_checks/referer.rb | 79 ++++++ app/services/spam_checks/turnstile.rb | 77 ++++++ app/services/spam_metrics.rb | 40 +++ .../admin/submissions/_spam_alert.html.erb | 66 +++++ app/views/admin/submissions/show.html.erb | 28 +- config/application.rb | 8 + config/initializers/notifications.rb | 4 - config/locales/en.yml | 2 +- config/locales/es.yml | 2 +- config/locales/zh-CN.yml | 2 +- .../submissions_controller_spec.rb | 107 ++++++-- spec/features/touchpoints_spec.rb | 2 +- spec/helpers/spam_helper_spec.rb | 24 ++ spec/rails_helper.rb | 2 +- spec/services/spam_checks/base_spec.rb | 100 +++++++ spec/services/spam_checks/honeypot_spec.rb | 23 ++ spec/services/spam_checks/referer_spec.rb | 119 +++++++++ spec/services/spam_checks/turnstile_spec.rb | 113 ++++++++ spec/services/spam_metrics_spec.rb | 44 ++++ touchpoints-demo.yml | 1 + touchpoints-staging.yml | 1 + touchpoints.yml | 1 + 28 files changed, 1120 insertions(+), 178 deletions(-) create mode 100644 app/helpers/spam_helper.rb create mode 100644 app/services/spam_checker.rb create mode 100644 app/services/spam_checks/base.rb create mode 100644 app/services/spam_checks/honeypot.rb create mode 100644 app/services/spam_checks/referer.rb create mode 100644 app/services/spam_checks/turnstile.rb create mode 100644 app/services/spam_metrics.rb create mode 100644 app/views/admin/submissions/_spam_alert.html.erb create mode 100644 spec/helpers/spam_helper_spec.rb create mode 100644 spec/services/spam_checks/base_spec.rb create mode 100644 spec/services/spam_checks/honeypot_spec.rb create mode 100644 spec/services/spam_checks/referer_spec.rb create mode 100644 spec/services/spam_checks/turnstile_spec.rb create mode 100644 spec/services/spam_metrics_spec.rb diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index d21092489..d3a3a9357 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -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 @@ -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.try_again')] }, + }, 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 + "

Your Response ID is: #{submission.uuid[-12..-1]}" + form_success_text = if @submission.form.append_id_to_success_text? + (@submission.form.success_text || '') + "

Your Response ID is: #{@submission.uuid[-12..-1]}" 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 @@ -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 @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b8e11ece5..ba820b3b2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/helpers/spam_helper.rb b/app/helpers/spam_helper.rb new file mode 100644 index 000000000..8f741a66e --- /dev/null +++ b/app/helpers/spam_helper.rb @@ -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 diff --git a/app/services/spam_checker.rb b/app/services/spam_checker.rb new file mode 100644 index 000000000..25bc94fd6 --- /dev/null +++ b/app/services/spam_checker.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Service object to run spam checks on a submission and return an overall outcome. +# +# Required context keys: +# :referer — String referer to validate (may be nil/blank) +# :root_url — String root URL of the running application (may be nil) +# :cf_turnstile_response — String token from the client (may be nil/blank) +# :remote_ip — String remote IP of the submitter +class SpamChecker + DEFAULT_SPAM_CHECKS = SpamChecks::Base.checks + + # Value object returned from SpamChecker#call. Wraps the per-check results + # and exposes the overall #verdict derived from them. + class SpamResults + attr_reader :results + + def initialize(results) + @results = Array(results) + end + + # Overall outcome derived from all checks, in precedence order: + # a hard :reject wins, then :surface (maybe-bot-but-user-fixable, prompt a + # retry), then :flag, otherwise :pass. + def verdict + if results.any?(&:reject?) + :reject + elsif results.any?(&:surface?) + :surface + elsif results.any?(&:flag?) + :flag + else + :pass + end + end + + def flagged + results.filter(&:flag?).map(&:name) + end + + def applicable + results.reject(&:not_applied?).map(&:name) + end + end + + def initialize(spam_checks: DEFAULT_SPAM_CHECKS) + @spam_checks = spam_checks + end + + def call(submission, context) + # Run ALL applicable checks (not short-circuited) so the full outcome + # vector is available for redundancy/co-occurrence analysis. + spam_results = SpamResults.new(run_spam_checks(submission, context)) + record_spam_results(submission.form.id, spam_results) + + spam_results + end + + private + + # Internal-only readers. These are set once in #initialize and used only + # within the object, so they are not part of the public interface. + attr_reader :spam_checks + + def run_spam_checks(submission, context) + spam_checks.map do |check_class| + check_class.new(submission: submission, context:).call + end + end + + # Record spam telemetry. + def record_spam_results(form_id, spam_results) + results = Array(spam_results.results) + results_by_check = SpamChecks::Base.results_to_h(results) + + SpamMetrics.record(form_id:, verdict: spam_results.verdict, results: results_by_check) + end +end diff --git a/app/services/spam_checks/base.rb b/app/services/spam_checks/base.rb new file mode 100644 index 000000000..0efd4b149 --- /dev/null +++ b/app/services/spam_checks/base.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SpamChecks + # Base defines the interface (via duck typing) that every spam check + # implements. A check is a small object constructed with the request-derived + # context it needs, exposing a single #call that returns a Result. + # + # Subclasses override: + # - #applicable? (optional) — should this check run for this submission? + # - #evaluate (required) — run the spam check, returning one of :reject :flag :surface :pass + class Base + # status is one of :pass, :reject, :flag, :surface, :not_applied + Result = Data.define(:name, :status) do + def reject? = status == :reject + def flag? = status == :flag + def surface? = status == :surface + def not_applied? = status == :not_applied + end + + # Reduce an array of Results to a plain { name => status_string } hash, + # suitable for a notification payload or JSONB persistence (no object + # references, safe to serialize / enqueue). + def self.results_to_h(results) + results.each_with_object({}) do |result, hash| + hash[result.name] = result.status.to_s + end + end + + # Human-readable label for this check, shown in the admin interface. + # Defaults to a humanized version of the check name; subclasses set LABEL. + def self.label + const_defined?(:LABEL, false) ? const_get(:LABEL) : check_name.humanize + end + + # Plain-language explanation of why this check flags a response. + # Subclasses set DESCRIPTION; nil when unset. + def self.description + const_get(:DESCRIPTION) if const_defined?(:DESCRIPTION, false) + end + + # The check's persisted identifier, matching the #name written to + # submission.spam_determination["reasons"] (e.g. "honeypot"). + def self.check_name + const_get(:CHECK_NAME) + end + + # Look up a subclass by its check name (e.g. "honeypot"). Returns nil for + # unknown names. + def self.for(check_name) + checks.find { |klass| klass.check_name == check_name.to_s } + end + + # The known spam check subclasses. Listed explicitly (rather than via + # .subclasses) so lookups work regardless of autoloading/eager-load state. + def self.checks + [SpamChecks::Honeypot, SpamChecks::Referer, SpamChecks::Turnstile] + end + + def initialize(submission:, context: {}) + @submission = submission + @context = context + end + + def call + applicable? ? result(evaluate) : result(:not_applied) + end + + private + + attr_reader :submission, :context + + def result(status) + Result.new(name: name, status: status) + end + + def name + self.class.check_name + end + + def applicable? + true + end + + def evaluate + raise NotImplementedError, "#{self.class} must implement #evaluate" + end + end +end diff --git a/app/services/spam_checks/honeypot.rb b/app/services/spam_checks/honeypot.rb new file mode 100644 index 000000000..ca9b2ffe8 --- /dev/null +++ b/app/services/spam_checks/honeypot.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module SpamChecks + # Flags submissions where the hidden honeypot field (fba_directive) was + # filled in. Legitimate users never see or populate this field, so any + # value indicates an automated submission. + # + # Required context keys: None + class Honeypot < Base + # Persisted identifier written to submission.spam_determination["reasons"]. + CHECK_NAME = 'honeypot' + LABEL = 'Hidden field was filled in' + DESCRIPTION = 'A hidden field that real people never see was filled in. ' \ + 'This almost always indicates an automated (bot) submission.' + + private + + def evaluate + submission.fba_directive.present? ? :reject : :pass + end + end +end diff --git a/app/services/spam_checks/referer.rb b/app/services/spam_checks/referer.rb new file mode 100644 index 000000000..a5c31714e --- /dev/null +++ b/app/services/spam_checks/referer.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module SpamChecks + # Flags submissions whose referer is not on the form's allowlist, and is + # neither the Touchpoints application itself nor the form's organization. + # + # Required context keys: + # :referer — String referer to validate (may be nil/blank) + # :root_url — String root URL of the running application (may be nil) + class Referer < Base + # Persisted identifier written to submission.spam_determination["reasons"]. + CHECK_NAME = 'referer' + LABEL = 'Unrecognized referring domain' + DESCRIPTION = 'The response was submitted from a domain that is not on ' \ + "this form's allowlist. This can indicate spam, but it may " \ + 'also happen when an embedded form is missing a domain ' \ + 'from its allowlist.' + + private + + def applicable? + referer.present? + end + + # Why flag on failure instead of reject? + # Because some failures could be due to form misconfiguration, + # specifically if the whitelist is incomplete for embedded forms. + def evaluate + is_allowed = allowlisted? || + from_touchpoints? || + from_form_organization? + is_allowed ? :pass : :flag + end + + def allowlisted? + whitelist_prefixes.any? { |prefix| referer.start_with?(prefix) } + end + + def from_touchpoints? + root_url = context[:root_url] + return false if root_url.blank? + + referer.start_with?(root_url) + end + + def from_form_organization? + org_url = submission.form.organization&.url + return false if org_url.blank? + + referer.start_with?(org_url) + end + + # Referer header + def referer + context[:referer] + end + + def 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 + ] + + whitelist_attributes.filter_map do |attr| + value = submission.form.public_send(attr) + value.presence + end + end + end +end diff --git a/app/services/spam_checks/turnstile.rb b/app/services/spam_checks/turnstile.rb new file mode 100644 index 000000000..3269d5cac --- /dev/null +++ b/app/services/spam_checks/turnstile.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +module SpamChecks + # Verifies a Cloudflare Turnstile token, treating a failed verification as + # spam. Only applicable when the form has Turnstile enabled. + # + # The actual HTTP call is delegated to a `verifier` collaborator so this + # check can be unit-tested without network access. The verifier must respond + # to #verify(token:, remote_ip:) and return one of :pass, :reject, :flag, or + # :surface (maybe-bot-but-user-fixable — prompt the user to try again). + # + # Required context keys: + # :cf_turnstile_response — String token from the client (may be nil/blank) + # :remote_ip — String remote IP of the submitter + class Turnstile < Base + # Persisted identifier written to submission.spam_determination["reasons"]. + CHECK_NAME = 'turnstile' + LABEL = 'Failed bot-detection challenge' + DESCRIPTION = 'The response did not pass the Cloudflare Turnstile ' \ + 'bot-detection challenge.' + + private + + def applicable? + submission.form.enable_turnstile? + end + + def evaluate + verifier.verify(token: context[:cf_turnstile_response], remote_ip: context[:remote_ip]) + end + + def verifier + context.fetch(:verifier, TurnstileVerifier) + end + end + + # Thin wrapper around the Cloudflare Turnstile siteverify endpoint. + module TurnstileVerifier + SITEVERIFY_URI = URI('https://challenges.cloudflare.com/turnstile/v0/siteverify') + + def self.verify(token:, remote_ip:) + # Might be a bot but fixable by user so give them a chance to fix + return :surface if token.blank? + + response = Net::HTTP.post_form( + SITEVERIFY_URI, + { + 'secret' => ENV.fetch('TURNSTILE_SECRET_KEY', nil), + 'response' => token, + 'remoteip' => remote_ip, + }, + ) + + result = JSON.parse(response.body) + return :pass if result['success'] == true + + if result['error-codes'].intersect?(%w[missing-input-secret invalid-input-secret]) + # Don't enforce Turnstile if Touchpoints is misconfigured + Rails.logger.error 'Turnstile secret key is misconfigured' + :pass + elsif result['error-codes'].intersect?(%w[timeout-or-duplicate invalid-input-response]) + # Might be a bot but fixable by user so give them a chance to fix + :surface + else + # Any other errors suggest Turnstile is having problems, fail open + Rails.logger.warn "Turnstile verification returned failure with error codes #{result['error-codes'].join(', ')}" + :pass + end + rescue StandardError => e + Rails.logger.warn "Turnstile verification call failed with message: #{e.message}" + :pass + end + end +end diff --git a/app/services/spam_metrics.rb b/app/services/spam_metrics.rb new file mode 100644 index 000000000..2d98852a9 --- /dev/null +++ b/app/services/spam_metrics.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# SpamMetrics records the observability side effects for a spam check run on a submission. +# It is a plain object so the logic is unit-testable and is called directly from SpamChecker. +# +# For now, spam telemetry is a TIME-BOXED analysis: we emit +# NewRelic custom events + counters rather than persisting to the database. +# NewRelic handles storage and retention. +module SpamMetrics + module_function + + # verdict: Symbol of :pass, :reject, :flag, or :surface + # results: Hash of { check_name => status_string }, e.g. + # { "honeypot" => "reject", "referer" => "pass", "turnstile" => "not_applied" } + def record(form_id:, verdict:, results:) + return unless defined?(NewRelic::Agent) + + increment_counters(verdict, results) + record_custom_event(form_id, results) + end + + # One counter per check outcome so we can chart how many submissions each + # prevention method blocks (and how often it is applied), plus a total. + def increment_counters(verdict, results) + NewRelic::Agent.increment_metric("Custom/Spam/#{verdict}") + results.each do |check, status| + NewRelic::Agent.increment_metric("Custom/Spam/#{check}/#{status}") + end + end + + # One custom event per submission, with each check flattened to its own + # attribute (e.g. honeypot: "reject", referer: "pass") so NRQL can FACET on + # check pairs to answer the redundancy / co-occurrence question. + def record_custom_event(form_id, results) + NewRelic::Agent.record_custom_event( + 'SpamCheck', + { form_id: form_id }.merge(results), + ) + end +end diff --git a/app/views/admin/submissions/_spam_alert.html.erb b/app/views/admin/submissions/_spam_alert.html.erb new file mode 100644 index 000000000..f0e5a79d4 --- /dev/null +++ b/app/views/admin/submissions/_spam_alert.html.erb @@ -0,0 +1,66 @@ +<%# Explains that a response is spam, plus how and why it was flagged. %> +<%# Expects: submission %> +<% determination = submission.spam_determination %> +<% source = determination.present? ? determination["source"] : nil %> +<% reasons = determination.present? ? Array(determination["reasons"]) : [] %> +<% if source == 'manual'%> +
+
+

+ This response was marked as spam by a member of your team. +

+
+
+<% elsif source == 'automated' %> +
+
+

This response was automatically marked as spam by Touchpoints.

+ <% if reasons.any? %> +

+ Why it was marked: +

+
    + <% reasons.each do |reason| %> +
  • + <%= spam_reason_label(reason) %> + <% if (description = spam_reason_description(reason)).present? %> + — <%= description %> + <% end %> +
  • + <% end %> +
+ <% end %> + + <% if form_permissions?(form: submission.form) %> +
    +
  • + <%= link_to mark_admin_form_submission_path(submission.form, submission, context: :show), + class: "usa-button usa-button--outline", + "aria-label" => "Confirm submission #{submission.id} as spam", + title: "Confirm submission #{submission.id} as spam", + method: :post, + remote: true, + data: { confirm: 'Confirm this response as spam?' }, + rel: "nofollow" do %> + + Confirm as spam + <% end %> +
  • +
  • + <%= link_to unmark_admin_form_submission_path(submission.form, submission, context: :show), + class: "usa-button usa-button--outline", + "aria-label" => "Unmark submission #{submission.id} as spam", + title: "Unmark submission #{submission.id} as spam", + method: :post, + remote: true, + data: { confirm: 'Unmark this response as spam?' }, + rel: "nofollow" do %> + + Unmark as spam + <% end %> +
  • +
+ <% end %> +
+
+<% end %> diff --git a/app/views/admin/submissions/show.html.erb b/app/views/admin/submissions/show.html.erb index f34ad8452..ac326631a 100644 --- a/app/views/admin/submissions/show.html.erb +++ b/app/views/admin/submissions/show.html.erb @@ -7,8 +7,11 @@ Back to Responses <% end %>

+<%- if @submission.spam? %> + <%= render "admin/submissions/spam_alert", submission: @submission %> +<% end %> <%- if @submission.flagged? %> -
+

This response is flagged. @@ -17,7 +20,7 @@

<% end %> <%- if @submission.archived? %> -
+

This response is archived. @@ -197,7 +200,7 @@ Flagged - <%= @submission.flagged %> + <%= boolean_to_text(@submission.flagged?) %> @@ -205,7 +208,7 @@ Spam - <%= @submission.spam? %> + <%= boolean_to_text(@submission.spam?) %> @@ -214,7 +217,18 @@ <%- if @submission.spam_determination.present? %> - <%= h(@submission.spam_determination.to_json) %> + <%- if @submission.spam_determination["source"] == "manual" %> + Marked manually + <% end %> + <%- if @submission.spam_determination["source"] == "automated" %> + Marked by Touchpoints + <% end %> + <%- if @submission.spam_determination["reasons"].present? %> +

+ Reasons: + <%= @submission.spam_determination["reasons"].map { |reason| spam_reason_label(reason) }.join(", ") %> +
+ <% end %> <% end %> @@ -223,7 +237,7 @@ Archived - <%= @submission.archived? %> + <%= boolean_to_text(@submission.archived?) %> @@ -231,7 +245,7 @@ Deleted - <%= @submission.deleted? %> + <%= boolean_to_text(@submission.deleted?) %> diff --git a/config/application.rb b/config/application.rb index c2a2bfd9a..9c3e46296 100644 --- a/config/application.rb +++ b/config/application.rb @@ -19,6 +19,14 @@ class Application < Rails::Application # Common ones are `templates`, `generators`, or `middleware`, for example. # Initialize an array of Omniauth providers config.x.omniauth.providers = [] + + # When enabled, spam submissions that are rejected are silently dropped + # (the client still receives a normal 200 OK) so spammers get no signal + # about what was detected. When disabled, rejected spam returns a 422. + # Kept as an env var toggle (per-deploy) but coerced to a real boolean so + # SILENTLY_REJECT_SPAM=false actually disables it. + config.x.silently_reject_spam = + ActiveModel::Type::Boolean.new.cast(ENV.fetch("SILENTLY_REJECT_SPAM", false)) config.i18n.available_locales = %w[en es zh-CN] config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')] config.generators do |g| diff --git a/config/initializers/notifications.rb b/config/initializers/notifications.rb index 1b9d8c728..7d52426a3 100644 --- a/config/initializers/notifications.rb +++ b/config/initializers/notifications.rb @@ -1,7 +1,3 @@ -ActiveSupport::Notifications.subscribe("spam_subverted") do |name, start, finish, request_id, payload| - Rails.logger.warn("SPAM subverted from #{payload[:request].referer}, IP: #{payload[:request].remote_ip}, User-Agent: #{payload[:request].user_agent}") -end - ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, payload| Rails.logger.info("[Rack::Attack] #{payload[:request].ip} blocked for #{payload[:discriminator]}") end diff --git a/config/locales/en.yml b/config/locales/en.yml index 0c9b27b4d..f9ae23619 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2,7 +2,7 @@ en: errors: format: "%{attribute} %{message}" request: - unauthorized_host: "request made from non-authorized host" + try_again: "Please try again." messages: carrierwave_processing_error: failed to be processed carrierwave_integrity_error: is not of an allowed file type diff --git a/config/locales/es.yml b/config/locales/es.yml index 1e854d081..b8a5f6f9d 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -161,7 +161,7 @@ es: errors: format: "%{attribute} %{message}" request: - unauthorized_host: "solicitud realizada desde un host no autorizado" + try_again: "Vuelva a intentarlo." messages: accepted: debe ser aceptado blank: no puede estar en blanco diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index b8abae233..cbd44c232 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -157,7 +157,7 @@ zh-CN: errors: format: "%{attribute}%{message}" request: - unauthorized_host: "来自非授权主机的请求" + try_again: "请重试。" messages: accepted: 必须是可被接受的 blank: 不能为空字符 diff --git a/spec/controllers/submissions_controller_spec.rb b/spec/controllers/submissions_controller_spec.rb index e5b151ff4..852d48e85 100644 --- a/spec/controllers/submissions_controller_spec.rb +++ b/spec/controllers/submissions_controller_spec.rb @@ -81,22 +81,6 @@ end describe 'POST #create' do - context 'SPAMBOT' do - it "won't create a submission if SPAMBOT detected" do - spam_attributes = { - form_id: form.id, - answer_01: 'body text', - answer_02: 'James', - answer_03: 'Madison', - answer_04: 'james.madison@lvh.me', - fba_directive: 'SPAM text', - } - expect do - post :create, params: { submission: spam_attributes, form_id: form.short_uuid }, session: valid_session - end.to change(Submission, :count).by(0) - end - end - context 'with valid params and an ID' do it 'creates a new Submission' do expect do @@ -130,6 +114,97 @@ end end + context 'spam handling' do + # Stub the SpamChecker so these examples exercise the controller's + # response to each verdict without depending on real check behavior. + def stub_spam_verdict(verdict, flagged: []) + results = SpamChecker::SpamResults.new([]) + allow(results).to receive(:verdict).and_return(verdict) + allow(results).to receive(:flagged).and_return(flagged) + allow_any_instance_of(SpamChecker).to receive(:call).and_return(results) + end + + context 'when the submission is rejected' do + before { stub_spam_verdict(:reject) } + + context 'and spam is silently rejected' do + before do + allow(Rails.configuration.x).to receive(:silently_reject_spam).and_return(true) + end + + it 'does not persist the submission' do + expect do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session + end.to change(Submission, :count).by(0) + form.reload + expect(form.response_count).to eq(0) + expect(form.last_response_created_at).to be nil + end + + it 'redirects as if the submission succeeded, giving the spammer no signal' do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session + expect(response).to redirect_to(submit_touchpoint_path(form)) + end + end + + context 'and spam is loudly rejected' do + before do + allow(Rails.configuration.x).to receive(:silently_reject_spam).and_return(false) + end + + it 'does not persist the submission' do + expect do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session, format: :json + end.to change(Submission, :count).by(0) + end + + it 'returns a 422 rejecting the submission as spam' do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session, format: :json + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['status']).to eq('unprocessable_content') + expect(JSON.parse(response.body)['messages']).to eq({ 'submission' => ['Submission rejected as spam'] }) + end + end + end + + context 'when the submission is surfaced for a retry' do + before { stub_spam_verdict(:surface) } + + it 'does not persist the submission' do + expect do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session, format: :json + end.to change(Submission, :count).by(0) + end + + it 'returns a 422 prompting the user to try again' do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session, format: :json + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['status']).to eq('unprocessable_content') + expect(JSON.parse(response.body)['messages']).to eq({ 'submission' => [I18n.t('errors.request.try_again')] }) + end + end + + context 'when the submission is flagged' do + before { stub_spam_verdict(:flag, flagged: %w[honeypot]) } + + it 'persists the submission' do + expect do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session + end.to change(Submission, :count).by(1) + end + + it 'marks the submission as spam with automated provenance' do + post :create, params: { submission: valid_attributes, form_id: form.short_uuid }, session: valid_session + submission = Submission.last + expect(submission.spam).to be(true) + expect(submission.spam_determination).to eq( + 'source' => 'automated', + 'reasons' => %w[honeypot], + ) + end + end + end + context 'with invalid params' do before do form.questions.first.update(is_required: true) diff --git a/spec/features/touchpoints_spec.rb b/spec/features/touchpoints_spec.rb index 3839edfe3..d02551a41 100644 --- a/spec/features/touchpoints_spec.rb +++ b/spec/features/touchpoints_spec.rb @@ -67,7 +67,7 @@ end it 'fails the submission' do - expect(page).to have_content('this submission was not successful') + expect(page).to have_content('Submission rejected as spam') expect(page.current_path).to eq("/touchpoints/#{form.short_uuid}/submit") # stays on end end diff --git a/spec/helpers/spam_helper_spec.rb b/spec/helpers/spam_helper_spec.rb new file mode 100644 index 000000000..b57884016 --- /dev/null +++ b/spec/helpers/spam_helper_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe SpamHelper, type: :helper do + describe "#spam_reason_label" do + it "returns the check's label for a known reason" do + expect(helper.spam_reason_label('honeypot')).to eq('Hidden field was filled in') + end + + it "humanizes an unknown reason" do + expect(helper.spam_reason_label('some_other_check')).to eq('Some other check') + end + end + + describe "#spam_reason_description" do + it "returns the check's description for a known reason" do + expect(helper.spam_reason_description('turnstile')) + .to eq('The response did not pass the Cloudflare Turnstile bot-detection challenge.') + end + + it "returns nil for an unknown reason" do + expect(helper.spam_reason_description('some_other_check')).to be_nil + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index b804648b0..290e6519f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,7 +65,7 @@ Capybara.server = :puma Capybara.server_host = '127.0.0.1' Capybara.server_port = 3000 -Capybara.app_host = 'http://127.0.0.1:3000' +Capybara.app_host = 'http://localhost:3000' TEST_API_KEY = '1234567890123456789012345678901234567890' diff --git a/spec/services/spam_checks/base_spec.rb b/spec/services/spam_checks/base_spec.rb new file mode 100644 index 000000000..8ec0cf24d --- /dev/null +++ b/spec/services/spam_checks/base_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SpamChecks::Base do + let(:submission) { Submission.new } + + describe '#call' do + it 'returns :not_applied without calling #evaluate when not applicable' do + check_class = Class.new(described_class) do + const_set(:CHECK_NAME, 'test_check') + def applicable? = false + def evaluate = raise('should not be called') + end + + result = check_class.new(submission: submission).call + expect(result.status).to eq(:not_applied) + end + + it 'returns :reject when evaluate returns :reject' do + check_class = Class.new(described_class) do + const_set(:CHECK_NAME, 'test_check') + def evaluate = :reject + end + + result = check_class.new(submission: submission).call + expect(result.status).to eq(:reject) + expect(result).to be_reject + end + + it 'raises NotImplementedError when #evaluate is not overridden' do + check_class = Class.new(described_class) do + const_set(:CHECK_NAME, 'test_check') + end + + expect do + check_class.new(submission: submission, context: {}).call + end.to raise_error(NotImplementedError) + end + end + + describe '.check_name' do + it 'returns the declared CHECK_NAME constant' do + expect(SpamChecks::Honeypot.check_name).to eq('honeypot') + end + end + + describe '.label' do + it 'returns the LABEL constant when set' do + expect(SpamChecks::Honeypot.label).to eq('Hidden field was filled in') + end + + it 'humanizes the check name when LABEL is unset' do + check_class = Class.new(described_class) do + const_set(:CHECK_NAME, 'some_other_check') + end + + expect(check_class.label).to eq('Some other check') + end + end + + describe '.description' do + it 'returns the DESCRIPTION constant when set' do + expect(SpamChecks::Turnstile.description) + .to eq('The response did not pass the Cloudflare Turnstile bot-detection challenge.') + end + + it 'returns nil when DESCRIPTION is unset' do + check_class = Class.new(described_class) do + const_set(:CHECK_NAME, 'some_other_check') + end + + expect(check_class.description).to be_nil + end + end + + describe '.for' do + it 'looks up a check class by its check name' do + expect(described_class.for('referer')).to eq(SpamChecks::Referer) + end + + it 'returns nil for an unknown check name' do + expect(described_class.for('nope')).to be_nil + end + end + + describe 'CHECK_NAME contract' do + it 'every registered check declares a CHECK_NAME' do + described_class.checks.each do |check| + expect { check.check_name }.not_to raise_error + expect(check.check_name).to be_present + end + end + + it 'check names are unique across all registered checks' do + names = described_class.checks.map(&:check_name) + expect(names).to eq(names.uniq) + end + end +end diff --git a/spec/services/spam_checks/honeypot_spec.rb b/spec/services/spam_checks/honeypot_spec.rb new file mode 100644 index 000000000..dfd97e1ed --- /dev/null +++ b/spec/services/spam_checks/honeypot_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SpamChecks::Honeypot do + def check(submission) + SpamChecks::Honeypot.new(submission: submission).call + end + + it 'rejects when the honeypot field (fba_directive) is filled in' do + submission = Submission.new + submission.fba_directive = 'i am a bot' + + expect(check(submission).status).to eq(:reject) + end + + it 'passes when the honeypot field is blank' do + submission = Submission.new + submission.fba_directive = nil + + expect(check(submission).status).to eq(:pass) + end +end diff --git a/spec/services/spam_checks/referer_spec.rb b/spec/services/spam_checks/referer_spec.rb new file mode 100644 index 000000000..fb15042d2 --- /dev/null +++ b/spec/services/spam_checks/referer_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SpamChecks::Referer do + def check(form:, context:) + submission = FactoryBot.build_stubbed(:submission, form:) + described_class.new(submission: submission, context: context).call + end + + describe 'applicability' do + it 'is not applied when no referer is provided' do + form = FactoryBot.build_stubbed(:form, whitelist_url: 'https://agency.gov/') + result = check( + form: form, + context: { referer: nil, root_url: 'https://touchpoints.gov/' } + ) + expect(result.status).to eq(:not_applied) + end + end + + describe 'allowlist' do + it 'passes when the referer matches a form whitelist prefix' do + form = FactoryBot.build_stubbed(:form, whitelist_url: 'https://agency.gov/') + result = check( + form: form, + context: { referer: 'https://agency.gov/feedback', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:pass) + end + + it 'passes when the referer matches the whitelist_test_url' do + form = FactoryBot.build_stubbed(:form, whitelist_url: '', whitelist_test_url: 'https://staging.agency.gov/') + result = check( + form: form, + context: { referer: 'https://staging.agency.gov/x', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:pass) + end + + it 'ignores blank whitelist columns' do + # whitelist_url / whitelist_test_url default to "" — a blank prefix must + # not match every referer. + form = FactoryBot.build_stubbed(:form, whitelist_url: '', whitelist_test_url: '') + allow(form).to receive(:organization).and_return(nil) + result = check( + form: form, + context: { referer: 'https://evil.example/', root_url: nil }, + ) + expect(result.status).to eq(:flag) + end + end + + describe 'Touchpoints application referer' do + it 'passes when the referer starts with the application root_url' do + form = FactoryBot.build_stubbed(:form, whitelist_url: '') + result = check( + form: form, + context: { referer: 'https://touchpoints.gov/some/page', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:pass) + end + + it 'does not treat the referer as Touchpoints when root_url is nil' do + form = FactoryBot.build_stubbed(:form, whitelist_url: '') + allow(form).to receive(:organization).and_return(nil) + result = check( + form: form, + context: { referer: 'https://touchpoints.gov/some/page', root_url: nil }, + ) + expect(result.status).to eq(:flag) + end + end + + describe "form organization's site" do + it 'passes when the referer starts with the organization url' do + organization = FactoryBot.build_stubbed(:organization, url: 'https://org.gov') + form = FactoryBot.build_stubbed(:form, organization: organization, whitelist_url: '') + result = check( + form: form, + context: { referer: 'https://org.gov/page', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:pass) + end + + it 'rejects (does not raise) when the organization is nil' do + form = FactoryBot.build_stubbed(:form, whitelist_url: '') + allow(form).to receive(:organization).and_return(nil) + result = check( + form: form, + context: { referer: 'https://evil.example/', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:flag) + end + + it 'rejects (does not raise) when the organization url is nil' do + organization = FactoryBot.build_stubbed(:organization, url: nil) + form = FactoryBot.build_stubbed(:form, organization: organization, whitelist_url: '') + result = check( + form: form, + context: { referer: 'https://evil.example/', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:flag) + end + end + + describe 'flag' do + it 'flags when the referer is not allowlisted, not Touchpoints, and not the org site' do + organization = FactoryBot.build_stubbed(:organization, url: 'https://org.gov') + form = FactoryBot.build_stubbed(:form, organization: organization, whitelist_url: 'https://agency.gov/') + result = check( + form: form, + context: { referer: 'https://evil.example/', root_url: 'https://touchpoints.gov/' }, + ) + expect(result.status).to eq(:flag) + end + + end +end diff --git a/spec/services/spam_checks/turnstile_spec.rb b/spec/services/spam_checks/turnstile_spec.rb new file mode 100644 index 000000000..3fd238719 --- /dev/null +++ b/spec/services/spam_checks/turnstile_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SpamChecks::Turnstile do + + def check(form:, context:) + submission = FactoryBot.build_stubbed(:submission, form:) + SpamChecks::Turnstile.new(submission: submission, context: context).call + end + + it 'is not applied when the form does not have Turnstile enabled' do + form = FactoryBot.build_stubbed(:form, enable_turnstile: false) + result = check(form: form, context: { remote_ip: '1.2.3.4' }) + + expect(result.status).to eq(:not_applied) + end + + context 'when Turnstile is enabled' do + let(:form) { FactoryBot.build_stubbed(:form, enable_turnstile: true) } + + it 'passes when the verifier confirms the token' do + verifier = double(verify: :pass) + result = check( + form: form, + context: { remote_ip: '1.2.3.4', verifier: verifier }, + ) + + expect(result.status).to eq(:pass) + end + + it 'surfaces when the verifier asks the user to try again' do + verifier = double(verify: :surface) + result = check( + form: form, + context: { remote_ip: '1.2.3.4', verifier: verifier }, + ) + + expect(result.status).to eq(:surface) + end + + it 'passes the token and remote_ip from the context to the verifier' do + verifier = double + expect(verifier).to receive(:verify).with(token: 'the-token', remote_ip: '1.2.3.4').and_return(:pass) + + check( + form: form, + context: { remote_ip: '1.2.3.4', cf_turnstile_response: 'the-token', verifier: verifier }, + ) + end + end +end + +RSpec.describe SpamChecks::TurnstileVerifier do + describe '.verify' do + def stub_cloudflare(body) + response = instance_double(Net::HTTPResponse, body: body) + allow(Net::HTTP).to receive(:post_form).and_return(response) + end + + it 'surfaces without making a request when the token is blank' do + expect(Net::HTTP).not_to receive(:post_form) + expect(described_class.verify(token: '', remote_ip: '1.2.3.4')).to eq(:surface) + end + + it 'surfaces without making a request when the token is nil' do + expect(Net::HTTP).not_to receive(:post_form) + expect(described_class.verify(token: nil, remote_ip: '1.2.3.4')).to eq(:surface) + end + + it 'returns :pass when Cloudflare reports success' do + stub_cloudflare('{"success":true}') + + expect(described_class.verify(token: 'good', remote_ip: '1.2.3.4')).to eq(:pass) + end + + it 'fails open (:pass) when the secret key is missing' do + stub_cloudflare('{"success":false,"error-codes":["missing-input-secret"]}') + + expect(described_class.verify(token: 'good', remote_ip: '1.2.3.4')).to eq(:pass) + end + + it 'fails open (:pass) when the secret key is invalid' do + stub_cloudflare('{"success":false,"error-codes":["invalid-input-secret"]}') + + expect(described_class.verify(token: 'good', remote_ip: '1.2.3.4')).to eq(:pass) + end + + it 'surfaces when the token is a timeout or duplicate' do + stub_cloudflare('{"success":false,"error-codes":["timeout-or-duplicate"]}') + + expect(described_class.verify(token: 'stale', remote_ip: '1.2.3.4')).to eq(:surface) + end + + it 'surfaces when the token response is invalid' do + stub_cloudflare('{"success":false,"error-codes":["invalid-input-response"]}') + + expect(described_class.verify(token: 'bad', remote_ip: '1.2.3.4')).to eq(:surface) + end + + it 'fails open (:pass) on any other Cloudflare error' do + stub_cloudflare('{"success":false,"error-codes":["internal-error"]}') + + expect(described_class.verify(token: 'good', remote_ip: '1.2.3.4')).to eq(:pass) + end + + it 'fails open (:pass) when the request raises an error' do + allow(Net::HTTP).to receive(:post_form).and_raise(StandardError) + + expect(described_class.verify(token: 'good', remote_ip: '1.2.3.4')).to eq(:pass) + end + end +end diff --git a/spec/services/spam_metrics_spec.rb b/spec/services/spam_metrics_spec.rb new file mode 100644 index 000000000..5dfa89da5 --- /dev/null +++ b/spec/services/spam_metrics_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SpamMetrics do + let(:results) do + { 'honeypot' => 'reject', 'referer' => 'pass', 'turnstile' => 'not_applied' } + end + + describe '.record' do + context 'when NewRelic is available' do + before do + stub_const('NewRelic::Agent', class_double('NewRelic::Agent')) + allow(NewRelic::Agent).to receive(:increment_metric) + allow(NewRelic::Agent).to receive(:record_custom_event) + end + + it 'increments one counter per check outcome plus a total' do + described_class.record(form_id: 42, verdict: :reject, results: results) + + expect(NewRelic::Agent).to have_received(:increment_metric).with('Custom/Spam/honeypot/reject') + expect(NewRelic::Agent).to have_received(:increment_metric).with('Custom/Spam/referer/pass') + expect(NewRelic::Agent).to have_received(:increment_metric).with('Custom/Spam/turnstile/not_applied') + expect(NewRelic::Agent).to have_received(:increment_metric).with('Custom/Spam/reject') + end + + it 'records a SpamCheck custom event with each check flattened to an attribute' do + described_class.record(form_id: 42, verdict: :reject, results: results) + + expect(NewRelic::Agent).to have_received(:record_custom_event).with( + 'SpamCheck', + { form_id: 42, 'honeypot' => 'reject', 'referer' => 'pass', 'turnstile' => 'not_applied' } + ) + end + end + + context 'when NewRelic is not defined' do + it 'does not raise' do + hide_const('NewRelic::Agent') if defined?(NewRelic::Agent) + expect { described_class.record(form_id: 42, verdict: :reject, results: results) }.not_to raise_error + end + end + end +end diff --git a/touchpoints-demo.yml b/touchpoints-demo.yml index e327057f6..b42f3cc72 100644 --- a/touchpoints-demo.yml +++ b/touchpoints-demo.yml @@ -20,6 +20,7 @@ applications: TOUCHPOINTS_GTM_CONTAINER_ID: TOUCHPOINTS_WEB_DOMAIN: touchpoints-demo.app.cloud.gov SKIP_WIDGET_RENDERER: "true" + SILENTLY_REJECT_SPAM: "true" buildpacks: - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack diff --git a/touchpoints-staging.yml b/touchpoints-staging.yml index 4ff0246b4..8934b388b 100644 --- a/touchpoints-staging.yml +++ b/touchpoints-staging.yml @@ -27,6 +27,7 @@ applications: ASSET_HOST: app-staging.touchpoints.digital.gov SKIP_WIDGET_RENDERER: "true" API_GATEWAY_BASE_URL: https://api.gsa.gov/test/analytics/touchpoints + SILENTLY_REJECT_SPAM: "true" buildpacks: - https://github.com/rileyseaburg/rust-buildpack-touchpoints.git - nodejs_buildpack diff --git a/touchpoints.yml b/touchpoints.yml index 211c5acbb..0ff6eb3ce 100644 --- a/touchpoints.yml +++ b/touchpoints.yml @@ -15,6 +15,7 @@ applications: INDEX_URL: /admin SKIP_WIDGET_RENDERER: "true" API_GATEWAY_BASE_URL: https://api.gsa.gov/analytics/touchpoints + SILENTLY_REJECT_SPAM: "true" # Secrets managed via cf set-env (DO NOT add empty keys here): # - AWS_SES_ACCESS_KEY_ID # - AWS_SES_SECRET_ACCESS_KEY