Fixes https://linear.app/chatwoot/issue/CW-6494/add-shopify-mandatory-compliance-webhooks-for-app-store-listing Shopify requires all public apps to handle three GDPR compliance webhooks before they can be listed on the App Store. Their automated review checks for these endpoints and verifies that apps validate HMAC signatures on incoming requests. We were failing both checks. This PR adds a single webhook endpoint at `POST /webhooks/shopify` that receives all three compliance events. When Shopify sends a webhook, it signs the payload with our app's client secret and includes the signature in the `X-Shopify-Hmac-SHA256` header. Our controller reads the raw body, computes the expected HMAC-SHA256 digest, and rejects mismatched requests with a 401. Shopify identifies the event type through the `X-Shopify-Topic` header. For `customers/data_request` and `customers/redact`, we simply acknowledge with a 200—Chatwoot doesn't persist any Shopify customer data. All order lookups happen as live API calls at query time. For `shop/redact`, which Shopify sends after a merchant uninstalls the app, we delete the integration hook for that shop domain and remove the stored access token and configuration. ### How to test via Rails console ``` secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil) body = '{"shop_domain":"test.myshopify.com"}' valid_hmac = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, body)) ``` #### Test 1: No HMAC → 401 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Topic' => 'customers/data_request' } app.response.code # => "401" ``` #### Test 2: Invalid HMAC → 401 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => 'invalid', 'X-Shopify-Topic' => 'customers/data_request' } app.response.code # => "401" ``` #### Test 3: Valid HMAC, customers/data_request → 200 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/data_request' } app.response.code # => "200" ``` #### Test 4: Valid HMAC, customers/redact → 200 ``` app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'customers/redact' } app.response.code # => "200" ``` #### Test 5: Valid HMAC, shop/redact → 200 (deletes hook) ``` # First check if a hook exists for this domain: Integrations::Hook.where(app_id: 'shopify', reference_id: 'test.myshopify.com').count app.post '/webhooks/shopify', params: body, headers: { 'Content-Type' => 'application/json', 'X-Shopify-Hmac-SHA256' => valid_hmac, 'X-Shopify-Topic' => 'shop/redact' } app.response.code # => "200" ``` --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
36 lines
925 B
Ruby
36 lines
925 B
Ruby
class Webhooks::ShopifyController < ActionController::API
|
|
before_action :verify_hmac!
|
|
|
|
def events
|
|
case request.headers['X-Shopify-Topic']
|
|
when 'shop/redact'
|
|
handle_shop_redact
|
|
end
|
|
|
|
head :ok
|
|
end
|
|
|
|
private
|
|
|
|
def verify_hmac!
|
|
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
|
|
return head :unauthorized if secret.blank?
|
|
|
|
data = request.body.read
|
|
request.body.rewind
|
|
|
|
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
|
|
return head :unauthorized if hmac_header.blank?
|
|
|
|
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
|
|
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
|
|
end
|
|
|
|
def handle_shop_redact
|
|
shop_domain = params[:shop_domain]
|
|
return if shop_domain.blank?
|
|
|
|
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
|
|
end
|
|
end
|