diff --git a/.env.example b/.env.example index 3e52404cc..5699b9873 100644 --- a/.env.example +++ b/.env.example @@ -67,4 +67,9 @@ SCRATCH_ASSET_CONFIG_BASE_URL=https://example.com/config/ SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ # Pardot Form Handler endpoint for subscription forwarding -PARDOT_SUBSCRIPTION_URL= \ No newline at end of file +PARDOT_SUBSCRIPTION_URL= + +# Cloudflare Turnstile bot protection. This is a test key that always passes. +# Others are available for testing purposes at +# https://developers.cloudflare.com/turnstile/troubleshooting/testing/. +CLOUDFLARE_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA \ No newline at end of file diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index 9047ec66d..63fcc848e 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -2,8 +2,11 @@ module Api class SubscriptionsController < ApiController + before_action :check_cloudflare_turnstile, only: :create + def create - payload = subscription_params.to_h + # turnstile token is only used for bot check so strip it out before validation and submission + payload = subscription_params.except(:turnstile_token).to_h errors = validation_errors_for(payload) if errors.empty? @@ -39,8 +42,28 @@ def create private + def check_cloudflare_turnstile + return unless Rails.configuration.x.cloudflare_turnstile.enabled + return if params[:subscription].blank? + + turnstile_check = Subscriptions::TurnstileVerifier.new( + token: params.dig(:subscription, :turnstile_token), + remote_ip: request.remote_ip, + secret_key: Rails.configuration.x.cloudflare_turnstile.secret_key + ) + + return if turnstile_check.passed? + + Rails.logger.warn('[subscriptions#create] outcome=failure error_code=turnstile_verification_failed') + render json: { + ok: false, + error_code: 'turnstile_verification_failed', + message: 'Bot protection check failed. Please try again.' + }, status: :unprocessable_content + end + def subscription_params - params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy) + params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy, :turnstile_token) end def subscriptions_submitter diff --git a/app/services/subscriptions/turnstile_verifier.rb b/app/services/subscriptions/turnstile_verifier.rb new file mode 100644 index 000000000..c7477c370 --- /dev/null +++ b/app/services/subscriptions/turnstile_verifier.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Subscriptions + class TurnstileVerifier + API_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + + def initialize(token:, remote_ip:, secret_key:) + @token = token + @remote_ip = remote_ip + @secret_key = secret_key + end + + def passed? + return false if @token.blank? + + response = faraday.post( + API_URL, + { + secret: secret_key, + response: token, + remoteip: remote_ip + } + ) + unless response.success? + Rails.logger.warn("[subscriptions#create] turnstile verification skipped: HTTP #{response.status}") + return true # fail open + end + + JSON.parse(response.body)['success'] == true + rescue Faraday::Error, JSON::ParserError => e + Sentry.capture_exception(e) + Rails.logger.warn("[subscriptions#create] turnstile verification error: #{e.message}") + # Fail open to allow the request through if verification is unavailable + # due to network issues, Cloudflare downtime or malformed responses etc. + true + end + + private + + attr_reader :secret_key, :remote_ip, :token + + def faraday + @faraday ||= Faraday.new do |f| + f.request :url_encoded + f.options.timeout = 5 + f.options.open_timeout = 2 + end + end + end +end diff --git a/config/application.rb b/config/application.rb index e749e6d49..a230f5477 100644 --- a/config/application.rb +++ b/config/application.rb @@ -70,5 +70,8 @@ class Application < Rails::Application config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT') config.x.subscriptions.pardot_form_handler_url = ENV.fetch('PARDOT_SUBSCRIPTION_URL', '') + + config.x.cloudflare_turnstile.secret_key = ENV.fetch('CLOUDFLARE_TURNSTILE_SECRET_KEY', nil) + config.x.cloudflare_turnstile.enabled = ENV['CLOUDFLARE_TURNSTILE_SECRET_KEY'].present? end end diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb index 6b8c05c2c..c562db889 100644 --- a/spec/requests/api/subscriptions_spec.rb +++ b/spec/requests/api/subscriptions_spec.rb @@ -10,7 +10,8 @@ subscription: { email: 'teacher@example.com', test_opt_in: true, - privacy_policy: true + privacy_policy: true, + turnstile_token: 'test-token' } } end @@ -37,8 +38,10 @@ let(:submitter) { instance_double(Subscriptions::PardotFormHandlerSubmitter) } before do + allow(Rails.configuration.x.cloudflare_turnstile).to receive(:enabled).and_return(false) allow(Subscriptions::PardotFormHandlerSubmitter).to receive(:new).and_return(submitter) allow(submitter).to receive(:call).and_return(submitter_result_success) + allow(Sentry).to receive(:capture_exception) end it 'returns success for a valid payload' do @@ -110,5 +113,47 @@ 'message' => 'Subscription provider rejected the request.' ) end + + describe 'Cloudflare Turnstile integration' do + let(:turnstile_check) { instance_double(Subscriptions::TurnstileVerifier, passed?: true) } + + before do + allow(Rails.configuration.x.cloudflare_turnstile).to receive_messages( + enabled: true, + secret_key: 'test-secret' + ) + allow(Subscriptions::TurnstileVerifier).to receive(:new).and_return(turnstile_check) + end + + it 'passes the token, remote IP and secret key to the verifier' do + post(path, params: payload, as: :json) + + expect(Subscriptions::TurnstileVerifier).to have_received(:new).with( + token: 'test-token', + remote_ip: '127.0.0.1', + secret_key: 'test-secret' + ) + end + + context 'when the turnstile check passes' do + it 'allows the request through' do + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['ok']).to be(true) + end + end + + context 'when the turnstile check fails' do + before { allow(turnstile_check).to receive(:passed?).and_return(false) } + + it 'returns 422 with turnstile_verification_failed error code' do + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed') + end + end + end end end diff --git a/spec/services/subscriptions/turnstile_verifier_spec.rb b/spec/services/subscriptions/turnstile_verifier_spec.rb new file mode 100644 index 000000000..872d05e60 --- /dev/null +++ b/spec/services/subscriptions/turnstile_verifier_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Subscriptions::TurnstileVerifier do + let(:secret_key) { 'test-secret' } + let(:remote_ip) { '127.0.0.1' } + let(:token) { 'test-token' } + let(:verifier) { described_class.new(token:, remote_ip:, secret_key:) } + let(:connection) { instance_double(Faraday::Connection) } + let(:response) do + instance_double(Faraday::Response, success?: true, status: 200, body: { success: true }.to_json) + end + + before do + allow(verifier).to receive(:faraday).and_return(connection) + allow(connection).to receive(:post).and_return(response) + allow(Sentry).to receive(:capture_exception) + end + + describe '#passed?' do + it 'posts to Cloudflare siteverify with the correct params' do + verifier.passed? + + expect(connection).to have_received(:post).with( + described_class::API_URL, + { secret: secret_key, response: token, remoteip: remote_ip } + ) + end + + context 'when turnstile token is missing' do + let(:token) { '' } + + it 'returns false without calling Cloudflare' do + expect(verifier.passed?).to be(false) + expect(connection).not_to have_received(:post) + end + end + + context 'when turnstile token is valid' do + it { expect(verifier.passed?).to be(true) } + end + + context 'when Cloudflare rejects the token' do + let(:response) do + instance_double(Faraday::Response, success?: true, status: 200, body: { success: false }.to_json) + end + + it { expect(verifier.passed?).to be(false) } + end + + context 'when Cloudflare returns a server error' do + let(:response) do + instance_double(Faraday::Response, success?: false, status: 500, body: 'Internal Server Error') + end + + it { expect(verifier.passed?).to be(true) } + end + + context 'when Cloudflare returns malformed JSON' do + let(:response) do + instance_double(Faraday::Response, success?: true, status: 200, body: 'not-json') + end + + it { expect(verifier.passed?).to be(true) } + + it 'reports the error to Sentry' do + verifier.passed? + expect(Sentry).to have_received(:capture_exception).with(be_a(JSON::ParserError)) + end + end + + context 'when the Cloudflare connection fails' do + before do + allow(connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('connection failed')) + end + + it { expect(verifier.passed?).to be(true) } + + it 'reports the error to Sentry' do + verifier.passed? + expect(Sentry).to have_received(:capture_exception).with(be_a(Faraday::Error)) + end + end + end +end