From 82743b1c0a2af7cff1ddd104e2d1d239a3b75b64 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 27 May 2026 22:52:15 -0400 Subject: [PATCH] feat: Allow http webhook urls when self-hosting (#12499) --- .../components/WebhookSubscriptionForm.tsx | 13 ++++++- plugins/webhooks/server/api/schema.ts | 11 ++++-- .../server/api/webhookSubscriptions.test.ts | 34 +++++++++++++++++++ shared/i18n/locales/en_US/translation.json | 1 + 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx index f79af68ecc..61d0fc24fd 100644 --- a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx +++ b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx @@ -13,6 +13,7 @@ import Input from "~/components/Input"; import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useMobile from "~/hooks/useMobile"; +import isCloudHosted from "~/utils/isCloudHosted"; import Flex from "@shared/components/Flex"; const WEBHOOK_EVENTS = { @@ -153,6 +154,9 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) { }); const events = watch("events"); + const url = watch("url"); + const showInsecureUrlWarning = + !isCloudHosted && typeof url === "string" && url.startsWith("http://"); const selectedGroups = filter(events, (e) => !e.includes(".")); const isAllEventSelected = includes(events, "*"); const filteredEvents = filter(events, (e) => { @@ -224,9 +228,16 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) { !env.isCloudHosted || val.startsWith("https://"), { + error: "Webhook url must use https", + }); + export const WebhookSubscriptionsListSchema = BaseSchema.extend({ body: z.object({ /** Webhook subscriptions sorting direction */ @@ -33,7 +40,7 @@ export type WebhookSubscriptionsListReq = z.infer< export const WebhookSubscriptionsCreateSchema = z.object({ body: z.object({ name: z.string(), - url: z.url(), + url: webhookUrl, secret: z.string().optional(), events: z.array(z.string()), }), @@ -47,7 +54,7 @@ export const WebhookSubscriptionsUpdateSchema = z.object({ body: z.object({ id: z.uuid(), name: z.string(), - url: z.url(), + url: webhookUrl, secret: z.string().optional(), events: z.array(z.string()), }), diff --git a/plugins/webhooks/server/api/webhookSubscriptions.test.ts b/plugins/webhooks/server/api/webhookSubscriptions.test.ts index 1eaf1d28de..7d3cf3783e 100644 --- a/plugins/webhooks/server/api/webhookSubscriptions.test.ts +++ b/plugins/webhooks/server/api/webhookSubscriptions.test.ts @@ -1,3 +1,4 @@ +import env from "@server/env"; import { buildAdmin, buildUser, @@ -167,6 +168,39 @@ describe("#webhookSubscriptions.create", () => { expect(webhook.secret).toEqual(secret); expect(webhook.enabled).toEqual(true); }); + + it("should reject http urls when cloud hosted", async () => { + vi.spyOn(env, "isCloudHosted", "get").mockReturnValue(true); + + const user = await buildAdmin(); + const res = await server.post("/api/webhookSubscriptions.create", user, { + body: { + name: "Test webhook", + url: "http://www.example.com", + events: ["comments"], + }, + }); + + expect(res.status).toEqual(400); + }); + + it("should allow http urls when not cloud hosted", async () => { + vi.spyOn(env, "isCloudHosted", "get").mockReturnValue(false); + + const user = await buildAdmin(); + const url = "http://www.example.com"; + const res = await server.post("/api/webhookSubscriptions.create", user, { + body: { + name: "Test webhook", + url, + events: ["comments"], + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.url).toEqual(url); + }); }); describe("#webhookSubscriptions.update", () => { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 483f33162b..09e6ba9493 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -1610,6 +1610,7 @@ "Provide a descriptive name for this webhook and the URL we should send a POST request to when matching events are created.": "Provide a descriptive name for this webhook and the URL we should send a POST request to when matching events are created.", "A memorable identifer": "A memorable identifer", "URL": "URL", + "Webhook delivery over http is insecure, use https if possible": "Webhook delivery over http is insecure, use https if possible", "Signing secret": "Signing secret", "Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.", "All events": "All events",