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/flow-trigger-lifecycle-callback-relative-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Allow Flow trigger lifecycle callback `url`s to be relative to the app's `application_url`
10 changes: 10 additions & 0 deletions packages/app/src/cli/models/app/validation/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ export function validateRelativeUrl(zodType: zod.ZodString, {message = 'URL must
return zodType.refine((value) => value.startsWith('/') || isValidUrl(value, true), {message})
}

/**
* Characters that are never legal in a URL, and that would let a malformed configuration value smuggle extra content
* into a request when the URL is later interpolated.
*/
export const URL_CONTROL_CHARACTERS = /[\r\n\t]/
Comment thread
EliasJRH marked this conversation as resolved.

export function isHttpsUrl(url: string): boolean {
return isValidUrl(url, true)
}

function isValidUrl(input: string, httpsOnly: boolean) {
try {
const url = new URL(input)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
createConfigExtensionSpecification,
createExtensionSpecification,
} from './specification.js'
import {BaseSchema} from './schemas.js'
import {BaseConfigType, BaseSchema} from './schemas.js'
import {placeholderAppConfiguration} from '../app/app.test-data.js'
import {ClientSteps} from '../../services/build/client-steps.js'
import {AppSchema} from '../app/app.js'
import {describe, test, expect, beforeAll} from 'vitest'
Expand Down Expand Up @@ -95,6 +96,93 @@ describe('createContractBasedModuleSpecification', () => {
// Then
expect(got.clientSteps).toBeUndefined()
})

describe('app relative URLs', () => {
interface LifecycleCallbackConfig extends BaseConfigType {
url: string
}

const lifecycleCallbackSpec = () =>
createContractBasedModuleSpecification<LifecycleCallbackConfig>({
identifier: 'flow_trigger_lifecycle_callback',
uidStrategy: 'uuid',
experience: 'extension',
appModuleFeatures: () => [],
})

test('resolves a relative url against the application URL when deploying', async () => {
// Given
const spec = lifecycleCallbackSpec()

// When
const got = await spec.deployConfig!(
{type: 'flow_trigger_lifecycle_callback', name: 'Auction lifecycle', url: '/api/flow/lifecycle'},
'./my-extension',
'api-key',
undefined,
{
appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'},
},
)

// Then
expect(got).toEqual({name: 'Auction lifecycle', url: 'https://my-app.example.com/api/flow/lifecycle'})
})

test('leaves an absolute url untouched when deploying', async () => {
// Given
const spec = lifecycleCallbackSpec()

// When
const got = await spec.deployConfig!(
{
type: 'flow_trigger_lifecycle_callback',
name: 'Auction lifecycle',
url: 'https://my-prod-host.example.com/api/flow/lifecycle',
},
'./my-extension',
'api-key',
undefined,
{
appConfiguration: {...placeholderAppConfiguration, application_url: 'https://my-app.example.com'},
},
)

// Then
expect(got).toEqual({
name: 'Auction lifecycle',
url: 'https://my-prod-host.example.com/api/flow/lifecycle',
})
})

test('resolves a relative url against the dev tunnel URL', () => {
// Given
const spec = lifecycleCallbackSpec()
const config = {type: 'flow_trigger_lifecycle_callback', name: 'Auction lifecycle', url: '/api/flow/lifecycle'}

// When
spec.patchWithAppDevURLs!(config, {applicationUrl: 'https://my-tunnel.example.com', redirectUrlWhitelist: []})

// Then
expect(config.url).toBe('https://my-tunnel.example.com/api/flow/lifecycle')
})

test('leaves a contract module without relative URL fields untouched', async () => {
// Given
const spec = createContractBasedModuleSpecification<LifecycleCallbackConfig>({
identifier: 'test',
uidStrategy: 'uuid',
experience: 'extension',
appModuleFeatures: () => [],
})

// When
const got = await spec.deployConfig!({type: 'test', url: '/api/something'}, './my-extension', 'api-key')

// Then
expect(got).toEqual({url: '/api/something'})
})
})
})

