Skip to content

Commit

Permalink
fix: Plaid webhook verification (#1824)
Browse files Browse the repository at this point in the history
* Fix Plaid webhook verification

* Fix client creation in webhook controller
  • Loading branch information
zachgoll authored Feb 7, 2025
1 parent 331de2f commit 5eb5ec7
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 55 deletions.
15 changes: 10 additions & 5 deletions app/controllers/concerns/accountable_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,27 +53,32 @@ def destroy
private
def set_link_token
@us_link_token = Current.family.get_link_token(
webhooks_url: webhooks_url,
webhooks_url: plaid_us_webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name,
region: :us
)

if Current.family.eu?
@eu_link_token = Current.family.get_link_token(
webhooks_url: webhooks_url,
webhooks_url: plaid_eu_webhooks_url,
redirect_url: accounts_url,
accountable_type: accountable_type.name,
region: :eu
)
end
end

def webhooks_url
def plaid_us_webhooks_url
return webhooks_plaid_url if Rails.env.production?

base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/"))
base_url + "/webhooks/plaid"
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
end

def plaid_eu_webhooks_url
return webhooks_plaid_eu_url if Rails.env.production?

ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
end

def accountable_type
Expand Down
21 changes: 19 additions & 2 deletions app/controllers/webhooks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,25 @@ def plaid
webhook_body = request.body.read
plaid_verification_header = request.headers["Plaid-Verification"]

Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body)
Provider::Plaid.process_webhook(webhook_body)
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)

client.validate_webhook!(plaid_verification_header, webhook_body)
client.process_webhook(webhook_body)

render json: { received: true }, status: :ok
rescue => error
Sentry.capture_exception(error)
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
end

def plaid_eu
webhook_body = request.body.read
plaid_verification_header = request.headers["Plaid-Verification"]

client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)

client.validate_webhook!(plaid_verification_header, webhook_body)
client.process_webhook(webhook_body)

render json: { received: true }, status: :ok
rescue => error
Expand Down
4 changes: 2 additions & 2 deletions app/models/concerns/plaidable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ module Plaidable

class_methods do
def plaid_us_provider
Provider::Plaid.new(Rails.application.config.plaid, :us) if Rails.application.config.plaid
Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid
end

def plaid_eu_provider
Provider::Plaid.new(Rails.application.config.plaid_eu, :eu) if Rails.application.config.plaid_eu
Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu
end

def plaid_provider_for_region(region)
Expand Down
94 changes: 48 additions & 46 deletions app/models/provider/plaid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,63 +4,65 @@ class Provider::Plaid
MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730

class << self
def process_webhook(webhook_body)
parsed = JSON.parse(webhook_body)
type = parsed["webhook_type"]
code = parsed["webhook_code"]

item = PlaidItem.find_by(plaid_id: parsed["item_id"])

case [ type, code ]
when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ]
item.sync_later
when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ]
item.sync_later
when [ "HOLDINGS", "DEFAULT_UPDATE" ]
item.sync_later
else
Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}")
end
end
def initialize(config, region: :us)
@client = Plaid::PlaidApi.new(
Plaid::ApiClient.new(config)
)
@region = region
end

def validate_webhook!(verification_header, raw_body)
jwks_loader = ->(options) do
key_id = options[:kid]
def process_webhook(webhook_body)
parsed = JSON.parse(webhook_body)

jwk_response = client.webhook_verification_key_get(
Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)
)
type = parsed["webhook_type"]
code = parsed["webhook_code"]

jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])
item = PlaidItem.find_by(plaid_id: parsed["item_id"])

jwks.filter! { |key| key[:use] == "sig" }
jwks
end
case [ type, code ]
when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ]
item.sync_later
when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ]
item.sync_later
when [ "HOLDINGS", "DEFAULT_UPDATE" ]
item.sync_later
else
Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}")
end
rescue => error
# Processing errors shouldn't return a 400 to Plaid since they are internal, so capture silently
Sentry.capture_exception(error)
end

def validate_webhook!(verification_header, raw_body)
jwks_loader = ->(options) do
key_id = options[:kid]

payload, _header = JWT.decode(
verification_header, nil, true,
{
algorithms: [ "ES256" ],
jwks: jwks_loader,
verify_expiration: false
}
jwk_response = client.webhook_verification_key_get(
Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)
)

issued_at = Time.at(payload["iat"])
raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes
jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])

expected_hash = payload["request_body_sha256"]
actual_hash = Digest::SHA256.hexdigest(raw_body)
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
jwks.filter! { |key| key[:use] == "sig" }
jwks
end
end

def initialize(config, region)
@client = Plaid::PlaidApi.new(
Plaid::ApiClient.new(config)
payload, _header = JWT.decode(
verification_header, nil, true,
{
algorithms: [ "ES256" ],
jwks: jwks_loader,
verify_expiration: false
}
)
@region = region

issued_at = Time.at(payload["iat"])
raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes

expected_hash = payload["request_body_sha256"]
actual_hash = Digest::SHA256.hexdigest(raw_body)
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
end

def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil)
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@

namespace :webhooks do
post "plaid"
post "plaid_eu"
post "stripe"
end

Expand Down

0 comments on commit 5eb5ec7

Please sign in to comment.