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 |
| --- |
| <img width="1728" height="997" alt="Webhooks Settings AWS Healthy
Toolshed Supabase-CB0D999C-D0BF-47AA-A10F-342A2E328DF9"
src="https://github.com/user-attachments/assets/bcbe4876-f9a7-497a-b288-460087a65546"
/> |

## To test

Form behaviour (in particular URL validation) on:

- Webhook endpoint
- Log drains
- Cron jobs
- Database hooks
This commit is contained in:
Danny White authored and GitHub committed 2026-03-19 11:43:02 +11:00
1 parent 46a793a32d
commit fd17b246e1
11 files changed
+567 -218

No files matched your search

@@ -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)
}
})
})
@@ -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({
@@ -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)
}
})
})
@@ -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
@@ -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(),
@@ -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,
@@ -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(),
@@ -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()
)
})
})
@@ -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 = ({
<Separator />
<div className="px-5 space-y-3">
<FormItemLayout
label="Event types"
description={
scope === 'organization' ? (
<>
Project events are triggered when any project in this organization matches
the event type. Add a{' '}
<InlineLink href="/project/_/settings/webhooks">
project endpoint
</InlineLink>{' '}
to listen to events on an individual project only.
</>
) : (
<>
Project events are triggered for this project only. Add an{' '}
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
organization endpoint
</InlineLink>{' '}
to listen to events from any project in your organization.
</>
)
}
layout="vertical"
className="gap-3"
>
<FormField_Shadcn_
control={form.control}
name="subscribeAll"
render={({ field }) => {
const subscribeAllId = 'subscribe-all-events'
return (
<div className="rounded-md border bg-surface-100 overflow-hidden">
<Label
htmlFor={subscribeAllId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
field.value ? 'bg-surface-100' : 'bg-surface-200'
)}
<FormField_Shadcn_
control={form.control}
name="eventTypes"
render={({ field, fieldState }) => {
const selectedTypes = field.value ?? []
const hasEventTypeError = !!fieldState.error
return (
<FormItemLayout
label="Event types"
description={
scope === 'organization' ? (
<>
Project events are triggered when any project in this organization
matches the event type. Add a{' '}
<InlineLink href="/project/_/settings/webhooks">
project endpoint
</InlineLink>{' '}
to listen to events on an individual project only.
</>
) : (
<>
Project events are triggered for this project only. Add an{' '}
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
organization endpoint
</InlineLink>{' '}
to listen to events from any project in your organization.
</>
)
}
layout="vertical"
className="gap-2"
>
<FormField_Shadcn_
control={form.control}
name="subscribeAll"
render={({ field }) => {
const subscribeAllId = 'subscribe-all-events'
return (
<div className="rounded-md border bg-surface-100 overflow-hidden">
<Label
htmlFor={subscribeAllId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
field.value ? 'bg-surface-100' : 'bg-surface-200'
)}
>
<FormControl_Shadcn_>
<Checkbox
id={subscribeAllId}
checked={field.value}
onCheckedChange={(checked) => {
const nextValue = Boolean(checked)
field.onChange(nextValue)
if (nextValue) {
form.setValue('eventTypes', eventTypes, {
shouldDirty: true,
shouldValidate: true,
})
return
}
form.setValue('eventTypes', [], {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
</FormControl_Shadcn_>
<span className="text-sm text-foreground">
Subscribe to all events{' '}
<code className="text-code-inline">(*)</code>
</span>
</Label>
</div>
)
}}
/>
<FormControl_Shadcn_>
<Accordion
type="multiple"
value={openEventGroups}
onValueChange={setOpenEventGroups}
className="mt-2 space-y-2"
>
<FormControl_Shadcn_>
<Checkbox
id={subscribeAllId}
checked={field.value}
onCheckedChange={(checked) => {
const nextValue = Boolean(checked)
field.onChange(nextValue)
{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)
if (nextValue) {
form.setValue('eventTypes', eventTypes, {
shouldDirty: true,
shouldValidate: true,
})
return
}
form.setValue('eventTypes', [], {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
</FormControl_Shadcn_>
<span className="text-sm text-foreground">
Subscribe to all events <code className="text-code-inline">(*)</code>
</span>
</Label>
</div>
)
}}
/>
<FormField_Shadcn_
control={form.control}
name="eventTypes"
render={({ field }) => {
const selectedTypes = field.value ?? []
return (
<>
<FormControl_Shadcn_>
<Accordion
type="multiple"
value={openEventGroups}
onValueChange={setOpenEventGroups}
className="mt-2 space-y-2"
>
{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 (
<AccordionItem
key={group.id}
value={group.id}
className="overflow-hidden rounded-md border"
return (
<AccordionItem
key={group.id}
value={group.id}
className={cn(
'overflow-hidden rounded-md border',
hasEventTypeError && 'border-destructive-400'
)}
>
<AccordionTrigger
hideIcon
className="group px-4 py-3 hover:no-underline"
>
<AccordionTrigger
hideIcon
className="group px-4 py-3 hover:no-underline"
>
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">
{group.label}
</p>
{selectedInGroup.length > 0 && (
<span className="text-xs text-foreground-muted">
{selectedInGroup.length}
</span>
)}
</div>
<div className="flex items-center gap-3">
{isGroupOpen && group.eventTypes.length > 1 && (
<span
className="text-xs text-foreground-muted hover:text-foreground"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">
{group.label}
</p>
{selectedInGroup.length > 0 && (
<span className="text-xs text-foreground-muted">
{selectedInGroup.length}
</span>
)}
</div>
<div className="flex items-center gap-3">
{isGroupOpen && group.eventTypes.length > 1 && (
<span
className="text-xs text-foreground-muted hover:text-foreground"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
field.onChange(
toggleEventTypeGroup(
selectedTypes,
group.eventTypes,
!allSelected
)
)
}}
>
{allSelected ? 'Clear all' : 'Select all'}
</span>
)}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
<div className="divide-y border-t">
{group.eventTypes.map((eventType) => {
const checked = selectedTypes.includes(eventType)
const eventTypeId = toControlId('event-type', eventType)
return (
<Label
key={eventType}
htmlFor={eventTypeId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
checked && 'bg-surface-100'
)}
>
<Checkbox
id={eventTypeId}
checked={checked}
onCheckedChange={(next) => {
field.onChange(
toggleEventTypeGroup(
toggleEventType(
selectedTypes,
group.eventTypes,
!allSelected
eventType,
Boolean(next)
)
)
}}
>
{allSelected ? 'Clear all' : 'Select all'}
</span>
)}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
<div className="divide-y border-t">
{group.eventTypes.map((eventType) => {
const checked = selectedTypes.includes(eventType)
const eventTypeId = toControlId('event-type', eventType)
return (
<Label
key={eventType}
htmlFor={eventTypeId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
checked && 'bg-surface-100'
)}
>
<Checkbox
id={eventTypeId}
checked={checked}
onCheckedChange={(next) => {
field.onChange(
toggleEventType(
selectedTypes,
eventType,
Boolean(next)
)
)
}}
/>
<code className="text-code-inline">{eventType}</code>
</Label>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</FormControl_Shadcn_>
</>
)
}}
/>
</FormItemLayout>
/>
<code className="text-code-inline">{eventType}</code>
</Label>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</FormControl_Shadcn_>
</FormItemLayout>
)
}}
/>
</div>
<Separator />
@@ -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')
}
})
})
+59
View File
@@ -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,
})
}
})