diff --git a/.env.example b/.env.example index 3e52404cc..7d6357f48 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..b5cc4e193 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -2,6 +2,10 @@ module Api class SubscriptionsController < ApiController + before_action :check_cloudflare_turnstile, only: :create + + API_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + def create payload = subscription_params.to_h errors = validation_errors_for(payload) @@ -39,6 +43,37 @@ def create private + def check_cloudflare_turnstile + return unless Rails.configuration.x.cloudflare_turnstile.enabled + return if valid_turnstile_token? + + 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 valid_turnstile_token? + token = params.dig(:subscription, :turnstile_token) + return false if token.blank? + + response = Faraday.post( + API_URL, + { + secret: Rails.configuration.x.cloudflare_turnstile.secret_key, + response: token, + remoteip: request.remote_ip + } + ) + JSON.parse(response.body)['success'] == true + rescue StandardError + # Fail open to allow the request through if verification is unavailable + # due to network issues, Cloudflare downtime or malformed responses etc. + true + end + def subscription_params params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy) 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..0ca5a8ad3 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 @@ -110,5 +111,55 @@ 'message' => 'Subscription provider rejected the request.' ) end + + describe 'Cloudflare Turnstile integration' do + let(:request_url) { 'https://challenges.cloudflare.com/turnstile/v0/siteverify' } + + before do + allow(Rails.configuration.x.cloudflare_turnstile).to receive_messages( + enabled: true, + secret_key: 'test-secret' + ) + end + + it 'returns 422 when turnstile token is missing' do + post(path, params: payload.deep_merge(subscription: { turnstile_token: '' }), as: :json) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed') + end + + it 'returns 422 when turnstile verification fails' do + stub_request(:post, request_url) + .with( + body: hash_including( + secret: 'test-secret', + response: 'test-token' + ) + ) + .to_return(status: 200, body: { success: false }.to_json) + + 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 + + it 'allows request through if turnstile verification is unavailable' do + stub_request(:post, request_url) + .with( + body: hash_including( + secret: 'test-secret', + response: 'test-token' + ) + ) + .to_timeout + + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['ok']).to be(true) + end + end end end