From 86044915a74a13cfbe92d19b59d3dc501635a3f7 Mon Sep 17 00:00:00 2001 From: Mbarak Bujra Date: Wed, 24 Jun 2026 14:00:41 -0400 Subject: [PATCH] Parse store input as URL before normalizing Parse supported store inputs through URL.hostname and reject unsupported URL delimiters before normalizing Shopify store FQDNs. Co-authored-by: Pi AI Assisted-By: devx/1c1546df-0edf-4c5c-930a-da142ea5a7ef --- .changeset/reject-store-domain-delimiters.md | 5 ++ .../src/public/node/context/fqdn.test.ts | 27 ++++++++ .../cli-kit/src/public/node/context/fqdn.ts | 66 +++++++++++++++++-- 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 .changeset/reject-store-domain-delimiters.md diff --git a/.changeset/reject-store-domain-delimiters.md b/.changeset/reject-store-domain-delimiters.md new file mode 100644 index 00000000000..36c7efc3df0 --- /dev/null +++ b/.changeset/reject-store-domain-delimiters.md @@ -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. diff --git a/packages/cli-kit/src/public/node/context/fqdn.test.ts b/packages/cli-kit/src/public/node/context/fqdn.test.ts index 77969053eaf..53c27078c2f 100644 --- a/packages/cli-kit/src/public/node/context/fqdn.test.ts +++ b/packages/cli-kit/src/public/node/context/fqdn.test.ts @@ -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') @@ -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') + }) }) diff --git a/packages/cli-kit/src/public/node/context/fqdn.ts b/packages/cli-kit/src/public/node/context/fqdn.ts index 4bbe1592cf4..a1b4c5318db 100644 --- a/packages/cli-kit/src/public/node/context/fqdn.ts +++ b/packages/cli-kit/src/public/node/context/fqdn.ts @@ -126,10 +126,10 @@ export async function identityFqdn(): Promise { * @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': @@ -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\}.