Skip to content

Commit 0466429

Browse files
committed
Implement webhook validation for project and user notifications
1 parent ddb885e commit 0466429

4 files changed

Lines changed: 340 additions & 0 deletions

File tree

src/resolvers/projectNotifications.ts

Lines changed: 26 additions & 0 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
@@ -129,6 +130,19 @@ function validateNotificationsRuleChannels(channels: NotificationsChannelsDBSche
129130
return null;
130131
}
131132

133+
/**
134+
* Validates webhook endpoint for SSRF safety (async DNS check)
135+
*
136+
* @param channels - notification channels to validate
137+
*/
138+
async function validateWebhookChannel(channels: NotificationsChannelsDBScheme): Promise<string | null> {
139+
if (channels.webhook?.isEnabled && channels.webhook.endpoint) {
140+
return validateWebhookEndpoint(channels.webhook.endpoint);
141+
}
142+
143+
return null;
144+
}
145+
132146
/**
133147
* See all types and fields here {@see ../typeDefs/notify.graphql}
134148
*/
@@ -158,6 +172,12 @@ export default {
158172
throw new UserInputError(channelsValidationResult);
159173
}
160174

175+
const webhookValidationResult = await validateWebhookChannel(input.channels);
176+
177+
if (webhookValidationResult !== null) {
178+
throw new UserInputError(webhookValidationResult);
179+
}
180+
161181
if (input.whatToReceive === ReceiveTypes.SEEN_MORE) {
162182
const thresholdValidationResult = validateNotificationsRuleTresholdAndPeriod(input.threshold, input.thresholdPeriod);
163183

@@ -196,6 +216,12 @@ export default {
196216
throw new UserInputError(channelsValidationResult);
197217
}
198218

219+
const webhookValidationResult = await validateWebhookChannel(input.channels);
220+
221+
if (webhookValidationResult !== null) {
222+
throw new UserInputError(webhookValidationResult);
223+
}
224+
199225
if (input.whatToReceive === ReceiveTypes.SEEN_MORE) {
200226
const thresholdValidationResult = validateNotificationsRuleTresholdAndPeriod(input.threshold, input.thresholdPeriod);
201227

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 && input.webhook.endpoint) {
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 || {};
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import dns from 'dns';
2+
3+
/**
4+
* Regex patterns matching private/reserved IP ranges:
5+
*
6+
* IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
7+
* 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
8+
* 255.255.255.255 (broadcast), 224-239.x (multicast),
9+
* 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
10+
*
11+
* IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
12+
*
13+
* Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
14+
*/
15+
const PRIVATE_IP_PATTERNS: RegExp[] = [
16+
/^0\./,
17+
/^10\./,
18+
/^127\./,
19+
/^169\.254\./,
20+
/^172\.(1[6-9]|2\d|3[01])\./,
21+
/^192\.168\./,
22+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
23+
/^255\.255\.255\.255$/,
24+
/^2(2[4-9]|3\d)\./,
25+
/^192\.0\.2\./,
26+
/^198\.51\.100\./,
27+
/^203\.0\.113\./,
28+
/^198\.1[89]\./,
29+
/^::1$/,
30+
/^::$/,
31+
/^fe80/i,
32+
/^f[cd]/i,
33+
/^ff[0-9a-f]{2}:/i,
34+
/^::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,
35+
];
36+
37+
/**
38+
* Hostnames blocked regardless of DNS resolution
39+
*/
40+
const BLOCKED_HOSTNAMES: RegExp[] = [
41+
/^localhost$/i,
42+
/\.local$/i,
43+
/\.internal$/i,
44+
/\.lan$/i,
45+
/\.localdomain$/i,
46+
];
47+
48+
/**
49+
* Only these ports are allowed for webhook delivery
50+
*/
51+
const ALLOWED_PORTS: Record<string, number> = {
52+
'http:': 80,
53+
'https:': 443,
54+
};
55+
56+
/**
57+
* Checks whether an IP address belongs to a private/reserved range.
58+
* Strips zone ID before matching (e.g. fe80::1%lo0).
59+
*
60+
* @param ip - IP address string (v4 or v6)
61+
*/
62+
export function isPrivateIP(ip: string): boolean {
63+
const bare = ip.split('%')[0];
64+
65+
return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare));
66+
}
67+
68+
/**
69+
* Validates a webhook endpoint URL for SSRF safety.
70+
* Returns null if valid, or an error message string if invalid.
71+
*
72+
* Checks:
73+
* - Protocol whitelist (http/https)
74+
* - Port whitelist (80/443)
75+
* - Hostname blocklist (localhost, *.local, etc.)
76+
* - Private IP in URL
77+
* - DNS resolution — all A/AAAA records must be public
78+
*
79+
* @param endpoint - webhook URL to validate
80+
*/
81+
export async function validateWebhookEndpoint(endpoint: string): Promise<string | null> {
82+
let url: URL;
83+
84+
try {
85+
url = new URL(endpoint);
86+
} catch {
87+
return 'Invalid webhook URL';
88+
}
89+
90+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
91+
return 'Webhook URL must use http or https protocol';
92+
}
93+
94+
const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol];
95+
96+
if (requestedPort !== ALLOWED_PORTS[url.protocol]) {
97+
return `Webhook URL port ${requestedPort} is not allowed — only 80 (http) and 443 (https)`;
98+
}
99+
100+
const hostname = url.hostname;
101+
102+
if (BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname))) {
103+
return `Webhook hostname "${hostname}" is not allowed`;
104+
}
105+
106+
if (isPrivateIP(hostname)) {
107+
return 'Webhook URL points to a private/reserved IP address';
108+
}
109+
110+
try {
111+
const results = await dns.promises.lookup(hostname, { all: true });
112+
113+
for (const { address } of results) {
114+
if (isPrivateIP(address)) {
115+
return `Webhook hostname resolves to a private IP address (${address})`;
116+
}
117+
}
118+
} catch {
119+
return `Cannot resolve webhook hostname "${hostname}"`;
120+
}
121+
122+
return null;
123+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { isPrivateIP, validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator';
2+
3+
describe('isPrivateIP', () => {
4+
describe('should block private/reserved IPv4', () => {
5+
it.each([
6+
['127.0.0.1'],
7+
['127.255.255.255'],
8+
['10.0.0.1'],
9+
['10.255.255.255'],
10+
['0.0.0.0'],
11+
['172.16.0.1'],
12+
['172.31.255.255'],
13+
['192.168.0.1'],
14+
['192.168.255.255'],
15+
['169.254.1.1'],
16+
['169.254.169.254'],
17+
['100.64.0.1'],
18+
['100.127.255.255'],
19+
])('%s', (ip) => {
20+
expect(isPrivateIP(ip)).toBe(true);
21+
});
22+
});
23+
24+
describe('should block broadcast and multicast IPv4', () => {
25+
it.each([
26+
['255.255.255.255'],
27+
['224.0.0.1'],
28+
['239.255.255.255'],
29+
['230.1.2.3'],
30+
])('%s', (ip) => {
31+
expect(isPrivateIP(ip)).toBe(true);
32+
});
33+
});
34+
35+
describe('should block documentation and benchmarking IPv4', () => {
36+
it.each([
37+
['192.0.2.1'],
38+
['198.51.100.1'],
39+
['203.0.113.1'],
40+
['198.18.0.1'],
41+
['198.19.255.255'],
42+
])('%s', (ip) => {
43+
expect(isPrivateIP(ip)).toBe(true);
44+
});
45+
});
46+
47+
describe('should block private/reserved IPv6', () => {
48+
it.each([
49+
['::1'],
50+
['::'],
51+
['fe80::1'],
52+
['FE80::abc'],
53+
['fc00::1'],
54+
['fd12:3456::1'],
55+
])('%s', (ip) => {
56+
expect(isPrivateIP(ip)).toBe(true);
57+
});
58+
});
59+
60+
describe('should block IPv6 multicast', () => {
61+
it.each([
62+
['ff02::1'],
63+
['ff05::2'],
64+
['FF0E::1'],
65+
])('%s', (ip) => {
66+
expect(isPrivateIP(ip)).toBe(true);
67+
});
68+
});
69+
70+
describe('should block IPv6 with zone ID', () => {
71+
it.each([
72+
['fe80::1%lo0'],
73+
['fe80::1%eth0'],
74+
['::1%lo0'],
75+
])('%s', (ip) => {
76+
expect(isPrivateIP(ip)).toBe(true);
77+
});
78+
});
79+
80+
describe('should block IPv4-mapped IPv6', () => {
81+
it.each([
82+
['::ffff:127.0.0.1'],
83+
['::ffff:10.0.0.1'],
84+
['::ffff:192.168.1.1'],
85+
['::ffff:172.16.0.1'],
86+
['::ffff:169.254.169.254'],
87+
['::ffff:100.64.0.1'],
88+
['::ffff:0.0.0.0'],
89+
['::FFFF:127.0.0.1'],
90+
])('%s', (ip) => {
91+
expect(isPrivateIP(ip)).toBe(true);
92+
});
93+
});
94+
95+
describe('should allow public IPv4', () => {
96+
it.each([
97+
['8.8.8.8'],
98+
['1.1.1.1'],
99+
['93.184.216.34'],
100+
['172.32.0.1'],
101+
['172.15.255.255'],
102+
['192.169.0.1'],
103+
['100.128.0.1'],
104+
['100.63.255.255'],
105+
['169.255.0.1'],
106+
['223.255.255.255'],
107+
])('%s', (ip) => {
108+
expect(isPrivateIP(ip)).toBe(false);
109+
});
110+
});
111+
112+
describe('should allow public IPv6', () => {
113+
it.each([
114+
['2001:db8::1'],
115+
['2606:4700::1'],
116+
])('%s', (ip) => {
117+
expect(isPrivateIP(ip)).toBe(false);
118+
});
119+
});
120+
121+
describe('should allow public IPv4-mapped IPv6', () => {
122+
it.each([
123+
['::ffff:8.8.8.8'],
124+
['::ffff:93.184.216.34'],
125+
])('%s', (ip) => {
126+
expect(isPrivateIP(ip)).toBe(false);
127+
});
128+
});
129+
});
130+
131+
describe('validateWebhookEndpoint', () => {
132+
it('should reject invalid URL', async () => {
133+
expect(await validateWebhookEndpoint('not-a-url')).toBe('Invalid webhook URL');
134+
});
135+
136+
it('should reject ftp protocol', async () => {
137+
expect(await validateWebhookEndpoint('ftp://example.com/hook')).toBe('Webhook URL must use http or https protocol');
138+
});
139+
140+
it('should reject non-standard ports', async () => {
141+
const result = await validateWebhookEndpoint('https://example.com:8080/hook');
142+
143+
expect(result).toMatch(/port.*not allowed/);
144+
});
145+
146+
it('should reject localhost', async () => {
147+
const result = await validateWebhookEndpoint('http://localhost/hook');
148+
149+
expect(result).toMatch(/not allowed/);
150+
});
151+
152+
it('should reject .local hostnames', async () => {
153+
const result = await validateWebhookEndpoint('http://myapp.local/hook');
154+
155+
expect(result).toMatch(/not allowed/);
156+
});
157+
158+
it('should reject private IP in URL', async () => {
159+
const result = await validateWebhookEndpoint('http://127.0.0.1/hook');
160+
161+
expect(result).toMatch(/private/i);
162+
});
163+
164+
it('should reject 169.254.169.254 (metadata)', async () => {
165+
const result = await validateWebhookEndpoint('http://169.254.169.254/latest/meta-data');
166+
167+
expect(result).toMatch(/private/i);
168+
});
169+
170+
it('should accept valid public https URL', async () => {
171+
const result = await validateWebhookEndpoint('https://example.com/hawk-webhook');
172+
173+
expect(result).toBeNull();
174+
});
175+
176+
it('should accept valid public http URL on port 80', async () => {
177+
const result = await validateWebhookEndpoint('http://example.com/hawk-webhook');
178+
179+
expect(result).toBeNull();
180+
});
181+
});

0 commit comments

Comments
 (0)