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 |
| --- |
| |
## 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 = ({
- {group.label} -
- {selectedInGroup.length > 0 && ( - - {selectedInGroup.length} - - )} -+ {group.label} +
+ {selectedInGroup.length > 0 && ( + + {selectedInGroup.length} + + )} +{eventType}
- {eventType}
+
+ )
+ })}
+