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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
## Unreleased
- Add support for 2026-04 API version
- Add shop validation

## 16.1.0 (2026-01-15)
- Add support for 2026-01 API version
Expand Down
5 changes: 3 additions & 2 deletions lib/shopify_api/auth/client_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ def client_credentials(shop:)
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
validated_shop = Utils::ShopValidator.validate!(shop)
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
Expand All @@ -42,7 +43,7 @@ def client_credentials(shop:)
response_hash = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
shop: validated_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
)
end
Expand Down
5 changes: 3 additions & 2 deletions lib/shopify_api/auth/refresh_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ def refresh_access_token(shop:, refresh_token:)
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop:)
validated_shop = Utils::ShopValidator.validate!(shop)
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
Expand All @@ -47,7 +48,7 @@ def refresh_access_token(shop:, refresh_token:)
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop:,
shop: validated_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
Expand Down
14 changes: 8 additions & 6 deletions lib/shopify_api/auth/token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ def exchange_token(shop:, session_token:, requested_token_type:)
raise ShopifyAPI::Errors::UnsupportedOauthError,
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?

# Validate the session token content
ShopifyAPI::Auth::JwtPayload.new(session_token)
# Validate the session token and use the shop from the token's `dest` claim
jwt_payload = ShopifyAPI::Auth::JwtPayload.new(session_token)
dest_shop = jwt_payload.shop

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
shop_session = ShopifyAPI::Auth::Session.new(shop: dest_shop)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are no longer using the shop param. We could deprecate it, so it can be removed in the next major version.

  jwt_payload = ShopifyAPI::Auth::JwtPayload.new(session_token)
  dest_shop = jwt_payload.shop

  if shop != dest_shop
    ShopifyAPI::Context.logger.warn(
      "shop parameter (#{shop}) does not match session token dest claim (#{dest_shop}). " \
      "The dest claim will be used. The shop parameter is deprecated for exchange_token " \
      "and will be removed in a future major version."
    )
  end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also agree! We should remove shop from this method's arguments

body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
Expand Down Expand Up @@ -74,7 +75,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
shop: dest_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
Expand All @@ -91,7 +92,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
end

shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
validated_shop = Utils::ShopValidator.validate!(shop)
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
body = {
client_id: ShopifyAPI::Context.api_key,
client_secret: ShopifyAPI::Context.api_secret_key,
Expand Down Expand Up @@ -120,7 +122,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h

Session.from(
shop: shop,
shop: validated_shop,
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
)
end
Expand Down
5 changes: 3 additions & 2 deletions lib/shopify_api/clients/graphql/storefront.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ def initialize(shop, private_token: nil, public_token: nil, api_version: nil)
raise ArgumentError, "Storefront client requires either private_token or public_token to be provided"
end

validated_shop = Utils::ShopValidator.validate!(shop)
session = Auth::Session.new(
id: shop,
shop: shop,
id: validated_shop,
shop: validated_shop,
access_token: "",
is_online: false,
)
Expand Down
9 changes: 9 additions & 0 deletions lib/shopify_api/errors/invalid_shop_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Errors
class InvalidShopError < StandardError
end
end
end
31 changes: 31 additions & 0 deletions lib/shopify_api/utils/shop_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# typed: strict
# frozen_string_literal: true

module ShopifyAPI
module Utils
class ShopValidator
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to modify the ShopValidator check to protect against URLs like "attacker.com/.myshopify.com". This passes validation since it ends with .myshopify.com, but URI.parse resolves the host to
attacker.com

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could probably be module similar to hmac

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also a similar sanitize method from shopify_app gem, I wonder if the logic can be extracted so we don't have to maintain 2 packages for validating Shopify URLS

extend T::Sig

SHOPIFY_OWNED_SUFFIXES = T.let([
".myshopify.com",
".myshopify.io",
].freeze, T::Array[String])

class << self
extend T::Sig

sig { params(shop: String).returns(String) }
def validate!(shop)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate might be a misleading method name since it does more than validation and returns the sanitized shop name

cleaned = shop.to_s.strip.downcase.gsub(%r{\A(https?://)?}, "").gsub(%r{/\z}, "")

if cleaned.empty? || !SHOPIFY_OWNED_SUFFIXES.any? { |suffix| cleaned.end_with?(suffix) }
raise Errors::InvalidShopError,
"shop must end with one of #{SHOPIFY_OWNED_SUFFIXES.join(", ")}, got: #{shop.inspect}"
end

cleaned
end
end
end
end
end
6 changes: 6 additions & 0 deletions test/auth/client_credentials_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ def test_client_credentials_context_not_setup
end
end

def test_client_credentials_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: "attacker.example")
end
end

def test_client_credentials_offline_token
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @client_credentials_request)
Expand Down
9 changes: 9 additions & 0 deletions test/auth/refresh_token_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ def setup
}
end

def test_refresh_access_token_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Auth::RefreshToken.refresh_access_token(
shop: "attacker.example",
refresh_token: @refresh_token,
)
end
end

def test_refresh_access_token_success
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @refresh_token_request)
Expand Down
42 changes: 42 additions & 0 deletions test/auth/token_exchange_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,39 @@ def test_exchange_token_offline_token
assert_equal(expected_session, session)
end

