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
5 changes: 5 additions & 0 deletions .changeset/reject-store-domain-delimiters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': patch
---

Parse supported store URLs via `URL.hostname` and reject unsupported domain delimiters before normalizing Shopify store FQDNs.
27 changes: 27 additions & 0 deletions packages/cli-kit/src/public/node/context/fqdn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,14 @@ describe('normalizeStore', () => {
expect(got).toEqual('example.myshopify.com')
})

test('parses supported store URLs using the URL hostname', async () => {
// When
const got = normalizeStoreFqdn('https://Example.myshopify.com/admin/')

// Then
expect(got).toEqual('example.myshopify.com')
})

test('parses store name without domain', async () => {
// When
const got = normalizeStoreFqdn('example')
Expand Down Expand Up @@ -250,4 +258,23 @@ describe('normalizeStore', () => {
// Then
expect(got).toEqual('example.exampleshopify.io.myshopify.com')
})

test.each([
'attacker#.myshopify.com',
'attacker?.myshopify.com',
'attacker/.myshopify.com',
'attacker.com/x.myshopify.com',
'http://attacker-domain.com#x.myshopify.com',
'https://example.myshopify.com/admin?redirect=attacker',
'127.0.0.1:8443#.myshopify.com',
'attacker.com@x.myshopify.com',
'/x.myshopify.com',
'/',
])('rejects store values that URL parsers can reinterpret (%s)', (store) => {
expect(() => normalizeStoreFqdn(store)).toThrow('Invalid store value')
})

test('rejects URL paths other than /admin', async () => {
expect(() => normalizeStoreFqdn('https://example.myshopify.com/themes')).toThrow('Invalid store value')
})
})
66 changes: 62 additions & 4 deletions packages/cli-kit/src/public/node/context/fqdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,10 @@ export async function identityFqdn(): Promise<string> {
* @returns Normalized store name.
*/
export function normalizeStoreFqdn(store: string): string {
const storeFqdn = store
.replace(/^https?:\/\//, '')
.replace(/\/$/, '')
.replace(/\/admin$/, '')
const storeFqdn = parseStoreFqdn(store)

assertValidStoreFqdn(storeFqdn, store)

const addDomain = (storeFqdn: string) => {
switch (serviceEnvironment()) {
case 'local':
Expand All @@ -143,6 +143,64 @@ export function normalizeStoreFqdn(store: string): string {
return containDomain(storeFqdn) ? storeFqdn : addDomain(storeFqdn)
}

const invalidStoreFqdnTryMessage =
'Provide a store handle or Shopify store domain, for example "example" or "example.myshopify.com". ' +
'Store URLs may only include the optional /admin path.'

function parseStoreFqdn(store: string): string {
const trimmedStore = store.trim()

if (trimmedStore.startsWith('/')) {
throw new AbortError(`Invalid store value: ${store}`, invalidStoreFqdnTryMessage)
}

const storeUrl = parseSupportedStoreUrl(
/^https?:\/\//i.test(trimmedStore) ? trimmedStore : `https://${trimmedStore}`,
store,
)
return storeUrl.hostname
}

function parseSupportedStoreUrl(store: string, originalStore: string): URL {
let storeUrl: URL
try {
storeUrl = new URL(store)
} catch {
throw new AbortError(`Invalid store value: ${originalStore}`, invalidStoreFqdnTryMessage)
}

const supportedPath = storeUrl.pathname === '/' || storeUrl.pathname === '/admin' || storeUrl.pathname === '/admin/'
if (
!storeUrl.hostname ||
storeUrl.username ||
storeUrl.password ||
storeUrl.port ||
storeUrl.search ||
storeUrl.hash ||
!supportedPath
) {
throw new AbortError(`Invalid store value: ${originalStore}`, invalidStoreFqdnTryMessage)
}

return storeUrl
}

function assertValidStoreFqdn(storeFqdn: string, store: string) {
if (storeFqdn.length === 0 || storeFqdn.length > 253) {
throw new AbortError(`Invalid store value: ${store}`, invalidStoreFqdnTryMessage)
}

const domainLabels = storeFqdn.split('.')
const validStoreDomainLabel = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i
const validDomain = domainLabels.every((label) => {
return label.length > 0 && label.length <= 63 && validStoreDomainLabel.test(label)
})

if (!validDomain) {
throw new AbortError(`Invalid store value: ${store}`, invalidStoreFqdnTryMessage)
}
}

/**
* Convert a store FQDN to the admin URL pattern for local development.
* In local mode, transforms \{store\}.my.shop.dev to admin.shop.dev/store/\{store\}.
Expand Down
Loading