From 5d5d91c013b20ce15dac2724df6362ae75bfabfa Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Fri, 23 Oct 2026 16:35:27 -0500 Subject: [PATCH 1/5] Let theme app extensions declare the network origins they contact Theme app extensions can now list the origins they contact in shopify.extension.toml: [extensions.capabilities.network_access] allowed_sources = ["https://api.example.com", "https://*.example-cdn.com"] Each entry must be an HTTPS origin with no path, query or fragment. Entries are normalized and deduplicated, and a list can have up to 20 origins. The theme deploy config includes capabilities only when the table is declared, so existing payloads don't change. UI extensions keep the boolean network_access. --- ...xtension-network-access-allowed-sources.md | 5 + .../app/src/cli/models/app/loader.test.ts | 25 +++ .../app/src/cli/models/extensions/schemas.ts | 7 +- .../extensions/specifications/theme.test.ts | 197 ++++++++++++++++++ .../models/extensions/specifications/theme.ts | 91 +++++++- .../src/cli/services/dev/extension/payload.ts | 2 +- 6 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 .changeset/theme-extension-network-access-allowed-sources.md create mode 100644 packages/app/src/cli/models/extensions/specifications/theme.test.ts diff --git a/.changeset/theme-extension-network-access-allowed-sources.md b/.changeset/theme-extension-network-access-allowed-sources.md new file mode 100644 index 00000000000..123061e9b23 --- /dev/null +++ b/.changeset/theme-extension-network-access-allowed-sources.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Let theme app extensions declare the network origins they contact with `[extensions.capabilities.network_access] allowed_sources` in `shopify.extension.toml`. diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 1f97c353388..dbe092d6d3a 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -488,6 +488,31 @@ describe('load', () => { expect(app.errors.isEmpty()).toBe(true) }) + test('loads network_access allowed_sources for theme extensions', async () => { + // Given + await writeConfig(appConfiguration) + const blockConfiguration = ` + name = "my_extension" + type = "theme" + + [capabilities.network_access] + allowed_sources = ["https://API.example.com/", "https://*.example-cdn.com"] + ` + await writeBlockConfig({blockConfiguration, name: 'my-extension'}) + await writeFile(joinPath(blockPath('my-extension'), 'index.js'), '') + + // When + const app = await loadTestingApp() + + // Then + expect(app.errors.isEmpty()).toBe(true) + const extension = getRealExtensions(app)[0]! + await expect(extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration})).resolves.toEqual({ + theme_extension: {files: {}}, + capabilities: {network_access: {allowed_sources: ['https://api.example.com', 'https://*.example-cdn.com']}}, + }) + }) + test('reports error for duplicated handles when ignoreUnknownExtensions is true', async () => { // Given await writeConfig(appConfiguration) diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 4c32cf3eb7c..509228f4570 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -21,8 +21,11 @@ const IframeCapabilitySchema = zod.object({ sources: zod.array(zod.string()).optional(), }) -const CapabilitiesSchema = zod.object({ - network_access: zod.boolean().optional(), +export const CapabilitiesSchema = zod.object({ + // Theme app extensions declare `network_access` as a table that lists the origins they contact (see the theme + // specification). Every other extension declares a boolean, which is all this shared schema accepts. The wider + // type lets the theme specification reuse `BaseConfigType`. + network_access: zod.boolean().optional() as ZodSchemaType, block_progress: zod.boolean().optional(), api_access: zod.boolean().optional(), collect_buyer_consent: CollectBuyerConsentCapabilitySchema.optional(), diff --git a/packages/app/src/cli/models/extensions/specifications/theme.test.ts b/packages/app/src/cli/models/extensions/specifications/theme.test.ts new file mode 100644 index 00000000000..d38c026b7d3 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -0,0 +1,197 @@ +import themeSpec, {MAX_ALLOWED_SOURCES, normalizeAllowedSource} from './theme.js' +import {UIExtensionSchema} from './ui_extension.js' +import {describe, expect, test} from 'vitest' + +function parse(capabilities?: object) { + return themeSpec.parseConfigurationObject({ + name: 'theme extension', + type: 'theme', + ...(capabilities === undefined ? {} : {capabilities}), + }) +} + +function parseSources(allowedSources: unknown) { + return parse({network_access: {allowed_sources: allowedSources}}) +} + +function errorMessages(result: ReturnType) { + return result.state === 'error' ? result.errors.map((error) => error.message) : [] +} + +describe('theme', () => { + describe('capabilities.network_access.allowed_sources', () => { + test('accepts HTTPS origins and normalizes them', () => { + const result = parseSources([ + 'https://api.example.com', + ' HTTPS://CDN.Example.com/ ', + 'https://*.example-cdn.com', + 'https://api.example.com:443', + 'https://api.example.com:8443', + ]) + + expect(result.state).toBe('ok') + expect(result.data?.capabilities?.network_access).toEqual({ + allowed_sources: [ + 'https://api.example.com', + 'https://cdn.example.com', + 'https://*.example-cdn.com', + 'https://api.example.com:8443', + ], + }) + }) + + test('accepts an empty list', () => { + const result = parseSources([]) + + expect(result.state).toBe('ok') + expect(result.data?.capabilities?.network_access).toEqual({allowed_sources: []}) + }) + + test('leaves network_access undefined when it is omitted', () => { + expect(parse().data?.capabilities).toBeUndefined() + expect(parse({}).data?.capabilities?.network_access).toBeUndefined() + }) + + test('ignores a boolean network_access, as before', () => { + const result = parse({network_access: true}) + + expect(result.state).toBe('ok') + expect(result.data?.capabilities?.network_access).toBeUndefined() + }) + + test('requires allowed_sources when the network_access table is declared', () => { + const result = parse({network_access: {}}) + + expect(result.state).toBe('error') + expect(errorMessages(result)).toEqual([ + 'allowed_sources is required. Use an empty list if your extension contacts no other origins.', + ]) + }) + + test('rejects a value that is not a list', () => { + const result = parseSources('https://api.example.com') + + expect(result.state).toBe('error') + expect(errorMessages(result)).toEqual([ + 'allowed_sources must be a list of HTTPS origins, like ["https://api.example.com"]', + ]) + }) + + test('rejects a network_access value that is neither a table nor a boolean', () => { + const result = parse({network_access: 'https://api.example.com'}) + + expect(result.state).toBe('error') + expect(errorMessages(result)).toEqual([ + 'network_access must be a table with an allowed_sources list for theme app extensions', + ]) + }) + + test.each([ + ['an http origin', 'http://api.example.com'], + ['a missing scheme', 'api.example.com'], + ['a path', 'https://api.example.com/v1'], + ['a query', 'https://api.example.com?a=b'], + ['a fragment', 'https://api.example.com#a'], + ['credentials', 'https://user@api.example.com'], + ['a bare wildcard', '*'], + ['a wildcard on a top-level domain', 'https://*.com'], + ['a wildcard that is not the leading label', 'https://api.*.example.com'], + ['a single-label host', 'https://localhost'], + ['an IP address', 'https://127.0.0.1'], + ['an invalid label', 'https://-api.example.com'], + ['port 0', 'https://api.example.com:0'], + ['a port above 65535', 'https://api.example.com:65536'], + ['a CSP keyword', "'self'"], + ['a CSP injection', 'https://api.example.com; script-src *'], + ])('rejects %s', (_description, source) => { + const result = parseSources(['https://ok.example.com', source]) + + expect(result.state).toBe('error') + expect(errorMessages(result)).toEqual([ + `Invalid allowed_sources: ${JSON.stringify( + source, + )}. Use HTTPS origins with no path, query or fragment, like "https://api.example.com" or "https://*.example-cdn.com".`, + ]) + }) + + test(`accepts up to ${MAX_ALLOWED_SOURCES} origins after removing duplicates`, () => { + const sources = Array.from({length: MAX_ALLOWED_SOURCES}, (_, index) => `https://api${index}.example.com`) + + expect(parseSources([...sources, 'HTTPS://API0.EXAMPLE.COM']).state).toBe('ok') + }) + + test(`rejects more than ${MAX_ALLOWED_SOURCES} origins`, () => { + const sources = Array.from({length: MAX_ALLOWED_SOURCES + 1}, (_, index) => `https://api${index}.example.com`) + const result = parseSources(sources) + + expect(result.state).toBe('error') + expect(errorMessages(result)).toEqual([`allowed_sources can list up to ${MAX_ALLOWED_SOURCES} origins`]) + }) + }) + + describe('normalizeAllowedSource', () => { + test('rejects a host longer than 253 characters', () => { + const host = `${'a'.repeat(60)}.`.repeat(5).concat('com') + + expect(host.length).toBeGreaterThan(253) + expect(normalizeAllowedSource(`https://${host}`)).toBeUndefined() + }) + }) + + describe('deployConfig', () => { + test('sends only the theme extension files when network_access is omitted', async () => { + const config = parse().data! + + await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({theme_extension: {files: {}}}) + }) + + test('sends only the theme extension files when network_access is a boolean', async () => { + const config = parse({network_access: true, block_progress: true}).data! + + await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({theme_extension: {files: {}}}) + }) + + test('sends the normalized allowed sources when they are declared', async () => { + const config = parseSources(['https://API.example.com/', 'https://*.example-cdn.com']).data! + + await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({ + theme_extension: {files: {}}, + capabilities: {network_access: {allowed_sources: ['https://api.example.com', 'https://*.example-cdn.com']}}, + }) + }) + + test('sends an empty list when allowed_sources is empty', async () => { + const config = parseSources([]).data! + + await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({ + theme_extension: {files: {}}, + capabilities: {network_access: {allowed_sources: []}}, + }) + }) + }) + + describe('UI extensions', () => { + test('still accept a boolean network_access', () => { + const result = UIExtensionSchema.safeParse({ + name: 'ui extension', + type: 'ui_extension', + extension_points: [], + capabilities: {network_access: true}, + }) + + expect(result.success).toBe(true) + expect(result.data?.capabilities?.network_access).toBe(true) + }) + + test('reject a network_access table', () => { + const result = UIExtensionSchema.safeParse({ + name: 'ui extension', + type: 'ui_extension', + extension_points: [], + capabilities: {network_access: {allowed_sources: ['https://api.example.com']}}, + }) + + expect(result.success).toBe(false) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 8e6d27d27e4..3318c064c39 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -1,15 +1,91 @@ import {createExtensionSpecification} from '../specification.js' -import {BaseSchema} from '../schemas.js' +import {BaseSchema, CapabilitiesSchema} from '../schemas.js' import {themeExtensionFiles} from '../../../utilities/extensions/theme.js' import {ExtensionInstance} from '../extension-instance.js' import {fileSize} from '@shopify/cli-kit/node/fs' import {dirname, relativePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {zod} from '@shopify/cli-kit/node/schema' + +export const MAX_ALLOWED_SOURCES = 20 +const MAX_HOST_LENGTH = 253 +const DEFAULT_HTTPS_PORT = 443 +const MAX_PORT = 65535 +const ALLOWED_SOURCE_PATTERN = /^https:\/\/(\*\.)?([a-z0-9.-]+)(?::(\d{1,5}))?$/ +const HOST_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + +/** + * Normalizes an `allowed_sources` entry to `https://[*.]host[:port]`. + * + * Entries are trimmed and lowercased, a single trailing slash is dropped, and the default `:443` port is removed. + * The host needs at least two labels and can't be an IP address. Only a leading `*.` wildcard is allowed. + * + * @param source - The entry as written in `shopify.extension.toml`. + * @returns The normalized origin, or `undefined` if the entry isn't a valid HTTPS origin. + */ +export function normalizeAllowedSource(source: string): string | undefined { + const value = source.trim().toLowerCase().replace(/\/$/, '') + const match = ALLOWED_SOURCE_PATTERN.exec(value) + if (!match) return undefined + + const [, wildcard = '', host = '', port] = match + if (host.length > MAX_HOST_LENGTH) return undefined + + const labels = host.split('.') + if (labels.length < 2 || !labels.every((label) => HOST_LABEL_PATTERN.test(label))) return undefined + // A numeric last label means an IP address (or a TLD that doesn't exist). + if (/^\d+$/.test(labels[labels.length - 1]!)) return undefined + + let portSuffix = '' + if (port !== undefined) { + const portNumber = Number(port) + if (portNumber < 1 || portNumber > MAX_PORT) return undefined + if (portNumber !== DEFAULT_HTTPS_PORT) portSuffix = `:${portNumber}` + } + + return `https://${wildcard}${host}${portSuffix}` +} + +const AllowedSourcesSchema = zod + .array(zod.string(), { + required_error: 'allowed_sources is required. Use an empty list if your extension contacts no other origins.', + invalid_type_error: 'allowed_sources must be a list of HTTPS origins, like ["https://api.example.com"]', + }) + .superRefine((sources, ctx) => { + const invalid = sources.filter((source) => normalizeAllowedSource(source) === undefined) + if (invalid.length > 0) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Invalid allowed_sources: ${invalid.map((source) => JSON.stringify(source)).join(', ')}. Use HTTPS origins with no path, query or fragment, like "https://api.example.com" or "https://*.example-cdn.com".`, + }) + } + }) + .transform((sources) => [...new Set(sources.map((source) => normalizeAllowedSource(source)!))]) + .refine((sources) => sources.length <= MAX_ALLOWED_SOURCES, { + message: `allowed_sources can list up to ${MAX_ALLOWED_SOURCES} origins`, + }) + +const ThemeNetworkAccessSchema = zod.object( + {allowed_sources: AllowedSourcesSchema}, + {invalid_type_error: 'network_access must be a table with an allowed_sources list for theme app extensions'}, +) + +const ThemeCapabilitiesSchema = CapabilitiesSchema.extend({ + // A boolean `network_access` (used by UI extensions) never had an effect on theme app extensions. Keep ignoring it. + network_access: zod.preprocess( + (value) => (typeof value === 'boolean' ? undefined : value), + ThemeNetworkAccessSchema.optional(), + ), +}) + +export const ThemeSchema = BaseSchema.extend({ + capabilities: ThemeCapabilitiesSchema.optional(), +}) const themeSpec = createExtensionSpecification({ identifier: 'theme', - schema: BaseSchema, + schema: ThemeSchema, partnersWebIdentifier: 'theme_app_extension', graphQLType: 'theme_app_extension', clientSteps: [ @@ -24,8 +100,15 @@ const themeSpec = createExtensionSpecification({ appModuleFeatures: (_) => { return ['theme'] }, - deployConfig: async () => { - return {theme_extension: {files: {}}} + deployConfig: async (config) => { + const networkAccess = config.capabilities?.network_access + // Only send capabilities when they're declared, so existing payloads stay the same. + if (typeof networkAccess !== 'object') return {theme_extension: {files: {}}} + + return { + theme_extension: {files: {}}, + capabilities: {network_access: {allowed_sources: networkAccess.allowed_sources}}, + } }, preDeployValidation: async (extension) => { return validateThemeExtension(extension) diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index aed224c70a1..8afeeecbd09 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -98,7 +98,7 @@ export async function getUIExtensionPayload( }, capabilities: { blockProgress: extension.configuration.capabilities?.block_progress ?? false, - networkAccess: extension.configuration.capabilities?.network_access ?? false, + networkAccess: extension.configuration.capabilities?.network_access === true, apiAccess: extension.configuration.capabilities?.api_access ?? false, collectBuyerConsent: { smsMarketing: extension.configuration.capabilities?.collect_buyer_consent?.sms_marketing ?? false, From 15ec5d36a91b7be9dcea1534e4aca24f25a9f3b3 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Fri, 23 Oct 2026 16:42:35 -0500 Subject: [PATCH 2/5] Match the server's example hosts and cover more invalid origins Use a wildcard example under example.com in the error hint, and test empty labels, trailing dots, newlines and single-label wildcards. --- packages/app/src/cli/models/app/loader.test.ts | 4 ++-- .../models/extensions/specifications/theme.test.ts | 14 +++++++++----- .../cli/models/extensions/specifications/theme.ts | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index dbe092d6d3a..44471a40578 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -496,7 +496,7 @@ describe('load', () => { type = "theme" [capabilities.network_access] - allowed_sources = ["https://API.example.com/", "https://*.example-cdn.com"] + allowed_sources = ["https://API.example.com/", "https://*.cdn.example.com"] ` await writeBlockConfig({blockConfiguration, name: 'my-extension'}) await writeFile(joinPath(blockPath('my-extension'), 'index.js'), '') @@ -509,7 +509,7 @@ describe('load', () => { const extension = getRealExtensions(app)[0]! await expect(extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration})).resolves.toEqual({ theme_extension: {files: {}}, - capabilities: {network_access: {allowed_sources: ['https://api.example.com', 'https://*.example-cdn.com']}}, + capabilities: {network_access: {allowed_sources: ['https://api.example.com', 'https://*.cdn.example.com']}}, }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.test.ts b/packages/app/src/cli/models/extensions/specifications/theme.test.ts index d38c026b7d3..5023a10e1fe 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -24,7 +24,7 @@ describe('theme', () => { const result = parseSources([ 'https://api.example.com', ' HTTPS://CDN.Example.com/ ', - 'https://*.example-cdn.com', + 'https://*.cdn.example.com', 'https://api.example.com:443', 'https://api.example.com:8443', ]) @@ -34,7 +34,7 @@ describe('theme', () => { allowed_sources: [ 'https://api.example.com', 'https://cdn.example.com', - 'https://*.example-cdn.com', + 'https://*.cdn.example.com', 'https://api.example.com:8443', ], }) @@ -95,10 +95,14 @@ describe('theme', () => { ['credentials', 'https://user@api.example.com'], ['a bare wildcard', '*'], ['a wildcard on a top-level domain', 'https://*.com'], + ['a wildcard on a single label', 'https://*.example'], ['a wildcard that is not the leading label', 'https://api.*.example.com'], ['a single-label host', 'https://localhost'], ['an IP address', 'https://127.0.0.1'], ['an invalid label', 'https://-api.example.com'], + ['an empty label', 'https://api..example.com'], + ['a trailing dot', 'https://api.example.com./'], + ['a newline', 'https://api.example.com\nhttps://other.example.com'], ['port 0', 'https://api.example.com:0'], ['a port above 65535', 'https://api.example.com:65536'], ['a CSP keyword', "'self'"], @@ -110,7 +114,7 @@ describe('theme', () => { expect(errorMessages(result)).toEqual([ `Invalid allowed_sources: ${JSON.stringify( source, - )}. Use HTTPS origins with no path, query or fragment, like "https://api.example.com" or "https://*.example-cdn.com".`, + )}. Use HTTPS origins with no path, query or fragment, like "https://api.example.com" or "https://*.cdn.example.com".`, ]) }) @@ -152,11 +156,11 @@ describe('theme', () => { }) test('sends the normalized allowed sources when they are declared', async () => { - const config = parseSources(['https://API.example.com/', 'https://*.example-cdn.com']).data! + const config = parseSources(['https://API.example.com/', 'https://*.cdn.example.com']).data! await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({ theme_extension: {files: {}}, - capabilities: {network_access: {allowed_sources: ['https://api.example.com', 'https://*.example-cdn.com']}}, + capabilities: {network_access: {allowed_sources: ['https://api.example.com', 'https://*.cdn.example.com']}}, }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 3318c064c39..c25618f0c0d 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -57,7 +57,7 @@ const AllowedSourcesSchema = zod if (invalid.length > 0) { ctx.addIssue({ code: zod.ZodIssueCode.custom, - message: `Invalid allowed_sources: ${invalid.map((source) => JSON.stringify(source)).join(', ')}. Use HTTPS origins with no path, query or fragment, like "https://api.example.com" or "https://*.example-cdn.com".`, + message: `Invalid allowed_sources: ${invalid.map((source) => JSON.stringify(source)).join(', ')}. Use HTTPS origins with no path, query or fragment, like "https://api.example.com" or "https://*.cdn.example.com".`, }) } }) From a337cb92258dfa955c8ad9f3e3fb03c26afdcbc0 Mon Sep 17 00:00:00 2001 From: Josh Larson Date: Fri, 23 Oct 2026 16:45:55 -0500 Subject: [PATCH 3/5] Don't export the theme schema Knip flags it as unused outside the module. --- packages/app/src/cli/models/extensions/specifications/theme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index c25618f0c0d..37a02c2a153 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -79,7 +79,7 @@ const ThemeCapabilitiesSchema = CapabilitiesSchema.extend({ ), }) -export const ThemeSchema = BaseSchema.extend({ +const ThemeSchema = BaseSchema.extend({ capabilities: ThemeCapabilitiesSchema.optional(), }) From 652c7903c789a4b47f5ee606d627cb4aef2253da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Mon, 26 Oct 2026 12:29:55 +0100 Subject: [PATCH 4/5] Reject hexadecimal IP addresses in theme network sources --- .../src/cli/models/extensions/specifications/theme.test.ts | 7 +++++++ .../app/src/cli/models/extensions/specifications/theme.ts | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.test.ts b/packages/app/src/cli/models/extensions/specifications/theme.test.ts index 5023a10e1fe..2c1075ddf38 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -99,6 +99,13 @@ describe('theme', () => { ['a wildcard that is not the leading label', 'https://api.*.example.com'], ['a single-label host', 'https://localhost'], ['an IP address', 'https://127.0.0.1'], + ['an IP address with a hexadecimal final label', 'https://127.0.0x1'], + ['an IP address in hexadecimal notation', 'https://0x7f.0x1'], + ['a link-local IP address in hexadecimal notation', 'https://0xa9.0xfe.0xa9.0xfe'], + ['a wildcard on an IP address', 'https://*.127.0.0.1'], + ['a wildcard on an IP address in hexadecimal notation', 'https://*.0x7f.0x1'], + ['a host that cannot be parsed as a URL', 'https://example.0xabc'], + ['a wildcard on a host that cannot be parsed as a URL', 'https://*.example.0xabc'], ['an invalid label', 'https://-api.example.com'], ['an empty label', 'https://api..example.com'], ['a trailing dot', 'https://api.example.com./'], diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 37a02c2a153..35b6a378a3a 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -7,6 +7,8 @@ import {dirname, relativePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {zod} from '@shopify/cli-kit/node/schema' +import {safeParseURL} from '@shopify/cli-kit/common/url' +import {isIP} from 'node:net' export const MAX_ALLOWED_SOURCES = 20 const MAX_HOST_LENGTH = 253 @@ -34,8 +36,8 @@ export function normalizeAllowedSource(source: string): string | undefined { const labels = host.split('.') if (labels.length < 2 || !labels.every((label) => HOST_LABEL_PATTERN.test(label))) return undefined - // A numeric last label means an IP address (or a TLD that doesn't exist). - if (/^\d+$/.test(labels[labels.length - 1]!)) return undefined + const parsedOrigin = safeParseURL(`https://${host}`) + if (!parsedOrigin || isIP(parsedOrigin.hostname) !== 0) return undefined let portSuffix = '' if (port !== undefined) { From a4490b62e92d01589aef296511d47fd5272d0902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Mon, 26 Oct 2026 12:29:55 +0100 Subject: [PATCH 5/5] Separate theme capabilities from the shared schema --- .../models/extensions/extension-instance.ts | 4 +-- .../app/src/cli/models/extensions/schemas.ts | 11 ++++---- .../cli/models/extensions/specification.ts | 19 +++++++------ .../extensions/specifications/theme.test.ts | 27 ++++++++++++------- .../models/extensions/specifications/theme.ts | 21 ++++++--------- .../services/build/steps/bundle-theme-step.ts | 2 +- .../services/deploy/theme-extension-config.ts | 2 +- .../src/cli/services/dev/extension/payload.ts | 2 +- .../app/src/cli/services/extensions/bundle.ts | 2 +- .../cli/utilities/extensions/theme.test.ts | 4 ++- .../app/src/cli/utilities/extensions/theme.ts | 7 +++-- 11 files changed, 54 insertions(+), 47 deletions(-) diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 54a14f89095..412f80975dc 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,4 +1,4 @@ -import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' +import {BaseConfigType, BaseExtensionConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' import {FunctionConfigType} from './specifications/function.js' import { DevSessionUpdateContext, @@ -58,7 +58,7 @@ export const DEFAULT_DEV_SESSION_UPDATE_MESSAGE = 'Configuration accepted' * * This class holds the public interface to interact with extensions */ -export class ExtensionInstance { +export class ExtensionInstance { entrySourceFilePath: string devUUID: string localIdentifier: string diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 509228f4570..f9c59cf5892 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -21,11 +21,8 @@ const IframeCapabilitySchema = zod.object({ sources: zod.array(zod.string()).optional(), }) -export const CapabilitiesSchema = zod.object({ - // Theme app extensions declare `network_access` as a table that lists the origins they contact (see the theme - // specification). Every other extension declares a boolean, which is all this shared schema accepts. The wider - // type lets the theme specification reuse `BaseConfigType`. - network_access: zod.boolean().optional() as ZodSchemaType, +const CapabilitiesSchema = zod.object({ + network_access: zod.boolean().optional(), block_progress: zod.boolean().optional(), api_access: zod.boolean().optional(), collect_buyer_consent: CollectBuyerConsentCapabilitySchema.optional(), @@ -157,5 +154,7 @@ export const UnifiedSchema = zod.object({ export type NewExtensionPointSchemaType = zod.infer -// Base config type that all config schemas must extend. export type BaseConfigType = zod.infer + +// Base config type that all config schemas must extend. +export type BaseExtensionConfigType = Omit diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index f6f98ff73e6..858f40030a7 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -1,4 +1,4 @@ -import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' +import {ZodSchemaType, BaseConfigType, BaseExtensionConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' import {patchAppRelativeUrls} from './specifications/validation/app_relative_urls.js' import {blocks} from '../../constants.js' @@ -81,7 +81,7 @@ export interface DevSessionUpdateContext { /** * Extension specification with all the needed properties and methods to load an extension. */ -export interface ExtensionSpecification { +export interface ExtensionSpecification { identifier: string externalIdentifier: string externalName: string @@ -179,7 +179,7 @@ export interface DevSessionWatchConfig { /** * Extension specification, explicitly marked as having taken remote configuration values into account. */ -export type RemoteAwareExtensionSpecification = +export type RemoteAwareExtensionSpecification = ExtensionSpecification & { loadedRemoteSpecs: true } @@ -202,7 +202,7 @@ type ForbiddenFields = /** * Partial ExtensionSpec type used when creating a new ExtensionSpec, the only mandatory field is the identifier */ -interface CreateExtensionSpecType extends Partial< +interface CreateExtensionSpecType extends Partial< Omit, ForbiddenFields> > { identifier: string @@ -229,9 +229,10 @@ interface CreateExtensionSpecType boolean // function to determine if the extension has a given extension point target * ``` */ -export function createExtensionSpecification( +export function createExtensionSpecification( spec: CreateExtensionSpecType, ): ExtensionSpecification { + const baseSchema: ZodSchemaType = BaseSchema const defaults = { // these two fields are going to be overridden by the extension specification API response, // but we need them to have a default value for tests @@ -240,7 +241,7 @@ export function createExtensionSpecification, + schema: baseSchema as ZodSchemaType, registrationLimit: blocks.extensions.defaultRegistrationLimit, transform: spec.transformLocalToRemote, reverseTransform: spec.transformRemoteToLocal, @@ -292,7 +293,9 @@ export function createExtensionSpecification(spec: { +export function createConfigExtensionSpecification< + TConfiguration extends BaseExtensionConfigType = BaseConfigType, +>(spec: { identifier: string schema: ZodSchemaType clientSteps?: ClientSteps @@ -325,7 +328,7 @@ export function createConfigExtensionSpecification( +export function createContractBasedModuleSpecification( spec: Pick< CreateExtensionSpecType, | 'identifier' diff --git a/packages/app/src/cli/models/extensions/specifications/theme.test.ts b/packages/app/src/cli/models/extensions/specifications/theme.test.ts index 2c1075ddf38..898949bdc33 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -52,11 +52,20 @@ describe('theme', () => { expect(parse({}).data?.capabilities?.network_access).toBeUndefined() }) - test('ignores a boolean network_access, as before', () => { - const result = parse({network_access: true}) + test.each([true, false])('rejects a boolean network_access of %s', (networkAccess) => { + const result = parse({network_access: networkAccess}) + + expect(result.state).toBe('error') + expect(errorMessages(result)).toEqual([ + 'network_access must be a table with an allowed_sources list for theme app extensions', + ]) + }) + + test('ignores capabilities that do not apply to theme extensions', () => { + const result = parse({network_access: {allowed_sources: []}, api_access: true, block_progress: true}) expect(result.state).toBe('ok') - expect(result.data?.capabilities?.network_access).toBeUndefined() + expect(result.data?.capabilities).toEqual({network_access: {allowed_sources: []}}) }) test('requires allowed_sources when the network_access table is declared', () => { @@ -77,7 +86,7 @@ describe('theme', () => { ]) }) - test('rejects a network_access value that is neither a table nor a boolean', () => { + test('rejects a network_access value that is not a table', () => { const result = parse({network_access: 'https://api.example.com'}) expect(result.state).toBe('error') @@ -156,8 +165,8 @@ describe('theme', () => { await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({theme_extension: {files: {}}}) }) - test('sends only the theme extension files when network_access is a boolean', async () => { - const config = parse({network_access: true, block_progress: true}).data! + test('sends only the theme extension files when other capabilities are declared', async () => { + const config = parse({api_access: true, block_progress: true}).data! await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({theme_extension: {files: {}}}) }) @@ -182,16 +191,16 @@ describe('theme', () => { }) describe('UI extensions', () => { - test('still accept a boolean network_access', () => { + test.each([true, false])('still accept a boolean network_access of %s', (networkAccess) => { const result = UIExtensionSchema.safeParse({ name: 'ui extension', type: 'ui_extension', extension_points: [], - capabilities: {network_access: true}, + capabilities: {network_access: networkAccess}, }) expect(result.success).toBe(true) - expect(result.data?.capabilities?.network_access).toBe(true) + expect(result.data?.capabilities?.network_access).toBe(networkAccess) }) test('reject a network_access table', () => { diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 35b6a378a3a..ccb61f86080 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -1,7 +1,6 @@ import {createExtensionSpecification} from '../specification.js' -import {BaseSchema, CapabilitiesSchema} from '../schemas.js' +import {BaseSchema} from '../schemas.js' import {themeExtensionFiles} from '../../../utilities/extensions/theme.js' -import {ExtensionInstance} from '../extension-instance.js' import {fileSize} from '@shopify/cli-kit/node/fs' import {dirname, relativePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' @@ -73,12 +72,8 @@ const ThemeNetworkAccessSchema = zod.object( {invalid_type_error: 'network_access must be a table with an allowed_sources list for theme app extensions'}, ) -const ThemeCapabilitiesSchema = CapabilitiesSchema.extend({ - // A boolean `network_access` (used by UI extensions) never had an effect on theme app extensions. Keep ignoring it. - network_access: zod.preprocess( - (value) => (typeof value === 'boolean' ? undefined : value), - ThemeNetworkAccessSchema.optional(), - ), +const ThemeCapabilitiesSchema = zod.object({ + network_access: ThemeNetworkAccessSchema.optional(), }) const ThemeSchema = BaseSchema.extend({ @@ -105,7 +100,7 @@ const themeSpec = createExtensionSpecification({ deployConfig: async (config) => { const networkAccess = config.capabilities?.network_access // Only send capabilities when they're declared, so existing payloads stay the same. - if (typeof networkAccess !== 'object') return {theme_extension: {files: {}}} + if (!networkAccess) return {theme_extension: {files: {}}} return { theme_extension: {files: {}}, @@ -113,7 +108,7 @@ const themeSpec = createExtensionSpecification({ } }, preDeployValidation: async (extension) => { - return validateThemeExtension(extension) + return validateThemeExtension(extension.directory) }, }) @@ -157,13 +152,13 @@ const SUPPORTED_EXTS: {[dirname: string]: FilenameValidation} = { } const SUPPORTED_BUCKETS = Object.keys(SUPPORTED_EXTS) -async function validateThemeExtension(extension: ExtensionInstance): Promise { - const themeFiles = await themeExtensionFiles(extension) +async function validateThemeExtension(extensionDirectory: string): Promise { + const themeFiles = await themeExtensionFiles(extensionDirectory) const liquidBytes: number[] = [] const extensionBytes: number[] = [] await Promise.all( themeFiles.map(async (filepath) => { - const relativePathName = relativePath(extension.directory, filepath) + const relativePathName = relativePath(extensionDirectory, filepath) const directoryName = dirname(relativePathName) validateFile(relativePathName, directoryName) const filesize = await fileSize(filepath) diff --git a/packages/app/src/cli/services/build/steps/bundle-theme-step.ts b/packages/app/src/cli/services/build/steps/bundle-theme-step.ts index f6fb19d5737..1aa83b85bed 100644 --- a/packages/app/src/cli/services/build/steps/bundle-theme-step.ts +++ b/packages/app/src/cli/services/build/steps/bundle-theme-step.ts @@ -15,7 +15,7 @@ export async function executeBundleThemeStep( ): Promise<{filesCopied: number}> { const {extension, options} = context options.stdout.write(`Bundling theme extension ${extension.localIdentifier}...`) - const files = await themeExtensionFiles(extension) + const files = await themeExtensionFiles(extension.directory) await Promise.all( files.map(async (filepath) => { diff --git a/packages/app/src/cli/services/deploy/theme-extension-config.ts b/packages/app/src/cli/services/deploy/theme-extension-config.ts index 005ba876d46..0ab9d603601 100644 --- a/packages/app/src/cli/services/deploy/theme-extension-config.ts +++ b/packages/app/src/cli/services/deploy/theme-extension-config.ts @@ -11,7 +11,7 @@ interface ThemeExtensionConfig { export async function themeExtensionConfig(themeExtension: ExtensionInstance): Promise { const files: {[key: string]: string} = {} - const themeFiles = await themeExtensionFiles(themeExtension) + const themeFiles = await themeExtensionFiles(themeExtension.directory) await Promise.all( themeFiles.map(async (filepath) => { const relativePathName = relativePath(themeExtension.directory, filepath) diff --git a/packages/app/src/cli/services/dev/extension/payload.ts b/packages/app/src/cli/services/dev/extension/payload.ts index 8afeeecbd09..aed224c70a1 100644 --- a/packages/app/src/cli/services/dev/extension/payload.ts +++ b/packages/app/src/cli/services/dev/extension/payload.ts @@ -98,7 +98,7 @@ export async function getUIExtensionPayload( }, capabilities: { blockProgress: extension.configuration.capabilities?.block_progress ?? false, - networkAccess: extension.configuration.capabilities?.network_access === true, + networkAccess: extension.configuration.capabilities?.network_access ?? false, apiAccess: extension.configuration.capabilities?.api_access ?? false, collectBuyerConsent: { smsMarketing: extension.configuration.capabilities?.collect_buyer_consent?.sms_marketing ?? false, diff --git a/packages/app/src/cli/services/extensions/bundle.ts b/packages/app/src/cli/services/extensions/bundle.ts index 8de5eebd4ea..ef48dec301a 100644 --- a/packages/app/src/cli/services/extensions/bundle.ts +++ b/packages/app/src/cli/services/extensions/bundle.ts @@ -78,7 +78,7 @@ export async function bundleThemeExtension( options: ExtensionBuildOptions, ): Promise { options.stdout.write(`Bundling theme extension ${extension.localIdentifier}...`) - const files = await themeExtensionFiles(extension) + const files = await themeExtensionFiles(extension.directory) await Promise.all( files.map(function (filepath) { diff --git a/packages/app/src/cli/utilities/extensions/theme.test.ts b/packages/app/src/cli/utilities/extensions/theme.test.ts index f83969b3f8f..0aedc32f717 100644 --- a/packages/app/src/cli/utilities/extensions/theme.test.ts +++ b/packages/app/src/cli/utilities/extensions/theme.test.ts @@ -34,7 +34,9 @@ describe('themeExtensionConfig', () => { ) // Then - await expect(themeExtensionFiles(themeExtension)).resolves.toStrictEqual([joinPath(tmpDir, 'blocks/test.liquid')]) + await expect(themeExtensionFiles(themeExtension.directory)).resolves.toStrictEqual([ + joinPath(tmpDir, 'blocks/test.liquid'), + ]) }) }) }) diff --git a/packages/app/src/cli/utilities/extensions/theme.ts b/packages/app/src/cli/utilities/extensions/theme.ts index 82a17df9f4d..0cf3d850bad 100644 --- a/packages/app/src/cli/utilities/extensions/theme.ts +++ b/packages/app/src/cli/utilities/extensions/theme.ts @@ -1,4 +1,3 @@ -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {glob, createFileReadStream, fileExistsSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {createInterface} from 'readline' @@ -22,9 +21,9 @@ const ignoredFilePatterns = [ '*.toml', ] -export async function themeExtensionFiles(themeExtension: ExtensionInstance): Promise { +export async function themeExtensionFiles(extensionDirectory: string): Promise { const filename = '.shopifyignore' - const filepath = joinPath(themeExtension.directory, filename) + const filepath = joinPath(extensionDirectory, filename) const ignore = ignoredFilePatterns.map((pattern) => joinPath('*', pattern)) if (fileExistsSync(filepath)) { @@ -34,7 +33,7 @@ export async function themeExtensionFiles(themeExtension: ExtensionInstance): Pr return glob('*/*', { absolute: true, - cwd: themeExtension.directory, + cwd: extensionDirectory, ignore, }) }