Skip to content

Commit a31c59d

Browse files
Always validate shop domain
1 parent 5bad867 commit a31c59d

11 files changed

Lines changed: 150 additions & 8 deletions

File tree

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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
9292
"ShopifyAPI::Context not setup, please call ShopifyAPI::Context.setup"
9393
end
9494

95-
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)
9697
body = {
9798
client_id: ShopifyAPI::Context.api_key,
9899
client_secret: ShopifyAPI::Context.api_secret_key,
@@ -121,7 +122,7 @@ def migrate_to_expiring_token(shop:, non_expiring_offline_token:)
121122
session_params = T.cast(response.body, T::Hash[String, T.untyped]).to_h
122123

123124
Session.from(
124-
shop: shop,
125+
shop: validated_shop,
125126
access_token_response: Oauth::AccessTokenResponse.from_hash(session_params),
126127
)
127128
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,15 @@ def test_exchange_token_online_token
262262
assert_equal(expected_session, session)
263263
end
264264

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+
265274
def test_migrate_to_expiring_token_context_not_setup
266275
modify_context(api_key: "", api_secret_key: "", host: "")
267276

test/clients/graphql/storefront_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ def build_client
3232
end
3333
end
3434

35+
def test_rejects_non_shopify_domain
36+
assert_raises(ShopifyAPI::Errors::InvalidShopError) do
37+
ShopifyAPI::Clients::Graphql::Storefront.new("attacker.example", public_token: "token")
38+
end
39+
end
40+
3541
def test_can_query_using_private_token
3642
query = <<~QUERY
3743
{

0 commit comments

Comments
 (0)