diff --git a/.changeset/flow-trigger-lifecycle-callback-relative-url.md b/.changeset/flow-trigger-lifecycle-callback-relative-url.md new file mode 100644 index 00000000000..009c36d0579 --- /dev/null +++ b/.changeset/flow-trigger-lifecycle-callback-relative-url.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Allow Flow trigger lifecycle callback `url`s to be relative to the app's `application_url` diff --git a/packages/app/src/cli/models/app/validation/common.ts b/packages/app/src/cli/models/app/validation/common.ts index 2850caa22ea..da24f2bb0f4 100644 --- a/packages/app/src/cli/models/app/validation/common.ts +++ b/packages/app/src/cli/models/app/validation/common.ts @@ -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]/ + +export function isHttpsUrl(url: string): boolean { + return isValidUrl(url, true) +} + function isValidUrl(input: string, httpsOnly: boolean) { try { const url = new URL(input) diff --git a/packages/app/src/cli/models/extensions/specification.integration.test.ts b/packages/app/src/cli/models/extensions/specification.integration.test.ts index f49d0712f3a..cb735acf4a5 100644 --- a/packages/app/src/cli/models/extensions/specification.integration.test.ts +++ b/packages/app/src/cli/models/extensions/specification.integration.test.ts @@ -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' @@ -95,6 +96,93 @@ describe('createContractBasedModuleSpecification', () => { // Then expect(got.clientSteps).toBeUndefined() }) + + describe('app relative URLs', () => { + interface LifecycleCallbackConfig extends BaseConfigType { + url: string + } + + const lifecycleCallbackSpec = () => + createContractBasedModuleSpecification({ + 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({ + 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', () => { diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 0dc1825a0a1..5dbb9f77b4d 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -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' @@ -312,8 +313,19 @@ export function createContractBasedModuleSpecification { + // 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} diff --git a/packages/app/src/cli/models/extensions/specifications/flow_action.ts b/packages/app/src/cli/models/extensions/specifications/flow_action.ts index 09801cd0c48..9dd3ebcbd4a 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_action.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_action.ts @@ -1,3 +1,4 @@ +import {resolveAppRelativeUrl} from './validation/app_relative_urls.js' import {BaseSchemaWithHandle} from '../schemas.js' import {createExtensionSpecification} from '../specification.js' import { @@ -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(), @@ -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) } } }, @@ -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, diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts new file mode 100644 index 00000000000..3e12988a9dc --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.test.ts @@ -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'}) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts new file mode 100644 index 00000000000..b78b3bed0ae --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_relative_urls.ts @@ -0,0 +1,78 @@ +import {prependApplicationUrl} from './url_prepender.js' +import {URL_CONTROL_CHARACTERS, isHttpsUrl} from '../../../app/validation/common.js' +import {AbortError} from '@shopify/cli-kit/node/error' + +interface RelativeUrlModule { + label: string + fields: string[] +} + +/** + * Contract based modules have no local specification, so their configuration is sent to the server exactly as it + * appears in the TOML. These fields are the exception: a relative value (a path starting with a single slash) is + * resolved against the app's URL, the same way `flow_action`'s URL fields are resolved by its own specification. + * + * To give another contract based module the same treatment, add it here. The server side contract has to accept the + * relative form as well, otherwise the configuration is rejected when it is parsed, before any of this runs. + */ +const MODULES_WITH_RELATIVE_URLS: {[identifier: string]: RelativeUrlModule} = { + flow_trigger_lifecycle_callback: {label: 'Flow trigger lifecycle callback', fields: ['url']}, +} + +/** + * Resolves a single app relative URL field against the app's URL, rejecting anything that cannot become a valid + * absolute HTTPS URL. `label` and `fieldName` only appear in the error messages, so callers can name the field the + * same way it is spelled in the TOML. + */ +export const resolveAppRelativeUrl = ( + label: string, + fieldName: string, + url: string, + appUrl: string | undefined, +): string => { + if (url.startsWith('//')) { + throw new AbortError( + `${label} ${fieldName} is invalid: a URL relative to the app URL must start with a single slash.`, + ) + } + + if (URL_CONTROL_CHARACTERS.test(url)) { + throw new AbortError( + `${label} ${fieldName} is invalid: a URL must not contain control characters such as newlines or tabs.`, + ) + } + + const resolvedUrl = prependApplicationUrl(url, appUrl) + if (resolvedUrl.startsWith('/')) { + throw new AbortError( + `${label} ${fieldName} is a relative URL, but no application_url is configured. ` + + 'Set application_url in your app configuration or use an absolute HTTPS URL.', + ) + } + + if (!isHttpsUrl(resolvedUrl)) { + throw new AbortError( + `${label} ${fieldName} must resolve to an HTTPS URL. ` + + 'Set application_url to an HTTPS URL or use an absolute HTTPS URL.', + ) + } + + return resolvedUrl +} + +/** + * Resolves in place every app relative URL field of a contract based module's configuration. Absolute URLs, and + * modules with no relative URL fields, are left untouched. + */ +export function patchAppRelativeUrls(identifier: string, config: object, appUrl: string | undefined): void { + const module = MODULES_WITH_RELATIVE_URLS[identifier] + if (!module) return + + const indexableConfig = config as {[key: string]: unknown} + for (const field of module.fields) { + const value = indexableConfig[field] + if (typeof value === 'string' && value.startsWith('/')) { + indexableConfig[field] = resolveAppRelativeUrl(module.label, field, value, appUrl) + } + } +} diff --git a/packages/app/src/cli/services/flow/types.ts b/packages/app/src/cli/services/flow/types.ts index a7e9c7fa2d0..10f6742a9ec 100644 --- a/packages/app/src/cli/services/flow/types.ts +++ b/packages/app/src/cli/services/flow/types.ts @@ -28,5 +28,3 @@ export const FLOW_ACTION_URL_FIELDS = [ 'config_page_url', 'config_page_preview_url', ] as const - -export type FlowActionUrlField = (typeof FLOW_ACTION_URL_FIELDS)[number] diff --git a/packages/app/src/cli/services/flow/utils.test.ts b/packages/app/src/cli/services/flow/utils.test.ts index 77a4e715742..d0582058c5c 100644 --- a/packages/app/src/cli/services/flow/utils.test.ts +++ b/packages/app/src/cli/services/flow/utils.test.ts @@ -1,52 +1,8 @@ -import {loadSchemaFromPath, resolveFlowActionUrl} from './utils.js' +import {loadSchemaFromPath} from './utils.js' import {describe, expect, test} from 'vitest' import {readFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' -describe('resolveFlowActionUrl', () => { - test('returns absolute URLs unchanged', () => { - expect( - resolveFlowActionUrl('runtime_url', '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( - resolveFlowActionUrl('runtime_url', '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(resolveFlowActionUrl('runtime_url', '/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(() => resolveFlowActionUrl('runtime_url', '/api/execute', undefined)).toThrow( - 'Flow action runtime_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(() => resolveFlowActionUrl('runtime_url', 'http://my-prod-host.example.com/api/execute', undefined)).toThrow( - 'Flow action runtime_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(() => resolveFlowActionUrl('runtime_url', '', 'https://my-app.example.com')).toThrow( - 'Flow action runtime_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(() => resolveFlowActionUrl('runtime_url', '/api/execute', 'http://my-app.example.com')).toThrow( - 'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.', - ) - }) -}) - describe('loadSchemaFromPath', () => { test('loading schema from valid file path should return file contents', async () => { const extensionPath = __dirname.concat('/fixtures') diff --git a/packages/app/src/cli/services/flow/utils.ts b/packages/app/src/cli/services/flow/utils.ts index 1df4e643bec..cdc71c0575f 100644 --- a/packages/app/src/cli/services/flow/utils.ts +++ b/packages/app/src/cli/services/flow/utils.ts @@ -1,40 +1,5 @@ -import {prependApplicationUrl} from '../../models/extensions/specifications/validation/url_prepender.js' import {joinPath} from '@shopify/cli-kit/node/path' import {glob, readFile} from '@shopify/cli-kit/node/fs' -import {AbortError} from '@shopify/cli-kit/node/error' -import type {FlowActionUrlField} from './types.js' - -const isHttpsUrl = (url: string) => { - try { - return new URL(url).protocol === 'https:' - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (TypeError) { - return false - } -} - -/** - * Resolves a Flow action URL by prepending the app URL to relative URLs and - * ensuring the resolved URL is HTTPS. - */ -export const resolveFlowActionUrl = (fieldName: FlowActionUrlField, url: string, appUrl: string | undefined) => { - const resolvedUrl = prependApplicationUrl(url, appUrl) - if (resolvedUrl.startsWith('/')) { - throw new AbortError( - `Flow action ${fieldName} is a relative URL, but no application_url is configured. ` + - 'Set application_url in your app configuration or use an absolute HTTPS URL.', - ) - } - - if (!isHttpsUrl(resolvedUrl)) { - throw new AbortError( - `Flow action ${fieldName} must resolve to an HTTPS URL. ` + - 'Set application_url to an HTTPS URL or use an absolute HTTPS URL.', - ) - } - - return resolvedUrl -} /** * Loads the schema from the partner defined file. diff --git a/packages/app/src/cli/services/flow/validation.ts b/packages/app/src/cli/services/flow/validation.ts index 48f1a210d3e..eb0ba5bff0a 100644 --- a/packages/app/src/cli/services/flow/validation.ts +++ b/packages/app/src/cli/services/flow/validation.ts @@ -1,7 +1,7 @@ import {ConfigField, FlowExtensionTypes} from './types.js' import {SUPPORTED_COMMERCE_OBJECTS} from './constants.js' import {FlowTriggerSettingsSchema} from '../../models/extensions/specifications/flow_trigger.js' -import {validateRelativeUrl} from '../../models/app/validation/common.js' +import {URL_CONTROL_CHARACTERS, validateRelativeUrl} from '../../models/app/validation/common.js' import {zod} from '@shopify/cli-kit/node/schema' function fieldValidationErrorMessage(property: string, configField: ConfigField, handle: string, index: number) { @@ -56,14 +56,12 @@ export const validateFieldShape = ( export const isSchemaTypeReference = (type: string) => type.startsWith('schema.') -const containsUrlControlCharacter = (value: string) => /[\r\n\t]/.test(value) - export const validateFlowActionUrl = (zodType: zod.ZodString) => { return validateRelativeUrl(zodType, { message: 'Invalid URL: URL must be an absolute HTTPS URL or a relative URL starting with a single slash (e.g. "/api/endpoint").', }) - .refine((value) => !containsUrlControlCharacter(value), { + .refine((value) => !URL_CONTROL_CHARACTERS.test(value), { message: 'Invalid URL: URL must not contain control characters such as newlines or tabs.', }) .refine((value) => !value.startsWith('//'), {message: 'Invalid URL: Relative URLs must start with a single slash.'})