From ddb885ee6a096d888a3dff129eb57c7a107c4564 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:48:12 +0300 Subject: [PATCH 1/7] Enhance notification system with webhook support --- package.json | 4 ++-- src/rabbitmq.ts | 9 +++++++++ src/typeDefs/notifications.ts | 5 +++++ src/typeDefs/notificationsInput.ts | 5 +++++ src/utils/personalNotifications.ts | 10 ++++++++++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 52feab40..3ab0a13c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.4.6", + "version": "1.4.7", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -42,7 +42,7 @@ "@graphql-tools/schema": "^8.5.1", "@graphql-tools/utils": "^8.9.0", "@hawk.so/nodejs": "^3.3.1", - "@hawk.so/types": "^0.5.8", + "@hawk.so/types": "^0.5.9", "@n1ru4l/json-patch-plus": "^0.2.0", "@node-saml/node-saml": "^5.0.1", "@octokit/oauth-methods": "^4.0.0", diff --git a/src/rabbitmq.ts b/src/rabbitmq.ts index c4c7e7db..6c437837 100644 --- a/src/rabbitmq.ts +++ b/src/rabbitmq.ts @@ -28,6 +28,7 @@ export enum Queues { Telegram = 'notify/telegram', Slack = 'notify/slack', Loop = 'notify/loop', + Webhook = 'sender/webhook', Limiter = 'cron-tasks/limiter', } @@ -90,6 +91,14 @@ export const WorkerPaths: Record = { queue: Queues.Loop, }, + /** + * Path to webhook worker + */ + Webhook: { + exchange: Exchanges.Empty, + queue: Queues.Webhook, + }, + /** * Path to limiter worker */ diff --git a/src/typeDefs/notifications.ts b/src/typeDefs/notifications.ts index a923b26b..0af34fc7 100644 --- a/src/typeDefs/notifications.ts +++ b/src/typeDefs/notifications.ts @@ -49,6 +49,11 @@ export default gql` """ loop: NotificationsChannelSettings + """ + Webhook channel + """ + webhook: NotificationsChannelSettings + """ Webpush """ diff --git a/src/typeDefs/notificationsInput.ts b/src/typeDefs/notificationsInput.ts index fb9aa835..5f124f01 100644 --- a/src/typeDefs/notificationsInput.ts +++ b/src/typeDefs/notificationsInput.ts @@ -45,6 +45,11 @@ export default gql` """ loop: NotificationsChannelSettingsInput + """ + Webhook channel + """ + webhook: NotificationsChannelSettingsInput + """ Web push """ diff --git a/src/utils/personalNotifications.ts b/src/utils/personalNotifications.ts index 9b3b7339..60dce817 100644 --- a/src/utils/personalNotifications.ts +++ b/src/utils/personalNotifications.ts @@ -52,4 +52,14 @@ export default async function sendNotification(user: UserDBScheme, task: SenderW }, }); } + + if (user.notifications.channels.webhook?.isEnabled) { + await enqueue(WorkerPaths.Webhook, { + type: task.type, + payload: { + ...task.payload, + endpoint: user.notifications.channels.webhook.endpoint, + }, + }); + } } From 04664298174ce7ad95c41001c72988e855eb717f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:36:38 +0300 Subject: [PATCH 2/7] Implement webhook validation for project and user notifications --- src/resolvers/projectNotifications.ts | 26 +++ src/resolvers/userNotifications.ts | 10 ++ src/utils/webhookEndpointValidator.ts | 123 +++++++++++++ test/utils/webhookEndpointValidator.test.ts | 181 ++++++++++++++++++++ 4 files changed, 340 insertions(+) create mode 100644 src/utils/webhookEndpointValidator.ts create mode 100644 test/utils/webhookEndpointValidator.test.ts diff --git a/src/resolvers/projectNotifications.ts b/src/resolvers/projectNotifications.ts index af8e0cec..608ac5a9 100644 --- a/src/resolvers/projectNotifications.ts +++ b/src/resolvers/projectNotifications.ts @@ -5,6 +5,7 @@ import { ProjectNotificationsRuleDBScheme } from '@hawk.so/types'; import { ResolverContextWithUser } from '../types/graphql'; import { ApolloError, UserInputError } from 'apollo-server-express'; import { NotificationsChannelsDBScheme } from '../types/notification-channels'; +import { validateWebhookEndpoint } from '../utils/webhookEndpointValidator'; /** * Mutation payload for creating notifications rule from GraphQL Schema @@ -129,6 +130,19 @@ function validateNotificationsRuleChannels(channels: NotificationsChannelsDBSche return null; } +/** + * Validates webhook endpoint for SSRF safety (async DNS check) + * + * @param channels - notification channels to validate + */ +async function validateWebhookChannel(channels: NotificationsChannelsDBScheme): Promise { + if (channels.webhook?.isEnabled && channels.webhook.endpoint) { + return validateWebhookEndpoint(channels.webhook.endpoint); + } + + return null; +} + /** * See all types and fields here {@see ../typeDefs/notify.graphql} */ @@ -158,6 +172,12 @@ export default { throw new UserInputError(channelsValidationResult); } + const webhookValidationResult = await validateWebhookChannel(input.channels); + + if (webhookValidationResult !== null) { + throw new UserInputError(webhookValidationResult); + } + if (input.whatToReceive === ReceiveTypes.SEEN_MORE) { const thresholdValidationResult = validateNotificationsRuleTresholdAndPeriod(input.threshold, input.thresholdPeriod); @@ -196,6 +216,12 @@ export default { throw new UserInputError(channelsValidationResult); } + const webhookValidationResult = await validateWebhookChannel(input.channels); + + if (webhookValidationResult !== null) { + throw new UserInputError(webhookValidationResult); + } + if (input.whatToReceive === ReceiveTypes.SEEN_MORE) { const thresholdValidationResult = validateNotificationsRuleTresholdAndPeriod(input.threshold, input.thresholdPeriod); diff --git a/src/resolvers/userNotifications.ts b/src/resolvers/userNotifications.ts index a98d3148..faa03aed 100644 --- a/src/resolvers/userNotifications.ts +++ b/src/resolvers/userNotifications.ts @@ -2,6 +2,8 @@ import { ResolverContextWithUser } from '../types/graphql'; import { UserNotificationsDBScheme, UserNotificationType } from '../models/user'; import { NotificationsChannelsDBScheme } from '../types/notification-channels'; import { UserDBScheme } from '@hawk.so/types'; +import { UserInputError } from 'apollo-server-express'; +import { validateWebhookEndpoint } from '../utils/webhookEndpointValidator'; /** * We will get this structure from the client to update Channel settings @@ -45,6 +47,14 @@ export default { { input }: ChangeUserNotificationsChannelPayload, { user, factories }: ResolverContextWithUser ): Promise { + if (input.webhook?.isEnabled && input.webhook.endpoint) { + const webhookError = await validateWebhookEndpoint(input.webhook.endpoint); + + if (webhookError !== null) { + throw new UserInputError(webhookError); + } + } + const currentUser = await factories.usersFactory.findById(user.id); const currentNotifySet = currentUser?.notifications || {} as UserNotificationsDBScheme; const oldChannels = currentNotifySet.channels || {}; diff --git a/src/utils/webhookEndpointValidator.ts b/src/utils/webhookEndpointValidator.ts new file mode 100644 index 00000000..9e297aea --- /dev/null +++ b/src/utils/webhookEndpointValidator.ts @@ -0,0 +1,123 @@ +import dns from 'dns'; + +/** + * Regex patterns matching private/reserved IP ranges: + * + * IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918), + * 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598), + * 255.255.255.255 (broadcast), 224-239.x (multicast), + * 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking). + * + * IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast). + * + * Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0). + */ +const PRIVATE_IP_PATTERNS: RegExp[] = [ + /^0\./, + /^10\./, + /^127\./, + /^169\.254\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, + /^255\.255\.255\.255$/, + /^2(2[4-9]|3\d)\./, + /^192\.0\.2\./, + /^198\.51\.100\./, + /^203\.0\.113\./, + /^198\.1[89]\./, + /^::1$/, + /^::$/, + /^fe80/i, + /^f[cd]/i, + /^ff[0-9a-f]{2}:/i, + /^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/i, +]; + +/** + * Hostnames blocked regardless of DNS resolution + */ +const BLOCKED_HOSTNAMES: RegExp[] = [ + /^localhost$/i, + /\.local$/i, + /\.internal$/i, + /\.lan$/i, + /\.localdomain$/i, +]; + +/** + * Only these ports are allowed for webhook delivery + */ +const ALLOWED_PORTS: Record = { + 'http:': 80, + 'https:': 443, +}; + +/** + * Checks whether an IP address belongs to a private/reserved range. + * Strips zone ID before matching (e.g. fe80::1%lo0). + * + * @param ip - IP address string (v4 or v6) + */ +export function isPrivateIP(ip: string): boolean { + const bare = ip.split('%')[0]; + + return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare)); +} + +/** + * Validates a webhook endpoint URL for SSRF safety. + * Returns null if valid, or an error message string if invalid. + * + * Checks: + * - Protocol whitelist (http/https) + * - Port whitelist (80/443) + * - Hostname blocklist (localhost, *.local, etc.) + * - Private IP in URL + * - DNS resolution — all A/AAAA records must be public + * + * @param endpoint - webhook URL to validate + */ +export async function validateWebhookEndpoint(endpoint: string): Promise { + let url: URL; + + try { + url = new URL(endpoint); + } catch { + return 'Invalid webhook URL'; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return 'Webhook URL must use http or https protocol'; + } + + const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol]; + + if (requestedPort !== ALLOWED_PORTS[url.protocol]) { + return `Webhook URL port ${requestedPort} is not allowed — only 80 (http) and 443 (https)`; + } + + const hostname = url.hostname; + + if (BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname))) { + return `Webhook hostname "${hostname}" is not allowed`; + } + + if (isPrivateIP(hostname)) { + return 'Webhook URL points to a private/reserved IP address'; + } + + try { + const results = await dns.promises.lookup(hostname, { all: true }); + + for (const { address } of results) { + if (isPrivateIP(address)) { + return `Webhook hostname resolves to a private IP address (${address})`; + } + } + } catch { + return `Cannot resolve webhook hostname "${hostname}"`; + } + + return null; +} diff --git a/test/utils/webhookEndpointValidator.test.ts b/test/utils/webhookEndpointValidator.test.ts new file mode 100644 index 00000000..5b29cd52 --- /dev/null +++ b/test/utils/webhookEndpointValidator.test.ts @@ -0,0 +1,181 @@ +import { isPrivateIP, validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator'; + +describe('isPrivateIP', () => { + describe('should block private/reserved IPv4', () => { + it.each([ + ['127.0.0.1'], + ['127.255.255.255'], + ['10.0.0.1'], + ['10.255.255.255'], + ['0.0.0.0'], + ['172.16.0.1'], + ['172.31.255.255'], + ['192.168.0.1'], + ['192.168.255.255'], + ['169.254.1.1'], + ['169.254.169.254'], + ['100.64.0.1'], + ['100.127.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block broadcast and multicast IPv4', () => { + it.each([ + ['255.255.255.255'], + ['224.0.0.1'], + ['239.255.255.255'], + ['230.1.2.3'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block documentation and benchmarking IPv4', () => { + it.each([ + ['192.0.2.1'], + ['198.51.100.1'], + ['203.0.113.1'], + ['198.18.0.1'], + ['198.19.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block private/reserved IPv6', () => { + it.each([ + ['::1'], + ['::'], + ['fe80::1'], + ['FE80::abc'], + ['fc00::1'], + ['fd12:3456::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv6 multicast', () => { + it.each([ + ['ff02::1'], + ['ff05::2'], + ['FF0E::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv6 with zone ID', () => { + it.each([ + ['fe80::1%lo0'], + ['fe80::1%eth0'], + ['::1%lo0'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv4-mapped IPv6', () => { + it.each([ + ['::ffff:127.0.0.1'], + ['::ffff:10.0.0.1'], + ['::ffff:192.168.1.1'], + ['::ffff:172.16.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:100.64.0.1'], + ['::ffff:0.0.0.0'], + ['::FFFF:127.0.0.1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should allow public IPv4', () => { + it.each([ + ['8.8.8.8'], + ['1.1.1.1'], + ['93.184.216.34'], + ['172.32.0.1'], + ['172.15.255.255'], + ['192.169.0.1'], + ['100.128.0.1'], + ['100.63.255.255'], + ['169.255.0.1'], + ['223.255.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); + + describe('should allow public IPv6', () => { + it.each([ + ['2001:db8::1'], + ['2606:4700::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); + + describe('should allow public IPv4-mapped IPv6', () => { + it.each([ + ['::ffff:8.8.8.8'], + ['::ffff:93.184.216.34'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); +}); + +describe('validateWebhookEndpoint', () => { + it('should reject invalid URL', async () => { + expect(await validateWebhookEndpoint('not-a-url')).toBe('Invalid webhook URL'); + }); + + it('should reject ftp protocol', async () => { + expect(await validateWebhookEndpoint('ftp://example.com/hook')).toBe('Webhook URL must use http or https protocol'); + }); + + it('should reject non-standard ports', async () => { + const result = await validateWebhookEndpoint('https://example.com:8080/hook'); + + expect(result).toMatch(/port.*not allowed/); + }); + + it('should reject localhost', async () => { + const result = await validateWebhookEndpoint('http://localhost/hook'); + + expect(result).toMatch(/not allowed/); + }); + + it('should reject .local hostnames', async () => { + const result = await validateWebhookEndpoint('http://myapp.local/hook'); + + expect(result).toMatch(/not allowed/); + }); + + it('should reject private IP in URL', async () => { + const result = await validateWebhookEndpoint('http://127.0.0.1/hook'); + + expect(result).toMatch(/private/i); + }); + + it('should reject 169.254.169.254 (metadata)', async () => { + const result = await validateWebhookEndpoint('http://169.254.169.254/latest/meta-data'); + + expect(result).toMatch(/private/i); + }); + + it('should accept valid public https URL', async () => { + const result = await validateWebhookEndpoint('https://example.com/hawk-webhook'); + + expect(result).toBeNull(); + }); + + it('should accept valid public http URL on port 80', async () => { + const result = await validateWebhookEndpoint('http://example.com/hawk-webhook'); + + expect(result).toBeNull(); + }); +}); From 9a7ddf595081070f94f41ba7886ce1c8f75a6e79 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:50:36 +0300 Subject: [PATCH 3/7] Update yarn.lock to include @hawk.so/types@0.5.9 and add webhook property to NotificationsChannelsDBScheme interface --- src/types/notification-channels.d.ts | 5 +++++ yarn.lock | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/src/types/notification-channels.d.ts b/src/types/notification-channels.d.ts index 2e751e00..2ceaecd6 100644 --- a/src/types/notification-channels.d.ts +++ b/src/types/notification-channels.d.ts @@ -36,6 +36,11 @@ export interface NotificationsChannelsDBScheme { * Pushes through the Hawk Desktop app */ desktopPush?: NotificationsChannelSettingsDBScheme; + + /** + * Alerts through a custom Webhook URL + */ + webhook?: NotificationsChannelSettingsDBScheme; } /** diff --git a/yarn.lock b/yarn.lock index 490bd0dd..bfe5bde9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -510,6 +510,13 @@ dependencies: bson "^7.0.0" +"@hawk.so/types@^0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.9.tgz#817e8b26283d0367371125f055f2e37a274797bc" + integrity sha512-86aE0Bdzvy8C+Dqd1iZpnDho44zLGX/t92SGuAv2Q52gjSJ7SHQdpGDWtM91FXncfT5uzAizl9jYMuE6Qrtm0Q== + dependencies: + bson "^7.0.0" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" From 38ebc9bb46088dd46b574c058d15da1ed8d7c145 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:02:06 +0300 Subject: [PATCH 4/7] Refactor webhook endpoint validation by removing isPrivateIP function and related tests. Integrate private IP validation into ipValidator module for improved code organization. --- src/utils/ipValidator.ts | 45 +++++++ src/utils/webhookEndpointValidator.ts | 47 +------ test/utils/ipValidator.test.ts | 129 +++++++++++++++++++ test/utils/webhookEndpointValidator.test.ts | 130 +------------------- 4 files changed, 176 insertions(+), 175 deletions(-) create mode 100644 src/utils/ipValidator.ts create mode 100644 test/utils/ipValidator.test.ts diff --git a/src/utils/ipValidator.ts b/src/utils/ipValidator.ts new file mode 100644 index 00000000..ecb13940 --- /dev/null +++ b/src/utils/ipValidator.ts @@ -0,0 +1,45 @@ +/** + * Regex patterns matching private/reserved IP ranges: + * + * IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918), + * 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598), + * 255.255.255.255 (broadcast), 224-239.x (multicast), + * 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking). + * + * IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast). + * + * Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0). + */ +const PRIVATE_IP_PATTERNS: RegExp[] = [ + /^0\./, + /^10\./, + /^127\./, + /^169\.254\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^192\.168\./, + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, + /^255\.255\.255\.255$/, + /^2(2[4-9]|3\d)\./, + /^192\.0\.2\./, + /^198\.51\.100\./, + /^203\.0\.113\./, + /^198\.1[89]\./, + /^::1$/, + /^::$/, + /^fe80/i, + /^f[cd]/i, + /^ff[0-9a-f]{2}:/i, + /^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/i, +]; + +/** + * Checks whether an IP address belongs to a private/reserved range. + * Strips zone ID before matching (e.g. fe80::1%lo0). + * + * @param ip - IP address string (v4 or v6) + */ +export function isPrivateIP(ip: string): boolean { + const bare = ip.split('%')[0]; + + return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare)); +} diff --git a/src/utils/webhookEndpointValidator.ts b/src/utils/webhookEndpointValidator.ts index 9e297aea..c7e455d0 100644 --- a/src/utils/webhookEndpointValidator.ts +++ b/src/utils/webhookEndpointValidator.ts @@ -1,38 +1,5 @@ import dns from 'dns'; - -/** - * Regex patterns matching private/reserved IP ranges: - * - * IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918), - * 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598), - * 255.255.255.255 (broadcast), 224-239.x (multicast), - * 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking). - * - * IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast). - * - * Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0). - */ -const PRIVATE_IP_PATTERNS: RegExp[] = [ - /^0\./, - /^10\./, - /^127\./, - /^169\.254\./, - /^172\.(1[6-9]|2\d|3[01])\./, - /^192\.168\./, - /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, - /^255\.255\.255\.255$/, - /^2(2[4-9]|3\d)\./, - /^192\.0\.2\./, - /^198\.51\.100\./, - /^203\.0\.113\./, - /^198\.1[89]\./, - /^::1$/, - /^::$/, - /^fe80/i, - /^f[cd]/i, - /^ff[0-9a-f]{2}:/i, - /^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/i, -]; +import { isPrivateIP } from './ipValidator'; /** * Hostnames blocked regardless of DNS resolution @@ -53,18 +20,6 @@ const ALLOWED_PORTS: Record = { 'https:': 443, }; -/** - * Checks whether an IP address belongs to a private/reserved range. - * Strips zone ID before matching (e.g. fe80::1%lo0). - * - * @param ip - IP address string (v4 or v6) - */ -export function isPrivateIP(ip: string): boolean { - const bare = ip.split('%')[0]; - - return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare)); -} - /** * Validates a webhook endpoint URL for SSRF safety. * Returns null if valid, or an error message string if invalid. diff --git a/test/utils/ipValidator.test.ts b/test/utils/ipValidator.test.ts new file mode 100644 index 00000000..1328928d --- /dev/null +++ b/test/utils/ipValidator.test.ts @@ -0,0 +1,129 @@ +import { isPrivateIP } from '../../src/utils/ipValidator'; + +describe('isPrivateIP', () => { + describe('should block private/reserved IPv4', () => { + it.each([ + ['127.0.0.1'], + ['127.255.255.255'], + ['10.0.0.1'], + ['10.255.255.255'], + ['0.0.0.0'], + ['172.16.0.1'], + ['172.31.255.255'], + ['192.168.0.1'], + ['192.168.255.255'], + ['169.254.1.1'], + ['169.254.169.254'], + ['100.64.0.1'], + ['100.127.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block broadcast and multicast IPv4', () => { + it.each([ + ['255.255.255.255'], + ['224.0.0.1'], + ['239.255.255.255'], + ['230.1.2.3'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block documentation and benchmarking IPv4', () => { + it.each([ + ['192.0.2.1'], + ['198.51.100.1'], + ['203.0.113.1'], + ['198.18.0.1'], + ['198.19.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block private/reserved IPv6', () => { + it.each([ + ['::1'], + ['::'], + ['fe80::1'], + ['FE80::abc'], + ['fc00::1'], + ['fd12:3456::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv6 multicast', () => { + it.each([ + ['ff02::1'], + ['ff05::2'], + ['FF0E::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv6 with zone ID', () => { + it.each([ + ['fe80::1%lo0'], + ['fe80::1%eth0'], + ['::1%lo0'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should block IPv4-mapped IPv6', () => { + it.each([ + ['::ffff:127.0.0.1'], + ['::ffff:10.0.0.1'], + ['::ffff:192.168.1.1'], + ['::ffff:172.16.0.1'], + ['::ffff:169.254.169.254'], + ['::ffff:100.64.0.1'], + ['::ffff:0.0.0.0'], + ['::FFFF:127.0.0.1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + }); + + describe('should allow public IPv4', () => { + it.each([ + ['8.8.8.8'], + ['1.1.1.1'], + ['93.184.216.34'], + ['172.32.0.1'], + ['172.15.255.255'], + ['192.169.0.1'], + ['100.128.0.1'], + ['100.63.255.255'], + ['169.255.0.1'], + ['223.255.255.255'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); + + describe('should allow public IPv6', () => { + it.each([ + ['2001:db8::1'], + ['2606:4700::1'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); + + describe('should allow public IPv4-mapped IPv6', () => { + it.each([ + ['::ffff:8.8.8.8'], + ['::ffff:93.184.216.34'], + ])('%s', (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + }); +}); diff --git a/test/utils/webhookEndpointValidator.test.ts b/test/utils/webhookEndpointValidator.test.ts index 5b29cd52..f54f8126 100644 --- a/test/utils/webhookEndpointValidator.test.ts +++ b/test/utils/webhookEndpointValidator.test.ts @@ -1,132 +1,4 @@ -import { isPrivateIP, validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator'; - -describe('isPrivateIP', () => { - describe('should block private/reserved IPv4', () => { - it.each([ - ['127.0.0.1'], - ['127.255.255.255'], - ['10.0.0.1'], - ['10.255.255.255'], - ['0.0.0.0'], - ['172.16.0.1'], - ['172.31.255.255'], - ['192.168.0.1'], - ['192.168.255.255'], - ['169.254.1.1'], - ['169.254.169.254'], - ['100.64.0.1'], - ['100.127.255.255'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block broadcast and multicast IPv4', () => { - it.each([ - ['255.255.255.255'], - ['224.0.0.1'], - ['239.255.255.255'], - ['230.1.2.3'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block documentation and benchmarking IPv4', () => { - it.each([ - ['192.0.2.1'], - ['198.51.100.1'], - ['203.0.113.1'], - ['198.18.0.1'], - ['198.19.255.255'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block private/reserved IPv6', () => { - it.each([ - ['::1'], - ['::'], - ['fe80::1'], - ['FE80::abc'], - ['fc00::1'], - ['fd12:3456::1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block IPv6 multicast', () => { - it.each([ - ['ff02::1'], - ['ff05::2'], - ['FF0E::1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block IPv6 with zone ID', () => { - it.each([ - ['fe80::1%lo0'], - ['fe80::1%eth0'], - ['::1%lo0'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should block IPv4-mapped IPv6', () => { - it.each([ - ['::ffff:127.0.0.1'], - ['::ffff:10.0.0.1'], - ['::ffff:192.168.1.1'], - ['::ffff:172.16.0.1'], - ['::ffff:169.254.169.254'], - ['::ffff:100.64.0.1'], - ['::ffff:0.0.0.0'], - ['::FFFF:127.0.0.1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(true); - }); - }); - - describe('should allow public IPv4', () => { - it.each([ - ['8.8.8.8'], - ['1.1.1.1'], - ['93.184.216.34'], - ['172.32.0.1'], - ['172.15.255.255'], - ['192.169.0.1'], - ['100.128.0.1'], - ['100.63.255.255'], - ['169.255.0.1'], - ['223.255.255.255'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(false); - }); - }); - - describe('should allow public IPv6', () => { - it.each([ - ['2001:db8::1'], - ['2606:4700::1'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(false); - }); - }); - - describe('should allow public IPv4-mapped IPv6', () => { - it.each([ - ['::ffff:8.8.8.8'], - ['::ffff:93.184.216.34'], - ])('%s', (ip) => { - expect(isPrivateIP(ip)).toBe(false); - }); - }); -}); +import { validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator'; describe('validateWebhookEndpoint', () => { it('should reject invalid URL', async () => { From 9b2935eeace29311ea81159021382bcf802ff328 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:58:54 +0300 Subject: [PATCH 5/7] Refactor notification channel validation to use async functions for improved error handling. Update validation logic to await results from channel checks, enhancing overall reliability. --- src/resolvers/projectNotifications.ts | 33 +++++++-------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/src/resolvers/projectNotifications.ts b/src/resolvers/projectNotifications.ts index 608ac5a9..04651f91 100644 --- a/src/resolvers/projectNotifications.ts +++ b/src/resolvers/projectNotifications.ts @@ -102,7 +102,7 @@ function validateNotificationsRuleTresholdAndPeriod( /** * Return true if all passed channels are filled with correct endpoints */ -function validateNotificationsRuleChannels(channels: NotificationsChannelsDBScheme): string | null { +async function validateNotificationsRuleChannels(channels: NotificationsChannelsDBScheme): Promise { if (channels.email!.isEnabled) { if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(channels.email!.endpoint)) { return 'Invalid email endpoint passed'; @@ -127,17 +127,12 @@ function validateNotificationsRuleChannels(channels: NotificationsChannelsDBSche } } - return null; -} - -/** - * Validates webhook endpoint for SSRF safety (async DNS check) - * - * @param channels - notification channels to validate - */ -async function validateWebhookChannel(channels: NotificationsChannelsDBScheme): Promise { if (channels.webhook?.isEnabled && channels.webhook.endpoint) { - return validateWebhookEndpoint(channels.webhook.endpoint); + const webhookError = await validateWebhookEndpoint(channels.webhook.endpoint); + + if (webhookError !== null) { + return webhookError; + } } return null; @@ -166,18 +161,12 @@ export default { throw new ApolloError('No project with such id'); } - const channelsValidationResult = validateNotificationsRuleChannels(input.channels); + const channelsValidationResult = await validateNotificationsRuleChannels(input.channels); if (channelsValidationResult !== null) { throw new UserInputError(channelsValidationResult); } - const webhookValidationResult = await validateWebhookChannel(input.channels); - - if (webhookValidationResult !== null) { - throw new UserInputError(webhookValidationResult); - } - if (input.whatToReceive === ReceiveTypes.SEEN_MORE) { const thresholdValidationResult = validateNotificationsRuleTresholdAndPeriod(input.threshold, input.thresholdPeriod); @@ -210,18 +199,12 @@ export default { throw new ApolloError('No project with such id'); } - const channelsValidationResult = validateNotificationsRuleChannels(input.channels); + const channelsValidationResult = await validateNotificationsRuleChannels(input.channels); if (channelsValidationResult !== null) { throw new UserInputError(channelsValidationResult); } - const webhookValidationResult = await validateWebhookChannel(input.channels); - - if (webhookValidationResult !== null) { - throw new UserInputError(webhookValidationResult); - } - if (input.whatToReceive === ReceiveTypes.SEEN_MORE) { const thresholdValidationResult = validateNotificationsRuleTresholdAndPeriod(input.threshold, input.thresholdPeriod); From 8f5d1402ecdec8efd42cb051f617117a0a250b77 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:02:01 +0300 Subject: [PATCH 6/7] Refactor webhook endpoint validation to utilize BLOCKED_HOSTNAMES and ALLOWED_PORTS constants from ipValidator module, improving code organization and maintainability. --- src/utils/ipValidator.ts | 19 +++++++++++++++++++ src/utils/webhookEndpointValidator.ts | 21 +-------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/utils/ipValidator.ts b/src/utils/ipValidator.ts index ecb13940..7bceb476 100644 --- a/src/utils/ipValidator.ts +++ b/src/utils/ipValidator.ts @@ -43,3 +43,22 @@ export function isPrivateIP(ip: string): boolean { return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare)); } + +/** + * Hostnames blocked regardless of DNS resolution + */ +export const BLOCKED_HOSTNAMES: RegExp[] = [ + /^localhost$/i, + /\.local$/i, + /\.internal$/i, + /\.lan$/i, + /\.localdomain$/i, +]; + +/** + * Only these ports are allowed for webhook delivery + */ +export const ALLOWED_PORTS: Record = { + 'http:': 80, + 'https:': 443, +}; diff --git a/src/utils/webhookEndpointValidator.ts b/src/utils/webhookEndpointValidator.ts index c7e455d0..084ffe33 100644 --- a/src/utils/webhookEndpointValidator.ts +++ b/src/utils/webhookEndpointValidator.ts @@ -1,24 +1,5 @@ import dns from 'dns'; -import { isPrivateIP } from './ipValidator'; - -/** - * Hostnames blocked regardless of DNS resolution - */ -const BLOCKED_HOSTNAMES: RegExp[] = [ - /^localhost$/i, - /\.local$/i, - /\.internal$/i, - /\.lan$/i, - /\.localdomain$/i, -]; - -/** - * Only these ports are allowed for webhook delivery - */ -const ALLOWED_PORTS: Record = { - 'http:': 80, - 'https:': 443, -}; +import { isPrivateIP, BLOCKED_HOSTNAMES, ALLOWED_PORTS } from './ipValidator'; /** * Validates a webhook endpoint URL for SSRF safety. From 00d5febcbf3001c522748fff4a172ae7b4215770 Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:03:28 +0300 Subject: [PATCH 7/7] Refactor webhook validation logic in project and user notifications to remove endpoint checks when isEnabled is true, streamlining the validation process. --- src/resolvers/projectNotifications.ts | 2 +- src/resolvers/userNotifications.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resolvers/projectNotifications.ts b/src/resolvers/projectNotifications.ts index 04651f91..508be6fa 100644 --- a/src/resolvers/projectNotifications.ts +++ b/src/resolvers/projectNotifications.ts @@ -127,7 +127,7 @@ async function validateNotificationsRuleChannels(channels: NotificationsChannels } } - if (channels.webhook?.isEnabled && channels.webhook.endpoint) { + if (channels.webhook?.isEnabled) { const webhookError = await validateWebhookEndpoint(channels.webhook.endpoint); if (webhookError !== null) { diff --git a/src/resolvers/userNotifications.ts b/src/resolvers/userNotifications.ts index faa03aed..662562c9 100644 --- a/src/resolvers/userNotifications.ts +++ b/src/resolvers/userNotifications.ts @@ -47,7 +47,7 @@ export default { { input }: ChangeUserNotificationsChannelPayload, { user, factories }: ResolverContextWithUser ): Promise { - if (input.webhook?.isEnabled && input.webhook.endpoint) { + if (input.webhook?.isEnabled) { const webhookError = await validateWebhookEndpoint(input.webhook.endpoint); if (webhookError !== null) {