From 51093e0e81361e73adbf00ca56610b7e1c91fb74 Mon Sep 17 00:00:00 2001 From: JohnBlackwell Date: Fri, 4 Sep 2026 12:05:14 -0400 Subject: [PATCH 1/5] update teams sink validation to accept post-2026 webhook hosts --- .../sinks/UpsertNotificationSinkModal.tsx | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx index 761b86164f..4945c1961a 100644 --- a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx +++ b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx @@ -14,13 +14,35 @@ import { import { InlineLink } from 'components/utils/typography/InlineLink' import { appendConnection, updateCache } from 'utils/graphql' +import { isValidURL } from 'utils/url' import { sinkTypeToIcon } from './NotificationSinksColumns' +const teamsWebhookHosts = [ + 'office.com', + 'office365.com', + 'powerautomate.com', + 'powerplatform.com', + 'logic.azure.com', +] + +function matchesWebhookHost(url: string, hosts: string[]) { + if (!isValidURL(url) || !/^https:\/\//i.test(url)) { + return false + } + + const { hostname, protocol } = new URL(url) + + return ( + protocol === 'https:' && + hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`)) + ) +} + const hookUrlMatch = [ - [SinkType.Slack, /^https:\/\/[^/]*?slack/], - [SinkType.Teams, /^https:\/\/[^/]*?office/], -] as const satisfies [SinkType, RegExp][] + [SinkType.Slack, (url: string) => matchesWebhookHost(url, ['slack.com'])], + [SinkType.Teams, (url: string) => matchesWebhookHost(url, teamsWebhookHosts)], +] as const satisfies [SinkType, (url: string) => boolean][] type ModalBaseProps = { mode: 'edit' | 'create' @@ -39,6 +61,9 @@ function UpsertNotificationSinkModal({ ...props }: ModalProps) { const sink = mode === 'edit' ? props.sink : undefined + const sinkName = sink?.name + const slackUrl = sink?.configuration.slack?.url + const teamsUrl = sink?.configuration.teams?.url const theme = useTheme() const initialState = useMemo( () => ({ @@ -46,25 +71,19 @@ function UpsertNotificationSinkModal({ hookUrl: '', ...(mode === 'edit' ? { - name: sink?.name, - hookUrl: - sink?.configuration.slack?.url || sink?.configuration.teams?.url, + name: sinkName, + hookUrl: slackUrl || teamsUrl, } : {}), }), - [ - mode, - sink?.configuration.slack?.url, - sink?.configuration.teams?.url, - sink?.name, - ] + [mode, slackUrl, sinkName, teamsUrl] ) const { state, update, hasUpdates } = useUpdateState<{ name: string hookUrl: string }>(initialState) - const hookType = hookUrlMatch.find(([_, regex]) => - regex.test(state.hookUrl) + const hookType = hookUrlMatch.find(([_, matches]) => + matches(state.hookUrl) )?.[0] const [mutation, { loading }] = useUpsertNotificationSinkMutation({ From f6f3cb70924db9927230797df38957cdb7f4424c Mon Sep 17 00:00:00 2001 From: JohnBlackwell Date: Fri, 4 Sep 2026 12:12:32 -0400 Subject: [PATCH 2/5] catch malformed urls --- .../sinks/UpsertNotificationSinkModal.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx index 4945c1961a..8514ade5a7 100644 --- a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx +++ b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx @@ -31,11 +31,20 @@ function matchesWebhookHost(url: string, hosts: string[]) { return false } - const { hostname, protocol } = new URL(url) + let parsedUrl: URL + + try { + parsedUrl = new URL(url) + } catch { + return false + } return ( - protocol === 'https:' && - hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`)) + parsedUrl.protocol === 'https:' && + hosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) ) } From 86a3d73429efa4d9592622881bd025780713b782 Mon Sep 17 00:00:00 2001 From: JohnBlackwell Date: Fri, 4 Sep 2026 12:40:41 -0400 Subject: [PATCH 3/5] retain slack gov support --- .../sinks/UpsertNotificationSinkModal.tsx | 42 +------------- .../sinks/notificationSinkUrl.test.ts | 55 +++++++++++++++++++ .../sinks/notificationSinkUrl.ts | 44 +++++++++++++++ 3 files changed, 101 insertions(+), 40 deletions(-) create mode 100644 js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts create mode 100644 js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts diff --git a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx index 8514ade5a7..5c14118845 100644 --- a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx +++ b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx @@ -8,50 +8,14 @@ import { useUpdateState } from 'components/hooks/useUpdateState' import { NotificationSinkFragment, NotificationSinksDocument, - SinkType, useUpsertNotificationSinkMutation, } from 'generated/graphql' import { InlineLink } from 'components/utils/typography/InlineLink' import { appendConnection, updateCache } from 'utils/graphql' -import { isValidURL } from 'utils/url' import { sinkTypeToIcon } from './NotificationSinksColumns' - -const teamsWebhookHosts = [ - 'office.com', - 'office365.com', - 'powerautomate.com', - 'powerplatform.com', - 'logic.azure.com', -] - -function matchesWebhookHost(url: string, hosts: string[]) { - if (!isValidURL(url) || !/^https:\/\//i.test(url)) { - return false - } - - let parsedUrl: URL - - try { - parsedUrl = new URL(url) - } catch { - return false - } - - return ( - parsedUrl.protocol === 'https:' && - hosts.some( - (host) => - parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) - ) - ) -} - -const hookUrlMatch = [ - [SinkType.Slack, (url: string) => matchesWebhookHost(url, ['slack.com'])], - [SinkType.Teams, (url: string) => matchesWebhookHost(url, teamsWebhookHosts)], -] as const satisfies [SinkType, (url: string) => boolean][] +import { getSinkTypeForWebhookUrl } from './notificationSinkUrl.ts' type ModalBaseProps = { mode: 'edit' | 'create' @@ -91,9 +55,7 @@ function UpsertNotificationSinkModal({ name: string hookUrl: string }>(initialState) - const hookType = hookUrlMatch.find(([_, matches]) => - matches(state.hookUrl) - )?.[0] + const hookType = getSinkTypeForWebhookUrl(state.hookUrl) const [mutation, { loading }] = useUpsertNotificationSinkMutation({ onCompleted: () => onClose?.(), diff --git a/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts b/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts new file mode 100644 index 0000000000..ce4e62e350 --- /dev/null +++ b/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts @@ -0,0 +1,55 @@ +import { SinkType } from 'generated/graphql' + +import { getSinkTypeForWebhookUrl } from './notificationSinkUrl' + +describe('getSinkTypeForWebhookUrl', () => { + it('matches standard Slack incoming webhook URLs', () => { + expect( + getSinkTypeForWebhookUrl( + 'https://hooks.slack.com/services/T00000000/B00000000/test' + ) + ).toBe(SinkType.Slack) + }) + + it('matches GovSlack incoming webhook URLs', () => { + expect( + getSinkTypeForWebhookUrl( + 'https://hooks.slack-gov.com/services/T00000000/B00000000/test' + ) + ).toBe(SinkType.Slack) + }) + + it('rejects userinfo hostname spoofing', () => { + expect( + getSinkTypeForWebhookUrl( + 'https://hooks.slack.com@example.com/services/T00000000/B00000000/test' + ) + ).toBeUndefined() + expect( + getSinkTypeForWebhookUrl( + 'https://environment.api.powerplatform.com@example.com/workflows/test' + ) + ).toBeUndefined() + }) + + it('rejects suffix hostname spoofing', () => { + expect( + getSinkTypeForWebhookUrl( + 'https://hooks.slack.com.example.com/services/T00000000/B00000000/test' + ) + ).toBeUndefined() + expect( + getSinkTypeForWebhookUrl( + 'https://hooks.slack-gov.com.example.com/services/T00000000/B00000000/test' + ) + ).toBeUndefined() + }) + + it('rejects URLs with invalid ports', () => { + expect( + getSinkTypeForWebhookUrl( + 'https://hooks.slack.com:99999/services/T00000000/B00000000/test' + ) + ).toBeUndefined() + }) +}) diff --git a/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts b/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts new file mode 100644 index 0000000000..fe1eed1cfb --- /dev/null +++ b/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts @@ -0,0 +1,44 @@ +import { SinkType } from 'generated/graphql' + +import { isValidURL } from 'utils/url' + +const slackWebhookHosts = ['slack.com', 'slack-gov.com'] + +const teamsWebhookHosts = [ + 'office.com', + 'office365.com', + 'powerautomate.com', + 'powerplatform.com', + 'logic.azure.com', +] + +function matchesWebhookHost(url: string, hosts: string[]) { + if (!isValidURL(url) || !/^https:\/\//i.test(url)) { + return false + } + + let parsedUrl: URL + + try { + parsedUrl = new URL(url) + } catch { + return false + } + + return ( + parsedUrl.protocol === 'https:' && + hosts.some( + (host) => + parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) + ) + ) +} + +const hookUrlMatch = [ + [SinkType.Slack, (url: string) => matchesWebhookHost(url, slackWebhookHosts)], + [SinkType.Teams, (url: string) => matchesWebhookHost(url, teamsWebhookHosts)], +] as const satisfies [SinkType, (url: string) => boolean][] + +export function getSinkTypeForWebhookUrl(url: string) { + return hookUrlMatch.find(([_, matches]) => matches(url))?.[0] +} From a9a83b973fcabc4a9fee275e5bae7f7b0a65c70d Mon Sep 17 00:00:00 2001 From: JohnBlackwell Date: Tue, 8 Sep 2026 08:52:06 -0400 Subject: [PATCH 4/5] notification sink validation server-side --- .../sinks/UpsertNotificationSinkModal.tsx | 18 ++++-- .../sinks/notificationSinkUrl.test.ts | 55 ------------------- .../sinks/notificationSinkUrl.ts | 44 --------------- lib/console/schema/notification_sink.ex | 52 +++++++++++++++++- 4 files changed, 63 insertions(+), 106 deletions(-) delete mode 100644 js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts delete mode 100644 js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts diff --git a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx index 5c14118845..8868fa9ad6 100644 --- a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx +++ b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx @@ -8,6 +8,7 @@ import { useUpdateState } from 'components/hooks/useUpdateState' import { NotificationSinkFragment, NotificationSinksDocument, + SinkType, useUpsertNotificationSinkMutation, } from 'generated/graphql' import { InlineLink } from 'components/utils/typography/InlineLink' @@ -15,7 +16,8 @@ import { InlineLink } from 'components/utils/typography/InlineLink' import { appendConnection, updateCache } from 'utils/graphql' import { sinkTypeToIcon } from './NotificationSinksColumns' -import { getSinkTypeForWebhookUrl } from './notificationSinkUrl.ts' + +const slackHookUrlRegex = /^https:\/\/[^/]*?slack/ type ModalBaseProps = { mode: 'edit' | 'create' @@ -35,6 +37,7 @@ function UpsertNotificationSinkModal({ }: ModalProps) { const sink = mode === 'edit' ? props.sink : undefined const sinkName = sink?.name + const sinkType = sink?.type const slackUrl = sink?.configuration.slack?.url const teamsUrl = sink?.configuration.teams?.url const theme = useTheme() @@ -49,13 +52,18 @@ function UpsertNotificationSinkModal({ } : {}), }), - [mode, slackUrl, sinkName, teamsUrl] + [mode, sinkName, slackUrl, teamsUrl] ) const { state, update, hasUpdates } = useUpdateState<{ name: string hookUrl: string }>(initialState) - const hookType = getSinkTypeForWebhookUrl(state.hookUrl) + const hookType = + mode === 'edit' && sinkType + ? sinkType + : slackHookUrlRegex.test(state.hookUrl) + ? SinkType.Slack + : SinkType.Teams const [mutation, { loading }] = useUpsertNotificationSinkMutation({ onCompleted: () => onClose?.(), @@ -71,7 +79,7 @@ function UpsertNotificationSinkModal({ }), }) - const allowSubmit = hookType && state.name && state.hookUrl && hasUpdates + const allowSubmit = state.name && state.hookUrl && hasUpdates const onSubmit = useCallback( (e: FormEvent) => { @@ -168,7 +176,7 @@ function UpsertNotificationSinkModal({ )} update({ hookUrl: e.target.value })} placeholder="https://hooks.provider.com/..." css={{ flex: '1 1 100%' }} diff --git a/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts b/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts deleted file mode 100644 index ce4e62e350..0000000000 --- a/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { SinkType } from 'generated/graphql' - -import { getSinkTypeForWebhookUrl } from './notificationSinkUrl' - -describe('getSinkTypeForWebhookUrl', () => { - it('matches standard Slack incoming webhook URLs', () => { - expect( - getSinkTypeForWebhookUrl( - 'https://hooks.slack.com/services/T00000000/B00000000/test' - ) - ).toBe(SinkType.Slack) - }) - - it('matches GovSlack incoming webhook URLs', () => { - expect( - getSinkTypeForWebhookUrl( - 'https://hooks.slack-gov.com/services/T00000000/B00000000/test' - ) - ).toBe(SinkType.Slack) - }) - - it('rejects userinfo hostname spoofing', () => { - expect( - getSinkTypeForWebhookUrl( - 'https://hooks.slack.com@example.com/services/T00000000/B00000000/test' - ) - ).toBeUndefined() - expect( - getSinkTypeForWebhookUrl( - 'https://environment.api.powerplatform.com@example.com/workflows/test' - ) - ).toBeUndefined() - }) - - it('rejects suffix hostname spoofing', () => { - expect( - getSinkTypeForWebhookUrl( - 'https://hooks.slack.com.example.com/services/T00000000/B00000000/test' - ) - ).toBeUndefined() - expect( - getSinkTypeForWebhookUrl( - 'https://hooks.slack-gov.com.example.com/services/T00000000/B00000000/test' - ) - ).toBeUndefined() - }) - - it('rejects URLs with invalid ports', () => { - expect( - getSinkTypeForWebhookUrl( - 'https://hooks.slack.com:99999/services/T00000000/B00000000/test' - ) - ).toBeUndefined() - }) -}) diff --git a/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts b/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts deleted file mode 100644 index fe1eed1cfb..0000000000 --- a/js/console/src/components/settings/notifications/sinks/notificationSinkUrl.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { SinkType } from 'generated/graphql' - -import { isValidURL } from 'utils/url' - -const slackWebhookHosts = ['slack.com', 'slack-gov.com'] - -const teamsWebhookHosts = [ - 'office.com', - 'office365.com', - 'powerautomate.com', - 'powerplatform.com', - 'logic.azure.com', -] - -function matchesWebhookHost(url: string, hosts: string[]) { - if (!isValidURL(url) || !/^https:\/\//i.test(url)) { - return false - } - - let parsedUrl: URL - - try { - parsedUrl = new URL(url) - } catch { - return false - } - - return ( - parsedUrl.protocol === 'https:' && - hosts.some( - (host) => - parsedUrl.hostname === host || parsedUrl.hostname.endsWith(`.${host}`) - ) - ) -} - -const hookUrlMatch = [ - [SinkType.Slack, (url: string) => matchesWebhookHost(url, slackWebhookHosts)], - [SinkType.Teams, (url: string) => matchesWebhookHost(url, teamsWebhookHosts)], -] as const satisfies [SinkType, (url: string) => boolean][] - -export function getSinkTypeForWebhookUrl(url: string) { - return hookUrlMatch.find(([_, matches]) => matches(url))?.[0] -} diff --git a/lib/console/schema/notification_sink.ex b/lib/console/schema/notification_sink.ex index 5765c52333..768a513e27 100644 --- a/lib/console/schema/notification_sink.ex +++ b/lib/console/schema/notification_sink.ex @@ -44,6 +44,8 @@ defmodule Console.Schema.NotificationSink do end @valid ~w(type name)a + @slack_webhook_hosts ~w(slack.com slack-gov.com) + @teams_webhook_hosts ~w(office.com office365.com powerautomate.com powerplatform.com logic.azure.com) def changeset(model, attrs \\ %{}) do model @@ -57,8 +59,8 @@ defmodule Console.Schema.NotificationSink do defp config_changeset(model, attrs) do model |> cast(attrs, []) - |> cast_embed(:slack, with: &url_changeset/2) - |> cast_embed(:teams, with: &url_changeset/2) + |> cast_embed(:slack, with: &slack_changeset/2) + |> cast_embed(:teams, with: &teams_changeset/2) |> cast_embed(:plural, with: &plural_changeset/2) end @@ -68,6 +70,52 @@ defmodule Console.Schema.NotificationSink do |> validate_required([:url]) end + defp slack_changeset(model, attrs) do + model + |> url_changeset(attrs) + |> validate_change( + :url, + &validate_webhook_url(&1, &2, @slack_webhook_hosts, "must be a valid Slack webhook URL") + ) + end + + defp teams_changeset(model, attrs) do + model + |> url_changeset(attrs) + |> validate_change( + :url, + &validate_webhook_url( + &1, + &2, + @teams_webhook_hosts, + "must be a valid Microsoft Teams webhook URL" + ) + ) + end + + defp validate_webhook_url(:url, url, hosts, message) when is_binary(url) do + with {:ok, %URI{scheme: "https", host: host, userinfo: nil} = uri} when is_binary(host) <- + URI.new(url), + true <- valid_port?(uri.port), + true <- host_matches?(host, hosts) do + [] + else + _ -> [url: message] + end + end + + defp validate_webhook_url(:url, _, _, message), do: [url: message] + + defp valid_port?(port) when is_integer(port), do: port in 1..65_535 + defp valid_port?(nil), do: true + defp valid_port?(_), do: false + + defp host_matches?(host, hosts) do + host = String.downcase(host) + + Enum.any?(hosts, &(host == &1 || String.ends_with?(host, ".#{&1}"))) + end + defp plural_changeset(model, attrs) do model |> cast(attrs, ~w(priority urgent)a) From c005feaeaea77a060b94b0a945b867fa1bf6e3a3 Mon Sep 17 00:00:00 2001 From: JohnBlackwell Date: Tue, 8 Sep 2026 09:21:32 -0400 Subject: [PATCH 5/5] render error to user --- .../notifications/sinks/UpsertNotificationSinkModal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx index 8868fa9ad6..edc7593708 100644 --- a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx +++ b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx @@ -1,6 +1,7 @@ import { ComponentProps, FormEvent, useCallback, useMemo } from 'react' import { Button, FormField, Input2, Modal } from '@pluralsh/design-system' +import { GqlError } from 'components/utils/Alert' import { ModalMountTransition } from 'components/utils/ModalMountTransition' import { Body2P } from 'components/utils/typography/Text' import { useTheme } from 'styled-components' @@ -65,7 +66,7 @@ function UpsertNotificationSinkModal({ ? SinkType.Slack : SinkType.Teams - const [mutation, { loading }] = useUpsertNotificationSinkMutation({ + const [mutation, { loading, error }] = useUpsertNotificationSinkMutation({ onCompleted: () => onClose?.(), update: (cache, { data }) => updateCache(cache, { @@ -164,6 +165,7 @@ function UpsertNotificationSinkModal({ {' '} webhook url to send this event alert to your team. + {error && }
{mode !== 'edit' && (