Skip to content

Commit df8579f

Browse files
committed
Simplify theme capabilities and reject encoded IP origins
1 parent a337cb9 commit df8579f

7 files changed

Lines changed: 54 additions & 38 deletions

File tree

‎packages/app/src/cli/models/extensions/extension-instance.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js'
1+
import {BaseConfigType, BaseExtensionConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js'
22
import {FunctionConfigType} from './specifications/function.js'
33
import {
44
DevSessionUpdateContext,
@@ -58,7 +58,7 @@ export const DEFAULT_DEV_SESSION_UPDATE_MESSAGE = 'Configuration accepted'
5858
*
5959
* This class holds the public interface to interact with extensions
6060
*/
61-
export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfigType> {
61+
export class ExtensionInstance<TConfiguration extends BaseExtensionConfigType = BaseConfigType> {
6262
entrySourceFilePath: string
6363
devUUID: string
6464
localIdentifier: string

‎packages/app/src/cli/models/extensions/schemas.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,8 @@ const IframeCapabilitySchema = zod.object({
2121
sources: zod.array(zod.string()).optional(),
2222
})
2323

24-
export const CapabilitiesSchema = zod.object({
25-
// Theme app extensions declare `network_access` as a table that lists the origins they contact (see the theme
26-
// specification). Every other extension declares a boolean, which is all this shared schema accepts. The wider
27-
// type lets the theme specification reuse `BaseConfigType`.
28-
network_access: zod.boolean().optional() as ZodSchemaType<boolean | {allowed_sources: string[]} | undefined>,
24+
const CapabilitiesSchema = zod.object({
25+
network_access: zod.boolean().optional(),
2926
block_progress: zod.boolean().optional(),
3027
api_access: zod.boolean().optional(),
3128
collect_buyer_consent: CollectBuyerConsentCapabilitySchema.optional(),
@@ -157,5 +154,7 @@ export const UnifiedSchema = zod.object({
157154

158155
export type NewExtensionPointSchemaType = zod.infer<typeof NewExtensionPointSchema>
159156

160-
// Base config type that all config schemas must extend.
161157
export type BaseConfigType = zod.infer<typeof BaseSchema>
158+
159+
// Base config type that all config schemas must extend.
160+
export type BaseExtensionConfigType = Omit<BaseConfigType, 'capabilities'>

‎packages/app/src/cli/models/extensions/specification.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
1+
import {ZodSchemaType, BaseConfigType, BaseExtensionConfigType, BaseSchema} from './schemas.js'
22
import {ExtensionInstance} from './extension-instance.js'
33
import {patchAppRelativeUrls} from './specifications/validation/app_relative_urls.js'
44
import {blocks} from '../../constants.js'
@@ -81,7 +81,7 @@ export interface DevSessionUpdateContext {
8181
/**
8282
* Extension specification with all the needed properties and methods to load an extension.
8383
*/
84-
export interface ExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType> {
84+
export interface ExtensionSpecification<TConfiguration extends BaseExtensionConfigType = BaseConfigType> {
8585
identifier: string
8686
externalIdentifier: string
8787
externalName: string
@@ -179,7 +179,7 @@ export interface DevSessionWatchConfig {
179179
/**
180180
* Extension specification, explicitly marked as having taken remote configuration values into account.
181181
*/
182-
export type RemoteAwareExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType> =
182+
export type RemoteAwareExtensionSpecification<TConfiguration extends BaseExtensionConfigType = BaseConfigType> =
183183
ExtensionSpecification<TConfiguration> & {
184184
loadedRemoteSpecs: true
185185
}
@@ -202,7 +202,7 @@ type ForbiddenFields =
202202
/**
203203
* Partial ExtensionSpec type used when creating a new ExtensionSpec, the only mandatory field is the identifier
204204
*/
205-
interface CreateExtensionSpecType<TConfiguration extends BaseConfigType = BaseConfigType> extends Partial<
205+
interface CreateExtensionSpecType<TConfiguration extends BaseExtensionConfigType = BaseConfigType> extends Partial<
206206
Omit<ExtensionSpecification<TConfiguration>, ForbiddenFields>
207207
> {
208208
identifier: string
@@ -229,9 +229,10 @@ interface CreateExtensionSpecType<TConfiguration extends BaseConfigType = BaseCo
229229
* hasExtensionPointTarget?: (configuration: TConfiguration, target: string) => boolean // function to determine if the extension has a given extension point target
230230
* ```
231231
*/
232-
export function createExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
232+
export function createExtensionSpecification<TConfiguration extends BaseExtensionConfigType = BaseConfigType>(
233233
spec: CreateExtensionSpecType<TConfiguration>,
234234
): ExtensionSpecification<TConfiguration> {
235+
const baseSchema: ZodSchemaType<BaseExtensionConfigType> = BaseSchema
235236
const defaults = {
236237
// these two fields are going to be overridden by the extension specification API response,
237238
// but we need them to have a default value for tests
@@ -240,7 +241,7 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
240241
externalName: capitalize(spec.identifier.replace(/_/g, ' ')),
241242
surface: 'test-surface',
242243
partnersWebIdentifier: spec.identifier,
243-
schema: BaseSchema as ZodSchemaType<TConfiguration>,
244+
schema: baseSchema as ZodSchemaType<TConfiguration>,
244245
registrationLimit: blocks.extensions.defaultRegistrationLimit,
245246
transform: spec.transformLocalToRemote,
246247
reverseTransform: spec.transformRemoteToLocal,
@@ -292,7 +293,9 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
292293
* - App config extensions have default transform and reverseTransform functions
293294
294295
*/
295-
export function createConfigExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(spec: {
296+
export function createConfigExtensionSpecification<
297+
TConfiguration extends BaseExtensionConfigType = BaseConfigType,
298+
>(spec: {
296299
identifier: string
297300
schema: ZodSchemaType<TConfiguration>
298301
clientSteps?: ClientSteps
@@ -325,7 +328,7 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
325328
})
326329
}
327330

328-
export function createContractBasedModuleSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
331+
export function createContractBasedModuleSpecification<TConfiguration extends BaseExtensionConfigType = BaseConfigType>(
329332
spec: Pick<
330333
CreateExtensionSpecType<TConfiguration>,
331334
| 'identifier'

‎packages/app/src/cli/models/extensions/specifications/theme.test.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,20 @@ describe('theme', () => {
5252
expect(parse({}).data?.capabilities?.network_access).toBeUndefined()
5353
})
5454

55-
test('ignores a boolean network_access, as before', () => {
56-
const result = parse({network_access: true})
55+
test.each([true, false])('rejects a boolean network_access of %s', (networkAccess) => {
56+
const result = parse({network_access: networkAccess})
57+
58+
expect(result.state).toBe('error')
59+
expect(errorMessages(result)).toEqual([
60+
'network_access must be a table with an allowed_sources list for theme app extensions',
61+
])
62+
})
63+
64+
test('ignores capabilities that do not apply to theme extensions', () => {
65+
const result = parse({network_access: {allowed_sources: []}, api_access: true, block_progress: true})
5766

5867
expect(result.state).toBe('ok')
59-
expect(result.data?.capabilities?.network_access).toBeUndefined()
68+
expect(result.data?.capabilities).toEqual({network_access: {allowed_sources: []}})
6069
})
6170

6271
test('requires allowed_sources when the network_access table is declared', () => {
@@ -77,7 +86,7 @@ describe('theme', () => {
7786
])
7887
})
7988

80-
test('rejects a network_access value that is neither a table nor a boolean', () => {
89+
test('rejects a network_access value that is not a table', () => {
8190
const result = parse({network_access: 'https://api.example.com'})
8291

8392
expect(result.state).toBe('error')
@@ -99,6 +108,13 @@ describe('theme', () => {
99108
['a wildcard that is not the leading label', 'https://api.*.example.com'],
100109
['a single-label host', 'https://localhost'],
101110
['an IP address', 'https://127.0.0.1'],
111+
['an IP address with a hexadecimal final label', 'https://127.0.0x1'],
112+
['an IP address in hexadecimal notation', 'https://0x7f.0x1'],
113+
['a link-local IP address in hexadecimal notation', 'https://0xa9.0xfe.0xa9.0xfe'],
114+
['a wildcard on an IP address', 'https://*.127.0.0.1'],
115+
['a wildcard on an IP address in hexadecimal notation', 'https://*.0x7f.0x1'],
116+
['a host that cannot be parsed as a URL', 'https://example.0xabc'],
117+
['a wildcard on a host that cannot be parsed as a URL', 'https://*.example.0xabc'],
102118
['an invalid label', 'https://-api.example.com'],
103119
['an empty label', 'https://api..example.com'],
104120
['a trailing dot', 'https://api.example.com./'],
@@ -149,8 +165,8 @@ describe('theme', () => {
149165
await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({theme_extension: {files: {}}})
150166
})
151167

152-
test('sends only the theme extension files when network_access is a boolean', async () => {
153-
const config = parse({network_access: true, block_progress: true}).data!
168+
test('sends only the theme extension files when other capabilities are declared', async () => {
169+
const config = parse({api_access: true, block_progress: true}).data!
154170

155171
await expect(themeSpec.deployConfig?.(config, '', '', undefined)).resolves.toEqual({theme_extension: {files: {}}})
156172
})
@@ -175,16 +191,16 @@ describe('theme', () => {
175191
})
176192

177193
describe('UI extensions', () => {
178-
test('still accept a boolean network_access', () => {
194+
test.each([true, false])('still accept a boolean network_access of %s', (networkAccess) => {
179195
const result = UIExtensionSchema.safeParse({
180196
name: 'ui extension',
181197
type: 'ui_extension',
182198
extension_points: [],
183-
capabilities: {network_access: true},
199+
capabilities: {network_access: networkAccess},
184200
})
185201

186202
expect(result.success).toBe(true)
187-
expect(result.data?.capabilities?.network_access).toBe(true)
203+
expect(result.data?.capabilities?.network_access).toBe(networkAccess)
188204
})
189205

190206
test('reject a network_access table', () => {

‎packages/app/src/cli/models/extensions/specifications/theme.ts‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {createExtensionSpecification} from '../specification.js'
2-
import {BaseSchema, CapabilitiesSchema} from '../schemas.js'
2+
import {BaseSchema} from '../schemas.js'
33
import {themeExtensionFiles} from '../../../utilities/extensions/theme.js'
44
import {ExtensionInstance} from '../extension-instance.js'
55
import {fileSize} from '@shopify/cli-kit/node/fs'
66
import {dirname, relativePath} from '@shopify/cli-kit/node/path'
77
import {AbortError} from '@shopify/cli-kit/node/error'
88
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
99
import {zod} from '@shopify/cli-kit/node/schema'
10+
import {safeParseURL} from '@shopify/cli-kit/common/url'
11+
import {isIP} from 'node:net'
1012

1113
export const MAX_ALLOWED_SOURCES = 20
1214
const MAX_HOST_LENGTH = 253
@@ -34,8 +36,8 @@ export function normalizeAllowedSource(source: string): string | undefined {
3436

3537
const labels = host.split('.')
3638
if (labels.length < 2 || !labels.every((label) => HOST_LABEL_PATTERN.test(label))) return undefined
37-
// A numeric last label means an IP address (or a TLD that doesn't exist).
38-
if (/^\d+$/.test(labels[labels.length - 1]!)) return undefined
39+
const parsedOrigin = safeParseURL(`https://${host}`)
40+
if (!parsedOrigin || isIP(parsedOrigin.hostname) !== 0) return undefined
3941

4042
let portSuffix = ''
4143
if (port !== undefined) {
@@ -71,12 +73,8 @@ const ThemeNetworkAccessSchema = zod.object(
7173
{invalid_type_error: 'network_access must be a table with an allowed_sources list for theme app extensions'},
7274
)
7375

74-
const ThemeCapabilitiesSchema = CapabilitiesSchema.extend({
75-
// A boolean `network_access` (used by UI extensions) never had an effect on theme app extensions. Keep ignoring it.
76-
network_access: zod.preprocess(
77-
(value) => (typeof value === 'boolean' ? undefined : value),
78-
ThemeNetworkAccessSchema.optional(),
79-
),
76+
const ThemeCapabilitiesSchema = zod.object({
77+
network_access: ThemeNetworkAccessSchema.optional(),
8078
})
8179

8280
const ThemeSchema = BaseSchema.extend({
@@ -103,7 +101,7 @@ const themeSpec = createExtensionSpecification({
103101
deployConfig: async (config) => {
104102
const networkAccess = config.capabilities?.network_access
105103
// Only send capabilities when they're declared, so existing payloads stay the same.
106-
if (typeof networkAccess !== 'object') return {theme_extension: {files: {}}}
104+
if (!networkAccess) return {theme_extension: {files: {}}}
107105

108106
return {
109107
theme_extension: {files: {}},
@@ -155,7 +153,7 @@ const SUPPORTED_EXTS: {[dirname: string]: FilenameValidation} = {
155153
}
156154
const SUPPORTED_BUCKETS = Object.keys(SUPPORTED_EXTS)
157155

158-
async function validateThemeExtension(extension: ExtensionInstance): Promise<void> {
156+
async function validateThemeExtension(extension: Pick<ExtensionInstance, 'directory'>): Promise<void> {
159157
const themeFiles = await themeExtensionFiles(extension)
160158
const liquidBytes: number[] = []
161159
const extensionBytes: number[] = []

‎packages/app/src/cli/services/dev/extension/payload.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export async function getUIExtensionPayload(
9898
},
9999
capabilities: {
100100
blockProgress: extension.configuration.capabilities?.block_progress ?? false,
101-
networkAccess: extension.configuration.capabilities?.network_access === true,
101+
networkAccess: extension.configuration.capabilities?.network_access ?? false,
102102
apiAccess: extension.configuration.capabilities?.api_access ?? false,
103103
collectBuyerConsent: {
104104
smsMarketing: extension.configuration.capabilities?.collect_buyer_consent?.sms_marketing ?? false,

‎packages/app/src/cli/utilities/extensions/theme.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const ignoredFilePatterns = [
2222
'*.toml',
2323
]
2424

25-
export async function themeExtensionFiles(themeExtension: ExtensionInstance): Promise<string[]> {
25+
export async function themeExtensionFiles(themeExtension: Pick<ExtensionInstance, 'directory'>): Promise<string[]> {
2626
const filename = '.shopifyignore'
2727
const filepath = joinPath(themeExtension.directory, filename)
2828
const ignore = ignoredFilePatterns.map((pattern) => joinPath('*', pattern))

0 commit comments

Comments
 (0)