describe('createExtensionSpecification', () => {
Expand Down
14 changes: 13 additions & 1 deletion packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
import {ExtensionInstance} from './extension-instance.js'
import {patchAppRelativeUrls} from './specifications/validation/app_relative_urls.js'
import {blocks} from '../../constants.js'
import {ClientSteps} from '../../services/build/client-steps.js'

Expand Down Expand Up @@ -312,8 +313,19 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
uidStrategy: spec.uidStrategy,
transformRemoteToLocal: spec.transformRemoteToLocal,
devSessionWatchConfig: spec.devSessionWatchConfig,
deployConfig: async (config, directory) => {
// A contract based module has no local schema, so its configuration is deployed as authored. The exception is
// the app relative URL fields declared in app_relative_urls.ts: those are resolved against the dev tunnel here,
// and against the app's application_url in deployConfig below.
patchWithAppDevURLs: (config, urls) => {
patchAppRelativeUrls(spec.identifier, config, urls.applicationUrl)
},
deployConfig: async (config, directory, _apiKey, _moduleId, context) => {
const applicationUrl = context?.appConfiguration?.application_url
const appUrl = typeof applicationUrl === 'string' ? applicationUrl : undefined

// configWithoutFirstClassFields returns a fresh object, so patching it in place cannot affect the caller.
let parsedConfig = configWithoutFirstClassFields(config)
patchAppRelativeUrls(spec.identifier, parsedConfig, appUrl)
if (spec.appModuleFeatures().includes('localization')) {
const localization = await loadLocalesConfig(directory, spec.identifier)
parsedConfig = {...parsedConfig, localization}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {resolveAppRelativeUrl} from './validation/app_relative_urls.js'
import {BaseSchemaWithHandle} from '../schemas.js'
import {createExtensionSpecification} from '../specification.js'
import {
Expand All @@ -8,9 +9,12 @@ import {
} from '../../../services/flow/validation.js'
import {serializeFields} from '../../../services/flow/serialize-fields.js'
import {FLOW_ACTION_URL_FIELDS} from '../../../services/flow/types.js'
import {loadSchemaFromPath, resolveFlowActionUrl} from '../../../services/flow/utils.js'
import {loadSchemaFromPath} from '../../../services/flow/utils.js'
import {zod} from '@shopify/cli-kit/node/schema'

/** Prefixes the error messages raised while resolving this extension's URL fields. */
const FLOW_ACTION_LABEL = 'Flow action'

const FlowActionExtensionSchema = BaseSchemaWithHandle.extend({
type: zod.literal('flow_action'),
name: zod.string(),
Expand Down Expand Up @@ -58,7 +62,7 @@ const flowActionSpecification = createExtensionSpecification({
for (const key of FLOW_ACTION_URL_FIELDS) {
const value = config[key]
if (typeof value === 'string' && value.startsWith('/')) {
config[key] = resolveFlowActionUrl(key, value, urls.applicationUrl)
config[key] = resolveAppRelativeUrl(FLOW_ACTION_LABEL, key, value, urls.applicationUrl)
}
}
},
Expand All @@ -69,16 +73,16 @@ const flowActionSpecification = createExtensionSpecification({
return {
title: config.name,
description: config.description,
url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl),
url: resolveAppRelativeUrl(FLOW_ACTION_LABEL, 'runtime_url', config.runtime_url, appUrl),
fields: serializeFields('flow_action', config.settings?.fields),
validation_url: config.validation_url
? resolveFlowActionUrl('validation_url', config.validation_url, appUrl)
? resolveAppRelativeUrl(FLOW_ACTION_LABEL, 'validation_url', config.validation_url, appUrl)
: undefined,
custom_configuration_page_url: config.config_page_url
? resolveFlowActionUrl('config_page_url', config.config_page_url, appUrl)
? resolveAppRelativeUrl(FLOW_ACTION_LABEL, 'config_page_url', config.config_page_url, appUrl)
: undefined,
custom_configuration_page_preview_url: config.config_page_preview_url
? resolveFlowActionUrl('config_page_preview_url', config.config_page_preview_url, appUrl)
? resolveAppRelativeUrl(FLOW_ACTION_LABEL, 'config_page_preview_url', config.config_page_preview_url, appUrl)
: undefined,
schema_patch: await loadSchemaFromPath(extensionPath, config.schema),
return_type_ref: config.return_type_ref,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {patchAppRelativeUrls, resolveAppRelativeUrl} from './app_relative_urls.js'
import {describe, expect, test} from 'vitest'

const LIFECYCLE_CALLBACK = 'flow_trigger_lifecycle_callback'

describe('resolveAppRelativeUrl', () => {
const resolve = (url: string, appUrl: string | undefined) => resolveAppRelativeUrl('Test module', 'url', url, appUrl)

test('returns absolute URLs unchanged', () => {
expect(resolve('https://my-prod-host.example.com/api/execute', 'https://my-app.example.com')).toBe(
'https://my-prod-host.example.com/api/execute',
)
})

test('accepts absolute HTTPS URLs regardless of scheme casing', () => {
expect(resolve('HTTPS://my-prod-host.example.com/api/execute', 'https://my-app.example.com')).toBe(
'HTTPS://my-prod-host.example.com/api/execute',
)
})

test('prepends the app URL to relative URLs', () => {
expect(resolve('/api/execute', 'https://my-app.example.com/')).toBe('https://my-app.example.com/api/execute')
})

test('throws when a relative URL cannot be resolved without an app URL', () => {
expect(() => resolve('/api/execute', undefined)).toThrow(
'Test module url is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.',
)
})

test('throws when an absolute URL is not HTTPS', () => {
expect(() => resolve('http://my-prod-host.example.com/api/execute', undefined)).toThrow(
'Test module url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})

test('throws when the URL is empty', () => {
expect(() => resolve('', 'https://my-app.example.com')).toThrow(
'Test module url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})

test('throws when a relative URL resolves against a non-HTTPS app URL', () => {
expect(() => resolve('/api/execute', 'http://my-app.example.com')).toThrow(
'Test module url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})

test('throws on a protocol relative url', () => {
expect(() => resolve('//evil.example.com/api', 'https://my-app.example.com')).toThrow(
'Test module url is invalid: a URL relative to the app URL must start with a single slash.',
)
})

test('throws on a url containing control characters', () => {
expect(() => resolve('/api\nX-Injected: 1', 'https://my-app.example.com')).toThrow(
'Test module url is invalid: a URL must not contain control characters such as newlines or tabs.',
)
})
})

describe('patchAppRelativeUrls', () => {
const patch = (config: object, appUrl: string | undefined, identifier = LIFECYCLE_CALLBACK) => {
patchAppRelativeUrls(identifier, config, appUrl)
return config
}

test('prepends the app URL to a relative url', () => {
// When
const got = patch({name: 'Auction lifecycle', url: '/api/flow/lifecycle'}, 'https://my-app.example.com')

// Then
expect(got).toEqual({name: 'Auction lifecycle', url: 'https://my-app.example.com/api/flow/lifecycle'})
})

test('removes a trailing slash from the app URL', () => {
// When
const got = patch({url: '/api/flow/lifecycle'}, 'https://my-app.example.com/')

// Then
expect(got).toEqual({url: 'https://my-app.example.com/api/flow/lifecycle'})
})

test('leaves an absolute url untouched', () => {
// When
const got = patch({url: 'https://my-prod-host.example.com/api/flow/lifecycle'}, 'https://my-app.example.com')

// Then
expect(got).toEqual({url: 'https://my-prod-host.example.com/api/flow/lifecycle'})
})

test('leaves a module with no relative URL fields untouched', () => {
// When
const got = patch({url: '/api/something'}, 'https://my-app.example.com', 'some_other_contract_module')

// Then
expect(got).toEqual({url: '/api/something'})
})

test('throws when there is no app URL to resolve against', () => {
// When/Then
expect(() => patch({url: '/api/flow/lifecycle'}, undefined)).toThrow(
'Flow trigger lifecycle callback url is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.',
)
})

test('throws when the app URL is not HTTPS', () => {
// When/Then
expect(() => patch({url: '/api/flow/lifecycle'}, 'http://my-app.example.com')).toThrow(
'Flow trigger lifecycle callback url must resolve to an HTTPS URL.',
)
})

test('throws on a protocol relative url', () => {
// When/Then
expect(() => patch({url: '//example.com/api'}, 'https://my-app.example.com')).toThrow(
'a URL relative to the app URL must start with a single slash',
)
})

test('throws on a url containing control characters', () => {
// When/Then
expect(() => patch({url: '/api/flow/lifecycle\nmalicious-header: value'}, 'https://my-app.example.com')).toThrow(
'a URL must not contain control characters',
)
})

test('resolves against the dev tunnel URL', () => {
// When
const got = patch({url: '/api/flow/lifecycle'}, 'https://my-tunnel.example.com')

// Then
expect(got).toEqual({url: 'https://my-tunnel.example.com/api/flow/lifecycle'})
})
})
Loading
Loading