Skip to content
Draft
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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
PARDOT_SUBSCRIPTION_URL=

# Cloudflare Turnstile bot protection for the subscription form
CLOUDFLARE_TURNSTILE_SECRET_KEY=
35 changes: 35 additions & 0 deletions app/controllers/api/subscriptions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
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
53 changes: 52 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: 'teacher@example.com',
test_opt_in: true,
privacy_policy: true
privacy_policy: true,
turnstile_token: 'test-token'
}
}
end
Expand Down Expand Up @@ -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
Loading