Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
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
56 changes: 54 additions & 2 deletions app/controllers/api/subscriptions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

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
# 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?
Expand Down Expand Up @@ -39,8 +44,55 @@ def create

private

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')
render json: {
ok: false,
error_code: 'turnstile_verification_failed',
message: 'Bot protection check failed. Please try again.'
}, status: :unprocessable_content
Comment thread
cocomarine marked this conversation as resolved.
end

def valid_turnstile_token?
token = params.dig(:subscription, :turnstile_token)
return false if token.blank?

response = turnstile_connection.post(
API_URL,
{
secret: Rails.configuration.x.cloudflare_turnstile.secret_key,
response: token,
Comment thread
cocomarine marked this conversation as resolved.
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 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

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)
params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy, :turnstile_token)
end

def subscriptions_submitter
Expand Down
3 changes: 3 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
98 changes: 97 additions & 1 deletion spec/requests/api/subscriptions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
subscription: {
email: '[email protected]',
test_opt_in: true,
privacy_policy: true
privacy_policy: true,
turnstile_token: 'test-token'
}
}
end
Expand All @@ -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
Expand Down Expand Up @@ -110,5 +113,98 @@
'message' => 'Subscription provider rejected the request.'
)
end

describe 'Cloudflare Turnstile integration' do
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(
enabled: true,
secret_key: 'test-secret'
)
end

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')
end
end

shared_examples 'fail-open turnstile response' 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 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

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

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

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

it_behaves_like 'fail-open turnstile response'
end
end
end
end
Loading