From 3c87df7594504e0815c77db69f480fa89f4a3c8e Mon Sep 17 00:00:00 2001 From: cocomarine Date: Wed, 6 May 2026 09:06:30 +0100 Subject: [PATCH 1/7] Config turnstile screte and add test Co-authored-by: Copilot --- .env.example | 5 ++- config/application.rb | 3 ++ spec/requests/api/subscriptions_spec.rb | 51 ++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 3e52404cc..a36ff1404 100644 --- a/.env.example +++ b/.env.example @@ -67,4 +67,7 @@ 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 for the subscription form +CLOUDFLARE_TURNSTILE_SECRET_KEY= \ No newline at end of file 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..a0b0a46d3 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,53 @@ '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(:enabled).and_return(true) + allow(Rails.configuration.x.cloudflare_turnstile).to receive(:secret_key).and_return('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 eq(true) + end + end end end From 35fe29dcc68dcd94b828ba10d69cbe82d0cd6927 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Wed, 6 May 2026 09:13:21 +0100 Subject: [PATCH 2/7] linting --- spec/requests/api/subscriptions_spec.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb index a0b0a46d3..0ca5a8ad3 100644 --- a/spec/requests/api/subscriptions_spec.rb +++ b/spec/requests/api/subscriptions_spec.rb @@ -114,10 +114,12 @@ 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(:enabled).and_return(true) - allow(Rails.configuration.x.cloudflare_turnstile).to receive(:secret_key).and_return('test-secret') + 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 @@ -156,7 +158,7 @@ post(path, params: payload, as: :json) expect(response).to have_http_status(:ok) - expect(response.parsed_body['ok']).to eq(true) + expect(response.parsed_body['ok']).to be(true) end end end From d7426c4420ec1fd0736e7a8130b09a20809502bb Mon Sep 17 00:00:00 2001 From: cocomarine Date: Wed, 6 May 2026 09:23:13 +0100 Subject: [PATCH 3/7] update controller Co-authored-by: Copilot --- .../api/subscriptions_controller.rb | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 From 04866c6b6a9e1a53ce108db62ccbc15b1391a605 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 7 May 2026 11:36:51 +0100 Subject: [PATCH 4/7] add turnstile key to env --- .env.example | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index a36ff1404..7d6357f48 100644 --- a/.env.example +++ b/.env.example @@ -69,5 +69,7 @@ SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ # Pardot Form Handler endpoint for subscription forwarding PARDOT_SUBSCRIPTION_URL= -# Cloudflare Turnstile bot protection for the subscription form -CLOUDFLARE_TURNSTILE_SECRET_KEY= \ No newline at end of file +# 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 From e7a835f75f08ddd5c842963506310c2a5ae17bce Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 7 May 2026 15:20:55 +0100 Subject: [PATCH 5/7] update errors and test Co-authored-by: Copilot --- .../api/subscriptions_controller.rb | 9 +- spec/requests/api/subscriptions_spec.rb | 103 +++++++++++++----- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index b5cc4e193..898dc227d 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -67,8 +67,15 @@ def valid_turnstile_token? remoteip: request.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 StandardError + 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 diff --git a/spec/requests/api/subscriptions_spec.rb b/spec/requests/api/subscriptions_spec.rb index 0ca5a8ad3..25ca9f0a8 100644 --- a/spec/requests/api/subscriptions_spec.rb +++ b/spec/requests/api/subscriptions_spec.rb @@ -38,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 @@ -113,7 +115,9 @@ end describe 'Cloudflare Turnstile integration' do - let(:request_url) { 'https://challenges.cloudflare.com/turnstile/v0/siteverify' } + let(:request_url) { Api::SubscriptionsController::API_URL } + let(:turnstile_request_body) { { 'secret' => 'test-secret', 'response' => 'test-token', 'remoteip' => '127.0.0.1' } } + let(:post_params) { payload } before do allow(Rails.configuration.x.cloudflare_turnstile).to receive_messages( @@ -122,43 +126,84 @@ ) end - it 'returns 422 when turnstile token is missing' do - post(path, params: payload.deep_merge(subscription: { turnstile_token: '' }), as: :json) + shared_examples 'turnstile verification failure' do + it 'returns 422 with turnstile_verification_failed error code' do + post(path, params: post_params, as: :json) - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed') + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed') + end 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) + shared_examples 'fail-open turnstile response' do + it 'allows the request through' do + post(path, params: payload, as: :json) - 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 turnstile token is missing' do + let(:post_params) { payload.deep_merge(subscription: { turnstile_token: '' }) } + + it_behaves_like 'turnstile verification failure' + end + + context 'when turnstile verification fails' do + before do + stub_request(:post, request_url) + .with(body: turnstile_request_body) + .to_return(status: 200, body: { success: false }.to_json) + end + + it_behaves_like 'turnstile verification failure' + end - expect(response).to have_http_status(:unprocessable_content) - expect(response.parsed_body['error_code']).to eq('turnstile_verification_failed') + context 'when turnstile verification times out' do + before do + stub_request(:post, request_url) + .with(body: turnstile_request_body) + .to_timeout + end + + it 'allows the request through and reports to Sentry' do + post(path, params: payload, as: :json) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['ok']).to be(true) + expect(Sentry).to have_received(:capture_exception).with(be_a(Faraday::Error)) + end 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 + context 'when Cloudflare returns a server error' do + before do + stub_request(:post, request_url) + .with(body: turnstile_request_body) + .to_return(status: 500, body: 'Internal Server Error') + end + + it_behaves_like 'fail-open turnstile response' + end + + context 'when Cloudflare returns malformed JSON' do + before do + stub_request(:post, request_url) + .with(body: turnstile_request_body) + .to_return(status: 200, body: 'not-json') + end + + it_behaves_like 'fail-open turnstile response' + end - post(path, params: payload, as: :json) + context 'when turnstile token is valid' do + before do + stub_request(:post, request_url) + .with(body: turnstile_request_body) + .to_return(status: 200, body: { success: true }.to_json) + end - expect(response).to have_http_status(:ok) - expect(response.parsed_body['ok']).to be(true) + it_behaves_like 'fail-open turnstile response' end end end From e2e4784568a6585b58d28616af37f0a12ed5dc8a Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 7 May 2026 16:12:08 +0100 Subject: [PATCH 6/7] update params and add timeouts to faraday --- .env.example | 2 +- app/controllers/api/subscriptions_controller.rb | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 7d6357f48..5699b9873 100644 --- a/.env.example +++ b/.env.example @@ -69,7 +69,7 @@ SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/ # Pardot Form Handler endpoint for subscription forwarding PARDOT_SUBSCRIPTION_URL= -# Cloudflare Turnstile bot protection.This is a test key that always passes. +# 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 898dc227d..4696d5a0d 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -7,7 +7,8 @@ class SubscriptionsController < ApiController API_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify' 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? @@ -45,6 +46,7 @@ def create def check_cloudflare_turnstile return unless Rails.configuration.x.cloudflare_turnstile.enabled + return if params[:subscription].blank? return if valid_turnstile_token? Rails.logger.warn('[subscriptions#create] outcome=failure error_code=turnstile_verification_failed') @@ -65,6 +67,9 @@ def valid_turnstile_token? secret: Rails.configuration.x.cloudflare_turnstile.secret_key, response: token, remoteip: request.remote_ip + }, + { + request: { timeout: 5, open_timeout: 2 } } ) unless response.success? @@ -82,7 +87,7 @@ def valid_turnstile_token? 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 From d6044f9fed94b575e5af4d35605bb807afa0b84b Mon Sep 17 00:00:00 2001 From: cocomarine Date: Thu, 7 May 2026 16:17:34 +0100 Subject: [PATCH 7/7] fix faraday connection --- app/controllers/api/subscriptions_controller.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index 4696d5a0d..9430008a4 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -61,15 +61,12 @@ def valid_turnstile_token? token = params.dig(:subscription, :turnstile_token) return false if token.blank? - response = Faraday.post( + response = turnstile_connection.post( API_URL, { secret: Rails.configuration.x.cloudflare_turnstile.secret_key, response: token, remoteip: request.remote_ip - }, - { - request: { timeout: 5, open_timeout: 2 } } ) unless response.success? @@ -86,6 +83,14 @@ def valid_turnstile_token? true end + def turnstile_connection + Faraday.new do |f| + f.request :url_encoded + f.options.timeout = 5 + f.options.open_timeout = 2 + end + end + def subscription_params params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy, :turnstile_token) end