Skip to content

Commit 7992177

Browse files
Validate shop domains
1 parent 9deacbb commit 7992177

12 files changed

Lines changed: 190 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Note: For changes to the API, see https://shopify.dev/changelog?filter=api
44
## Unreleased
55
- Add support for 2026-04 API version
6+
- Add shop validation
67

78
## 16.1.0 (2026-01-15)
89
- Add support for 2026-01 API version

lib/shopify_api/auth/client_credentials.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ def client_credentials(shop:)
2222
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
2323
end
2424

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

4445
Session.from(
45-
shop: shop,
46+
shop: validated_shop,
4647
access_token_response: Oauth::AccessTokenResponse.from_hash(response_hash),
4748
)
4849
end

lib/shopify_api/auth/refresh_token.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ def refresh_access_token(shop:, refresh_token:)
2121
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
2222
end
2323

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

4950
Session.from(
50-
shop:,
51+
shop: validated_shop,
5152
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
5253
)
5354
end

lib/shopify_api/auth/token_exchange.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ def exchange_token(shop:, session_token:, requested_token_type:)
3636
raise ShopifyAPI::Errors::UnsupportedOauthError,
3737
"Cannot perform OAuth Token Exchange for non embedded apps." unless ShopifyAPI::Context.embedded?
3838

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

42-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
43+
shop_session = ShopifyAPI::Auth::Session.new(shop: dest_shop)
4344
body = {
4445
client_id: ShopifyAPI::Context.api_key,
4546
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -74,7 +75,7 @@ def exchange_token(shop:, session_token:, requested_token_type:)
7475
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
7576

7677
Session.from(
77-
shop: shop,
78+
shop: dest_shop,
7879
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
7980
)
8081
end
@@ -91,7 +92,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
9192
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
9293
end
9394

94-
shop_session = ShopifyAPI::Auth::Session.new(shop: shop)
95+
validated_shop = Utils::ShopValidator.validate!(shop)
96+
shop_session = ShopifyAPI::Auth::Session.new(shop: validated_shop)
9597
body = {
9698
client_id: ShopifyAPI::Context.api_key,
9799
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -120,7 +122,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
120122
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
121123

122124
Session.from(
123-
shop: shop,
125+
shop: validated_shop,
124126
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
125127
)
126128
end

lib/shopify_api/clients/graphql/storefront.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ def initialize(shop, private_token: nil, public_token: nil, api_version: nil)
1919
raise ArgumentError, "Storefront client requires either private_token or public_token to be provided"
2020
end
2121

22+
validated_shop = Utils::ShopValidator.validate!(shop)
2223
session = Auth::Session.new(
23-
id: shop,
24-
shop: shop,
24+
id: validated_shop,
25+
shop: validated_shop,
2526
access_token: "",
2627
is_online: false,
2728
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module ShopifyAPI
5+
module Errors
6+
class InvalidShopError < StandardError
7+
end
8+
end
9+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module ShopifyAPI
5+
module Utils
6+
class ShopValidator
7+
extend T::Sig
8+
9+
SHOPIFY_OWNED_SUFFIXES = T.let([
10+
".myshopify.com",
11+
".myshopify.io",
12+
].freeze, T::Array[String])
13+
14+
class << self
15+
extend T::Sig
16+
17+
sig { params(shop: String).returns(String) }
18+
def validate!(shop)
19+
cleaned = shop.to_s.strip.downcase.gsub(%r{\A(https?://)?}, "").gsub(%r{/\z}, "")
20+
21+
if cleaned.empty? || !SHOPIFY_OWNED_SUFFIXES.any? { |suffix| cleaned.end_with?(suffix) }
22+
raise Errors::InvalidShopError,
23+
"shop must end with one of #{SHOPIFY_OWNED_SUFFIXES.join(", ")}, got: #{shop.inspect}"
24+
end
25+
26+
cleaned
27+
end
28+
end
29+
end
30+
end
31+
end

test/auth/client_credentials_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ def test_client_credentials_context_not_setup
3030
end
3131
end
3232

33+
def test_client_credentials_rejects_non_shopify_domain
34+
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
35+
ShopifyAPI::Auth::ClientCredentials.client_credentials(shop: "attacker.example")
36+
end
37+
end
38+
3339
def test_client_credentials_offline_token
3440
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
3541
.with(body: @client_credentials_request)

test/auth/refresh_token_test.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ def setup
2929
}
3030
end
3131

32+
def test_refresh_access_token_rejects_non_shopify_domain
33+
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
34+
ShopifyAPI::Auth::RefreshToken.refresh_access_token(
35+
shop: "attacker.example",
36+
refresh_token: @refresh_token,
37+
)
38+
end
39+
end
40+
3241
def test_refresh_access_token_success
3342
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
3443
.with(body: @refresh_token_request)

test/auth/token_exchange_test.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,39 @@ def test_exchange_token_offline_token
174174
assert_equal(expected_session, session)
175175
end
176176

177+
def test_exchange_token_uses_shop_from_session_token_dest_claim
178+
modify_context(is_embedded: true, expiring_offline_access_tokens: false)
179+
180+
# Pass a different shop than what's in the JWT dest claim
181+
different_shop = "other-shop.myshopify.com"
182+
183+
# The request should go to the shop from the JWT dest claim, not the passed shop
184+
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
185+
.with(body: @non_expiring_offline_token_exchange_request)
186+
.to_return(body: @offline_token_response.to_json, headers: { content_type: "application/json" })
187+
188+
expected_session = ShopifyAPI::Auth::Session.new(
189+
id: "offline_#{@shop}",
190+
shop: @shop,
191+
access_token: @offline_token_response[:access_token],
192+
scope: @offline_token_response[:scope],
193+
is_online: false,
194+
expires: nil,
195+
shopify_session_id: @offline_token_response[:session],
196+
refresh_token: nil,
197+
refresh_token_expires: nil,
198+
)
199+
200+
session = ShopifyAPI::Auth::TokenExchange.exchange_token(
201+
shop: different_shop,
202+
session_token: @session_token,
203+
requested_token_type: ShopifyAPI::Auth::TokenExchange::RequestedTokenType::OFFLINE_ACCESS_TOKEN,
204+
)
205+
206+
assert_equal(expected_session, session)
207+
assert_equal(@shop, session.shop)
208+
end
209+
177210
def test_exchange_token_expiring_offline_token
178211
modify_context(is_embedded: true, expiring_offline_access_tokens: true)
179212
stub_request(:post, "https://#{@shop}/admin/oauth/access_token")
@@ -229,6 +262,15 @@ def test_exchange_token_online_token
229262
assert_equal(expected_session, session)
230263
end
231264

265+
def test_migrate_to_expiring_token_rejects_non_shopify_domain
266+
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
267+
ShopifyAPI::Auth::TokenExchange.migrate_to_expiring_token(
268+
shop: "attacker.example",
269+
non_expiring_offline_token: "old-offline-token-123",
270+
)
271+
end
272+
end
273+
232274
def test_migrate_to_expiring_token_context_not_setup
233275
modify_context(api_key: "", api_secret_key: "", host: "")
234276

0 commit comments

Comments
 (0)