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/resolvers/projectNotifications.ts b/src/resolvers/projectNotifications.ts index af8e0cec..508be6fa 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 @@ -101,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'; @@ -126,6 +127,14 @@ function validateNotificationsRuleChannels(channels: NotificationsChannelsDBSche } } + if (channels.webhook?.isEnabled) { + const webhookError = await validateWebhookEndpoint(channels.webhook.endpoint); + + if (webhookError !== null) { + return webhookError; + } + } + return null; } @@ -152,7 +161,7 @@ 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); @@ -190,7 +199,7 @@ 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); diff --git a/src/resolvers/userNotifications.ts b/src/resolvers/userNotifications.ts index a98d3148..662562c9 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) { + 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/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/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/src/utils/ipValidator.ts b/src/utils/ipValidator.ts new file mode 100644 index 00000000..7bceb476 --- /dev/null +++ b/src/utils/ipValidator.ts @@ -0,0 +1,64 @@ +/** + * 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)); +} + +/** + * 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/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, + }, + }); + } } diff --git a/src/utils/webhookEndpointValidator.ts b/src/utils/webhookEndpointValidator.ts new file mode 100644 index 00000000..084ffe33 --- /dev/null +++ b/src/utils/webhookEndpointValidator.ts @@ -0,0 +1,59 @@ +import dns from 'dns'; +import { isPrivateIP, BLOCKED_HOSTNAMES, ALLOWED_PORTS } from './ipValidator'; + +/** + * 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/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 new file mode 100644 index 00000000..f54f8126 --- /dev/null +++ b/test/utils/webhookEndpointValidator.test.ts @@ -0,0 +1,53 @@ +import { validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator'; + +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(); + }); +}); 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"