Skip to content

Commit 3d9de27

Browse files
authored
Add webhook notification channel to API layer (#626)
* Enhance notification system with webhook support * Implement webhook validation for project and user notifications * Update yarn.lock to include @hawk.so/types@0.5.9 and add webhook property to NotificationsChannelsDBScheme interface * Refactor webhook endpoint validation by removing isPrivateIP function and related tests. Integrate private IP validation into ipValidator module for improved code organization. * Refactor notification channel validation to use async functions for improved error handling. Update validation logic to await results from channel checks, enhancing overall reliability. * Refactor webhook endpoint validation to utilize BLOCKED_HOSTNAMES and ALLOWED_PORTS constants from ipValidator module, improving code organization and maintainability. * Refactor webhook validation logic in project and user notifications to remove endpoint checks when isEnabled is true, streamlining the validation process.
1 parent 335f6b7 commit 3d9de27

13 files changed

Lines changed: 370 additions & 5 deletions

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.4.6",
3+
"version": "1.4.7",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {
@@ -42,7 +42,7 @@
4242
"@graphql-tools/schema": "^8.5.1",
4343
"@graphql-tools/utils": "^8.9.0",
4444
"@hawk.so/nodejs": "^3.3.1",
45-
"@hawk.so/types": "^0.5.8",
45+
"@hawk.so/types": "^0.5.9",
4646
"@n1ru4l/json-patch-plus": "^0.2.0",
4747
"@node-saml/node-saml": "^5.0.1",
4848
"@octokit/oauth-methods": "^4.0.0",

src/rabbitmq.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export enum Queues {
2828
Telegram = 'notify/telegram',
2929
Slack = 'notify/slack',
3030
Loop = 'notify/loop',
31+
Webhook = 'sender/webhook',
3132
Limiter = 'cron-tasks/limiter',
3233
}
3334

@@ -90,6 +91,14 @@ export const WorkerPaths: Record<string, WorkerPath> = {
9091
queue: Queues.Loop,
9192
},
9293

94+
/**
95+
* Path to webhook worker
96+
*/
97+
Webhook: {
98+
exchange: Exchanges.Empty,
99+
queue: Queues.Webhook,
100+
},
101+
93102
/**
94103
* Path to limiter worker
95104
*/

src/resolvers/projectNotifications.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ProjectNotificationsRuleDBScheme } from '@hawk.so/types';
55
import { ResolverContextWithUser } from '../types/graphql';
66
import { ApolloError, UserInputError } from 'apollo-server-express';
77
import { NotificationsChannelsDBScheme } from '../types/notification-channels';
8+
import { validateWebhookEndpoint } from '../utils/webhookEndpointValidator';
89

910
/**
1011
* Mutation payload for creating notifications rule from GraphQL Schema
@@ -101,7 +102,7 @@ function validateNotificationsRuleTresholdAndPeriod(
101102
/**
102103
* Return true if all passed channels are filled with correct endpoints
103104
*/
104-
function validateNotificationsRuleChannels(channels: NotificationsChannelsDBScheme): string | null {
105+
async function validateNotificationsRuleChannels(channels: NotificationsChannelsDBScheme): Promise<string | null> {
105106
if (channels.email!.isEnabled) {
106107
if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(channels.email!.endpoint)) {
107108
return 'Invalid email endpoint passed';
@@ -126,6 +127,14 @@ function validateNotificationsRuleChannels(channels: NotificationsChannelsDBSche
126127
}
127128
}
128129

130+
if (channels.webhook?.isEnabled) {
131+
const webhookError = await validateWebhookEndpoint(channels.webhook.endpoint);
132+
133+
if (webhookError !== null) {
134+
return webhookError;
135+
}
136+
}
137+
129138
return null;
130139
}
131140

@@ -152,7 +161,7 @@ export default {
152161
throw new ApolloError('No project with such id');
153162
}
154163

155-
const channelsValidationResult = validateNotificationsRuleChannels(input.channels);
164+
const channelsValidationResult = await validateNotificationsRuleChannels(input.channels);
156165

157166
if (channelsValidationResult !== null) {
158167
throw new UserInputError(channelsValidationResult);
@@ -190,7 +199,7 @@ export default {
190199
throw new ApolloError('No project with such id');
191200
}
192201

193-
const channelsValidationResult = validateNotificationsRuleChannels(input.channels);
202+
const channelsValidationResult = await validateNotificationsRuleChannels(input.channels);
194203

195204
if (channelsValidationResult !== null) {
196205
throw new UserInputError(channelsValidationResult);

src/resolvers/userNotifications.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { ResolverContextWithUser } from '../types/graphql';
22
import { UserNotificationsDBScheme, UserNotificationType } from '../models/user';
33
import { NotificationsChannelsDBScheme } from '../types/notification-channels';
44
import { UserDBScheme } from '@hawk.so/types';
5+
import { UserInputError } from 'apollo-server-express';
6+
import { validateWebhookEndpoint } from '../utils/webhookEndpointValidator';
57

68
/**
79
* We will get this structure from the client to update Channel settings
@@ -45,6 +47,14 @@ export default {
4547
{ input }: ChangeUserNotificationsChannelPayload,
4648
{ user, factories }: ResolverContextWithUser
4749
): Promise<ChangeNotificationsResponse> {
50+
if (input.webhook?.isEnabled) {
51+
const webhookError = await validateWebhookEndpoint(input.webhook.endpoint);
52+
53+
if (webhookError !== null) {
54+
throw new UserInputError(webhookError);
55+
}
56+
}
57+
4858
const currentUser = await factories.usersFactory.findById(user.id);
4959
const currentNotifySet = currentUser?.notifications || {} as UserNotificationsDBScheme;
5060
const oldChannels = currentNotifySet.channels || {};

src/typeDefs/notifications.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ export default gql`
4949
"""
5050
loop: NotificationsChannelSettings
5151
52+
"""
53+
Webhook channel
54+
"""
55+
webhook: NotificationsChannelSettings
56+
5257
"""
5358
Webpush
5459
"""

src/typeDefs/notificationsInput.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export default gql`
4545
"""
4646
loop: NotificationsChannelSettingsInput
4747
48+
"""
49+
Webhook channel
50+
"""
51+
webhook: NotificationsChannelSettingsInput
52+
4853
"""
4954
Web push
5055
"""

src/types/notification-channels.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export interface NotificationsChannelsDBScheme {
3636
* Pushes through the Hawk Desktop app
3737
*/
3838
desktopPush?: NotificationsChannelSettingsDBScheme;
39+
40+
/**
41+
* Alerts through a custom Webhook URL
42+
*/
43+
webhook?: NotificationsChannelSettingsDBScheme;
3944
}
4045

4146
/**

src/utils/ipValidator.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Regex patterns matching private/reserved IP ranges:
3+
*
4+
* IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
5+
* 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
6+
* 255.255.255.255 (broadcast), 224-239.x (multicast),
7+
* 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
8+
*
9+
* IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
10+
*
11+
* Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
12+
*/
13+
const PRIVATE_IP_PATTERNS: RegExp[] = [
14+
/^0\./,
15+
/^10\./,
16+
/^127\./,
17+
/^169\.254\./,
18+
/^172\.(1[6-9]|2\d|3[01])\./,
19+
/^192\.168\./,
20+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
21+
/^255\.255\.255\.255$/,
22+
/^2(2[4-9]|3\d)\./,
23+
/^192\.0\.2\./,
24+
/^198\.51\.100\./,
25+
/^203\.0\.113\./,
26+
/^198\.1[89]\./,
27+
/^::1$/,
28+
/^::$/,
29+
/^fe80/i,
30+
/^f[cd]/i,
31+
/^ff[0-9a-f]{2}:/i,
32+
/^::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,
33+
];
34+
35+
/**
36+
* Checks whether an IP address belongs to a private/reserved range.
37+
* Strips zone ID before matching (e.g. fe80::1%lo0).
38+
*
39+
* @param ip - IP address string (v4 or v6)
40+
*/
41+
export function isPrivateIP(ip: string): boolean {
42+
const bare = ip.split('%')[0];
43+
44+
return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare));
45+
}
46+
47+
/**
48+
* Hostnames blocked regardless of DNS resolution
49+
*/
50+
export const BLOCKED_HOSTNAMES: RegExp[] = [
51+
/^localhost$/i,
52+
/\.local$/i,
53+
/\.internal$/i,
54+
/\.lan$/i,
55+
/\.localdomain$/i,
56+
];
57+
58+
/**
59+
* Only these ports are allowed for webhook delivery
60+
*/
61+
export const ALLOWED_PORTS: Record<string, number> = {
62+
'http:': 80,
63+
'https:': 443,
64+
};

src/utils/personalNotifications.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,14 @@ export default async function sendNotification(user: UserDBScheme, task: SenderW
5252
},
5353
});
5454
}
55+
56+
if (user.notifications.channels.webhook?.isEnabled) {
57+
await enqueue(WorkerPaths.Webhook, {
58+
type: task.type,
59+
payload: {
60+
...task.payload,
61+
endpoint: user.notifications.channels.webhook.endpoint,
62+
},
63+
});
64+
}
5565
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import dns from 'dns';
2+
import { isPrivateIP, BLOCKED_HOSTNAMES, ALLOWED_PORTS } from './ipValidator';
3+
4+
/**
5+
* Validates a webhook endpoint URL for SSRF safety.
6+
* Returns null if valid, or an error message string if invalid.
7+
*
8+
* Checks:
9+
* - Protocol whitelist (http/https)
10+
* - Port whitelist (80/443)
11+
* - Hostname blocklist (localhost, *.local, etc.)
12+
* - Private IP in URL
13+
* - DNS resolution — all A/AAAA records must be public
14+
*
15+
* @param endpoint - webhook URL to validate
16+
*/
17+
export async function validateWebhookEndpoint(endpoint: string): Promise<string | null> {
18+
let url: URL;
19+
20+
try {
21+
url = new URL(endpoint);
22+
} catch {
23+
return 'Invalid webhook URL';
24+
}
25+
26+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
27+
return 'Webhook URL must use http or https protocol';
28+
}
29+
30+
const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol];
31+
32+
if (requestedPort !== ALLOWED_PORTS[url.protocol]) {
33+
return `Webhook URL port ${requestedPort} is not allowed — only 80 (http) and 443 (https)`;
34+
}
35+
36+
const hostname = url.hostname;
37+
38+
if (BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname))) {
39+
return `Webhook hostname "${hostname}" is not allowed`;
40+
}
41+
42+
if (isPrivateIP(hostname)) {
43+
return 'Webhook URL points to a private/reserved IP address';
44+
}
45+
46+
try {
47+
const results = await dns.promises.lookup(hostname, { all: true });
48+
49+
for (const { address } of results) {
50+
if (isPrivateIP(address)) {
51+
return `Webhook hostname resolves to a private IP address (${address})`;
52+
}
53+
}
54+
} catch {
55+
return `Cannot resolve webhook hostname "${hostname}"`;
56+
}
57+
58+
return null;
59+
}

0 commit comments

Comments
 (0)