def test_exchange_token_uses_shop_from_session_token_dest_claim
modify_context(is_embedded: true, expiring_offline_access_tokens: false)

# Pass a different shop than what's in the JWT dest claim
different_shop = "other-shop.myshopify.com"

# The request should go to the shop from the JWT dest claim, not the passed shop
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
.with(body: @non_expiring_offline_token_exchange_request)
.to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" })

expected_session = ShopifyAPI::Auth::Session.new(
id: "offline_#{@shop}",
shop: @shop,
access_token: @offline_token_response[:access_token],
scope: @offline_token_response[:scope],
is_online: false,
expires: nil,
shopify_session_id: @offline_token_response[:session],
refresh_token: nil,
refresh_token_expires: nil,
)

session = ShopifyAPI::Auth::TokenExchange.exchange_token(
shop: different_shop,
session_token: @session_token,
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
)

assert_equal(expected_session, session)
assert_equal(@shop, session.shop)
end

def test_exchange_token_expiring_offline_token
modify_context(is_embedded: true, expiring_offline_access_tokens: true)
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
Expand Down Expand Up @@ -229,6 +262,15 @@ def test_exchange_token_online_token
assert_equal(expected_session, session)
end

def test_migrate_to_expiring_token_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
shop: "attacker.example",
non_expiring_offline_token: "old-offline-token-123",
)
end
end

def test_migrate_to_expiring_token_context_not_setup
modify_context(api_key: "", api_secret_key: "", host: "")

Expand Down
6 changes: 6 additions & 0 deletions test/clients/graphql/storefront_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ def build_client
end
end

def test_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Clients::Graphql::Storefront.new("attacker.example", public_token: "token")
end
end

def test_can_query_using_private_token
query = <<~QUERY
{
Expand Down
69 changes: 69 additions & 0 deletions test/utils/shop_validator_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# typed: false
# frozen_string_literal: true

require_relative "../test_helper"

module ShopifyAPITest
module Utils
class ShopValidatorTest < Test::Unit::TestCase
def test_accepts_valid_myshopify_com_domain
assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.validate!("test-shop.myshopify.com"))
end

def test_accepts_valid_myshopify_io_domain
assert_equal("test-shop.myshopify.io", ShopifyAPI::Utils::ShopValidator.validate!("test-shop.myshopify.io"))
end

def test_strips_https_scheme
assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.validate!("https://test-shop.myshopify.com"))
end

def test_strips_http_scheme
assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.validate!("http://test-shop.myshopify.com"))
end

def test_strips_trailing_slash
assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.validate!("test-shop.myshopify.com/"))
end

def test_normalizes_to_lowercase
assert_equal("test-shop.myshopify.com", ShopifyAPI::Utils::ShopValidator.validate!("Test-Shop.MyShopify.com"))
end

def test_strips_whitespace
result = ShopifyAPI::Utils::ShopValidator.validate!(" test-shop.myshopify.com ")
assert_equal("test-shop.myshopify.com", result)
end

def test_rejects_attacker_controlled_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Utils::ShopValidator.validate!("attacker.example")
end
end

def test_rejects_empty_string
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Utils::ShopValidator.validate!("")
end
end

def test_rejects_non_shopify_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Utils::ShopValidator.validate!("evil.com")
end
end

def test_rejects_shopify_suffix_as_subdomain_of_attacker
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Utils::ShopValidator.validate!("myshopify.com.evil.com")
end
end

def test_rejects_similar_looking_domain
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
ShopifyAPI::Utils::ShopValidator.validate!("test-shop.notmyshopify.com")
end
end
end
end
end
Loading