Skip to content
Merged
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
27 changes: 25 additions & 2 deletions app/controllers/api/subscriptions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Comment thread
cocomarine marked this conversation as resolved.
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
50 changes: 50 additions & 0 deletions app/services/subscriptions/turnstile_verifier.rb
Original file line number Diff line number Diff line change
@@ -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
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
47 changes: 46 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 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,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
86 changes: 86 additions & 0 deletions spec/services/subscriptions/turnstile_verifier_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading