From fd17b246e11553449eff2e97526651e7346abd11 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:43:02 +1100 Subject: [PATCH] fix(studio): tighten webhook endpoint validation (#43892) ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Webhook endpoint validation is inconsistent across Studio forms. The webhook sheet accepts incomplete hostnames like `https://webhook`, event type validation is not surfaced clearly, and HTTP endpoint validation differs between webhooks, log drains, cron jobs, and database hooks. ## What is the new behavior? - Tightens webhook endpoint URL validation and rejects incomplete hostnames while still allowing localhost and IP-based endpoints. - Surfaces the Event types validation through the standard form error styling and highlights the existing accordion item border when invalid. - **Extracts a shared HTTP endpoint URL validator and reuses it in webhooks, log drains, cron jobs, and database hooks.** - Adds focused regression tests for the webhook sheet and the shared/consumer validation paths. | After | | --- | | Webhooks Settings AWS Healthy
Toolshed Supabase-CB0D999C-D0BF-47AA-A10F-342A2E328DF9 | ## To test Form behaviour (in particular URL validation) on: - Webhook endpoint - Log drains - Cron jobs - Database hooks --- .../Hooks/EditHookPanel.constants.test.ts | 49 +++ .../Database/Hooks/EditHookPanel.constants.ts | 12 +- .../CreateCronJobSheet.constants.test.ts | 53 +++ .../CreateCronJobSheet.constants.ts | 13 +- .../LogDrainDestinationSheetForm.tsx | 38 +- .../LogDrains/LogDrains.utils.test.ts | 12 + .../interfaces/LogDrains/LogDrains.utils.ts | 13 +- .../PlatformWebhooksEndpointSheet.test.tsx | 117 +++++- .../PlatformWebhooksEndpointSheet.tsx | 357 +++++++++--------- apps/studio/lib/validation/http-url.test.ts | 62 +++ apps/studio/lib/validation/http-url.ts | 59 +++ 11 files changed, 567 insertions(+), 218 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.test.ts create mode 100644 apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.test.ts create mode 100644 apps/studio/lib/validation/http-url.test.ts create mode 100644 apps/studio/lib/validation/http-url.ts diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.test.ts b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.test.ts new file mode 100644 index 00000000000..3a5508f00a0 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { FormSchema } from './EditHookPanel.constants' + +describe('EditHookPanel FormSchema', () => { + it('rejects incomplete http_request hostnames', () => { + const result = FormSchema.safeParse({ + name: 'Test hook', + table_id: 'public.messages', + http_method: 'POST' as const, + timeout_ms: 1000, + events: ['INSERT'], + httpHeaders: [], + httpParameters: [], + function_type: 'http_request' as const, + http_url: 'https://webhook', + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message === 'Please provide a valid URL') + ).toBe(true) + } + }) + + it('rejects http_request URLs without an explicit protocol', () => { + const result = FormSchema.safeParse({ + name: 'Test hook', + table_id: 'public.messages', + http_method: 'POST' as const, + timeout_ms: 1000, + events: ['INSERT'], + httpHeaders: [], + httpParameters: [], + function_type: 'http_request' as const, + http_url: 'hooks.example.com/webhook', + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some( + (issue) => issue.message === 'Please prefix your URL with http:// or https://' + ) + ).toBe(true) + } + }) +}) diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.ts b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.ts index fb991e2c17b..87ece7fc9d0 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.ts +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.constants.ts @@ -1,14 +1,14 @@ import { z } from 'zod' -import { isValidHttpUrl } from '@/lib/helpers' +import { httpEndpointUrlSchema } from '@/lib/validation/http-url' const httpRequestSchema = z.object({ function_type: z.literal('http_request'), - http_url: z - .string() - .min(1, 'Please provide a URL') - .refine((val) => val.startsWith('http'), 'Please prefix your URL with http or https') - .refine((val) => isValidHttpUrl(val), 'Please provide a valid URL'), + http_url: httpEndpointUrlSchema({ + requiredMessage: 'Please provide a URL', + invalidMessage: 'Please provide a valid URL', + prefixMessage: 'Please prefix your URL with http:// or https://', + }), }) const supabaseFunctionSchema = z.object({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.test.ts new file mode 100644 index 00000000000..f048b52e4ab --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { FormSchema } from './CreateCronJobSheet.constants' + +describe('CreateCronJobSheet FormSchema', () => { + it('rejects incomplete http_request hostnames', () => { + const result = FormSchema.safeParse({ + name: 'Send webhook', + supportsSeconds: false, + schedule: '* * * * *', + values: { + type: 'http_request' as const, + method: 'POST' as const, + endpoint: 'https://webhook', + timeoutMs: 1000, + httpHeaders: [], + snippet: '', + }, + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message === 'Please provide a valid URL') + ).toBe(true) + } + }) + + it('rejects http_request URLs without an explicit protocol', () => { + const result = FormSchema.safeParse({ + name: 'Send webhook', + supportsSeconds: false, + schedule: '* * * * *', + values: { + type: 'http_request' as const, + method: 'POST' as const, + endpoint: 'hooks.example.com/webhook', + timeoutMs: 1000, + httpHeaders: [], + snippet: '', + }, + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some( + (issue) => issue.message === 'Please prefix your URL with http:// or https://' + ) + ).toBe(true) + } + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts index a9f70ce8c2a..ddbdf55928b 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.constants.ts @@ -1,8 +1,8 @@ import { toString as CronToString } from 'cronstrue' import z from 'zod' -import { urlRegex } from 'components/interfaces/Auth/Auth.constants' import { cronPattern, secondsPattern } from '../CronJobs.constants' +import { httpEndpointUrlSchema } from '@/lib/validation/http-url' const convertCronToString = (schedule: string) => { // pg_cron can also use "30 seconds" format for schedule. Cronstrue doesn't understand that format so just use the @@ -40,12 +40,11 @@ const edgeFunctionSchema = z.object({ const httpRequestSchema = z.object({ type: z.literal('http_request'), method: z.enum(['GET', 'POST']), - endpoint: z - .string() - .trim() - .min(1, 'Please provide a URL') - .regex(urlRegex(), 'Please provide a valid URL') - .refine((value) => value.startsWith('http'), 'Please include HTTP/HTTPs to your URL'), + endpoint: httpEndpointUrlSchema({ + requiredMessage: 'Please provide a URL', + invalidMessage: 'Please provide a valid URL', + prefixMessage: 'Please prefix your URL with http:// or https://', + }), timeoutMs: z.coerce.number().int().gte(1000).lte(5000).default(1000), httpHeaders: z.array(z.object({ name: z.string(), value: z.string() })), httpBody: z diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index 1bcb5bf50de..bf04fae4380 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -39,7 +39,6 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { z } from 'zod' -import { urlRegex } from '../Auth/Auth.constants' import { DATADOG_REGIONS, LAST9_REGIONS, @@ -51,19 +50,18 @@ import { getHeadersSectionDescription as getHeadersDescription, validateNewHeader, } from './LogDrains.utils' +import { httpEndpointUrlSchema } from '@/lib/validation/http-url' const FORM_ID = 'log-drain-destination-form' const formUnion = z.discriminatedUnion('type', [ z.object({ type: z.literal('webhook'), - url: z - .string() - .regex(urlRegex(), 'Endpoint URL is required and must be a valid URL') - .refine( - (url) => url.startsWith('http://') || url.startsWith('https://'), - 'Endpoint URL must start with http:// or https://' - ), + url: httpEndpointUrlSchema({ + requiredMessage: 'Endpoint URL is required', + invalidMessage: 'Endpoint URL must be a valid URL', + prefixMessage: 'Endpoint URL must start with http:// or https://', + }), http: z.enum(['http1', 'http2']), gzip: z.boolean(), headers: z.record(z.string(), z.string()).optional(), @@ -75,13 +73,11 @@ const formUnion = z.discriminatedUnion('type', [ }), z.object({ type: z.literal('loki'), - url: z - .string() - .min(1, { message: 'Loki URL is required' }) - .refine( - (url) => url.startsWith('http://') || url.startsWith('https://'), - 'Loki URL must start with http:// or https://' - ), + url: httpEndpointUrlSchema({ + requiredMessage: 'Loki URL is required', + invalidMessage: 'Loki URL must be a valid URL', + prefixMessage: 'Loki URL must start with http:// or https://', + }), headers: z.record(z.string(), z.string()), username: z.string().optional(), password: z.string().optional(), @@ -130,13 +126,11 @@ const formUnion = z.discriminatedUnion('type', [ }), z.object({ type: z.literal('otlp'), - endpoint: z - .string() - .min(1, { message: 'OTLP endpoint is required' }) - .refine( - (url) => url.startsWith('http://') || url.startsWith('https://'), - 'OTLP endpoint must start with http:// or https://' - ), + endpoint: httpEndpointUrlSchema({ + requiredMessage: 'OTLP endpoint is required', + invalidMessage: 'OTLP endpoint must be a valid URL', + prefixMessage: 'OTLP endpoint must start with http:// or https://', + }), protocol: z.string().optional().default('http/protobuf'), gzip: z.boolean().optional().default(true), headers: z.record(z.string(), z.string()).optional(), diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.utils.test.ts b/apps/studio/components/interfaces/LogDrains/LogDrains.utils.test.ts index 3c37c43ad9b..40cea68d6c6 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.utils.test.ts +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.utils.test.ts @@ -223,6 +223,18 @@ describe('otlpConfigSchema', () => { expect(result.success).toBe(false) }) + it('rejects endpoint with an incomplete hostname', () => { + const config = { + type: 'otlp' as const, + endpoint: 'https://webhook', + } + const result = otlpConfigSchema.safeParse(config) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('must be a valid URL') + } + }) + it('rejects wrong type field', () => { const config = { type: 'webhook' as const, diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.utils.ts b/apps/studio/components/interfaces/LogDrains/LogDrains.utils.ts index 5195513ce75..95c5712e819 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.utils.ts +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.utils.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { LogDrainType } from './LogDrains.constants' +import { httpEndpointUrlSchema } from '@/lib/validation/http-url' /** * Get the description text for the custom headers section based on log drain type @@ -62,13 +63,11 @@ export function validateNewHeader( */ export const otlpConfigSchema = z.object({ type: z.literal('otlp'), - endpoint: z - .string() - .min(1, { message: 'OTLP endpoint is required' }) - .refine( - (url) => url.startsWith('http://') || url.startsWith('https://'), - 'OTLP endpoint must start with http:// or https://' - ), + endpoint: httpEndpointUrlSchema({ + requiredMessage: 'OTLP endpoint is required', + invalidMessage: 'OTLP endpoint must be a valid URL', + prefixMessage: 'OTLP endpoint must start with http:// or https://', + }), protocol: z.string().optional().default('http/protobuf'), gzip: z.boolean().optional().default(true), headers: z.record(z.string(), z.string()).optional(), diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx index 5a3fbff9b7a..f82967ca320 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx @@ -55,6 +55,15 @@ const renderEndpointSheet = ( return { onClose, onSubmit } } +const submitForm = () => + fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!) + +const getUrlInput = () => screen.getByPlaceholderText('https://api.example.com/webhooks/supabase') + +const findEventTypeCheckbox = (eventType: string) => + screen.findByRole('checkbox', { + name: new RegExp(eventType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + }) describe('PlatformWebhooksEndpointSheet', () => { afterEach(() => { vi.clearAllMocks() @@ -85,7 +94,7 @@ describe('PlatformWebhooksEndpointSheet', () => { const nameInput = await screen.findByDisplayValue('Billing events') await user.clear(nameInput) - fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!) + submitForm() await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) expect(onSubmit).toHaveBeenCalledWith( @@ -97,4 +106,110 @@ describe('PlatformWebhooksEndpointSheet', () => { expect.anything() ) }) + + it('blocks submit when the endpoint URL is empty', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.click(await findEventTypeCheckbox('project.updated')) + submitForm() + + expect(await screen.findByText('Please provide a URL')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('blocks submit when the endpoint URL is malformed', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.type(getUrlInput(), 'https://not a url') + await user.click(await findEventTypeCheckbox('project.updated')) + submitForm() + + expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('blocks submit when the endpoint URL uses an incomplete hostname', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.type(getUrlInput(), 'https://webhook') + await user.click(await findEventTypeCheckbox('project.updated')) + submitForm() + + expect(await screen.findByText('Please provide a valid URL')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('blocks submit when the endpoint URL does not include a protocol', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.type(getUrlInput(), 'hooks.example.com/billing') + await user.click(await findEventTypeCheckbox('project.updated')) + submitForm() + + expect( + await screen.findByText('Please prefix your URL with http:// or https://') + ).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('shows an error when no event types are selected', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.type(getUrlInput(), 'https://hooks.example.com/billing') + submitForm() + + expect(await screen.findByText('Select at least one event type')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('clears the event type error after selecting an event and allows submit', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.type(getUrlInput(), 'https://hooks.example.com/billing') + submitForm() + + expect(await screen.findByText('Select at least one event type')).toBeInTheDocument() + + await user.click(await findEventTypeCheckbox('project.updated')) + + await waitFor(() => { + expect(screen.queryByText('Select at least one event type')).not.toBeInTheDocument() + }) + + submitForm() + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://hooks.example.com/billing', + eventTypes: ['project.updated'], + }), + expect.anything() + ) + }) + + it('allows submit when subscribe all is enabled', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEndpointSheet() + + await user.type(getUrlInput(), 'https://hooks.example.com/billing') + await user.click(screen.getByRole('checkbox', { name: /subscribe to all events/i })) + submitForm() + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + subscribeAll: true, + eventTypes: PROJECT_EVENT_TYPES, + url: 'https://hooks.example.com/billing', + }), + expect.anything() + ) + }) }) diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx index db3bfd83430..e51d59c0bf0 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -38,11 +38,16 @@ import type { WebhookScope, } from './PlatformWebhooks.types' import { generateWebhookEndpointName } from './PlatformWebhooks.utils' +import { httpEndpointUrlSchema } from '@/lib/validation/http-url' const endpointFormSchema = z .object({ name: z.string().trim().max(64, 'Name cannot exceed 64 characters'), - url: z.string().trim().url('Please enter a valid URL'), + url: httpEndpointUrlSchema({ + requiredMessage: 'Please provide a URL', + invalidMessage: 'Please provide a valid URL', + prefixMessage: 'Please prefix your URL with http:// or https://', + }), description: z.string().trim().max(512, 'Description cannot exceed 512 characters'), enabled: z.boolean().default(true), subscribeAll: z.boolean().default(false), @@ -345,188 +350,190 @@ export const PlatformWebhooksEndpointSheet = ({
- - Project events are triggered when any project in this organization matches - the event type. Add a{' '} - - project endpoint - {' '} - to listen to events on an individual project only. - - ) : ( - <> - Project events are triggered for this project only. Add an{' '} - - organization endpoint - {' '} - to listen to events from any project in your organization. - - ) - } - layout="vertical" - className="gap-3" - > - { - const subscribeAllId = 'subscribe-all-events' - return ( -
- -
- ) - }} - /> - - { - const selectedTypes = field.value ?? [] - - return ( - <> - - - {groupedEventTypes.map((group) => { - const selectedInGroup = group.eventTypes.filter((eventType) => - selectedTypes.includes(eventType) - ) - const allSelected = - selectedInGroup.length === group.eventTypes.length - const isGroupOpen = openEventGroups.includes(group.id) - - return ( - + - -
-
-

- {group.label} -

- {selectedInGroup.length > 0 && ( - - {selectedInGroup.length} - - )} -
-
- {isGroupOpen && group.eventTypes.length > 1 && ( - { - event.preventDefault() - event.stopPropagation() +
+
+

+ {group.label} +

+ {selectedInGroup.length > 0 && ( + + {selectedInGroup.length} + + )} +
+
+ {isGroupOpen && group.eventTypes.length > 1 && ( + { + event.preventDefault() + event.stopPropagation() + field.onChange( + toggleEventTypeGroup( + selectedTypes, + group.eventTypes, + !allSelected + ) + ) + }} + > + {allSelected ? 'Clear all' : 'Select all'} + + )} + +
+
+ + +
+ {group.eventTypes.map((eventType) => { + const checked = selectedTypes.includes(eventType) + const eventTypeId = toControlId('event-type', eventType) + + return ( +
-
- - -
- {group.eventTypes.map((eventType) => { - const checked = selectedTypes.includes(eventType) - const eventTypeId = toControlId('event-type', eventType) - - return ( - - ) - })} -
-
- - ) - })} - - - - ) - }} - /> - + /> + {eventType} + + ) + })} +
+ +
+ ) + })} +
+
+
+ ) + }} + />
diff --git a/apps/studio/lib/validation/http-url.test.ts b/apps/studio/lib/validation/http-url.test.ts new file mode 100644 index 00000000000..c10f5a6785c --- /dev/null +++ b/apps/studio/lib/validation/http-url.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { httpEndpointUrlSchema, isValidHttpEndpointUrl } from './http-url' + +const schema = httpEndpointUrlSchema({ + requiredMessage: 'required', + invalidMessage: 'invalid', + prefixMessage: 'prefix', +}) + +describe('isValidHttpEndpointUrl', () => { + it('accepts valid http and https endpoints', () => { + expect(isValidHttpEndpointUrl('https://api.supabase.com/webhooks')).toBe(true) + expect(isValidHttpEndpointUrl('http://localhost:3000/hooks')).toBe(true) + expect(isValidHttpEndpointUrl('https://127.0.0.1:4318/v1/logs')).toBe(true) + expect(isValidHttpEndpointUrl('https://[::1]:4318/v1/logs')).toBe(true) + }) + + it('rejects invalid endpoint URLs', () => { + expect(isValidHttpEndpointUrl('https://webhook')).toBe(false) + expect(isValidHttpEndpointUrl('ftp://api.supabase.com/webhooks')).toBe(false) + expect(isValidHttpEndpointUrl('not a url')).toBe(false) + }) +}) + +describe('httpEndpointUrlSchema', () => { + it('rejects empty values', () => { + const result = schema.safeParse('') + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('required') + } + }) + + it('rejects URLs without an http or https prefix', () => { + const result = schema.safeParse('api.supabase.com/webhooks') + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('prefix') + } + }) + + it('rejects incomplete hostnames', () => { + const result = schema.safeParse('https://webhook') + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('invalid') + } + }) + + it('accepts valid endpoints after trimming', () => { + const result = schema.safeParse(' https://api.supabase.com/webhooks ') + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBe('https://api.supabase.com/webhooks') + } + }) +}) diff --git a/apps/studio/lib/validation/http-url.ts b/apps/studio/lib/validation/http-url.ts new file mode 100644 index 00000000000..a6bd0c85074 --- /dev/null +++ b/apps/studio/lib/validation/http-url.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' + +const HTTP_URL_PROTOCOL_REGEX = /^https?:\/\// +const IPV4_SEGMENT = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)' +const IPV4_REGEX = new RegExp(`^(?:${IPV4_SEGMENT}\\.){3}${IPV4_SEGMENT}$`) +const BRACKETED_IPV6_REGEX = /^\[[0-9a-f:.]+\]$/i + +export const hasHttpUrlProtocol = (value: string) => HTTP_URL_PROTOCOL_REGEX.test(value) + +export const isValidHttpEndpointUrl = (value: string) => { + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + + const { hostname } = url + return ( + hostname === 'localhost' || + hostname.includes('.') || + IPV4_REGEX.test(hostname) || + BRACKETED_IPV6_REGEX.test(hostname) + ) + } catch { + return false + } +} + +type HttpEndpointUrlSchemaOptions = { + requiredMessage: string + invalidMessage: string + prefixMessage: string +} + +export const httpEndpointUrlSchema = ({ + requiredMessage, + invalidMessage, + prefixMessage, +}: HttpEndpointUrlSchemaOptions) => + z + .string() + .trim() + .min(1, requiredMessage) + .superRefine((value, ctx) => { + if (!value) return + + if (!hasHttpUrlProtocol(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: prefixMessage, + }) + return + } + + if (!isValidHttpEndpointUrl(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: invalidMessage, + }) + } + })