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) %>
+
+ <% 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