Skip to content

Commit a12b398

Browse files
committed
refactor(webhook): streamline webhook deliverer by removing private IP checks and enhancing payload sanitization
1 parent ca2cf91 commit a12b398

File tree

4 files changed

+465
-396
lines changed

4 files changed

+465
-396
lines changed

workers/webhook/src/deliverer.ts

Lines changed: 8 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,19 @@
11
import https from 'https';
22
import http from 'http';
3-
import dns from 'dns';
43
import { createLogger, format, Logger, transports } from 'winston';
54
import { WebhookDelivery } from '../types/template';
5+
import { HttpStatusCode, MS_IN_SEC } from '../../../lib/utils/consts';
66

77
/**
88
* Timeout for webhook delivery in milliseconds
99
*/
10-
const DELIVERY_TIMEOUT_MS = 10000;
11-
12-
/**
13-
* HTTP status code threshold for error responses
14-
*/
15-
const HTTP_ERROR_STATUS = 400;
16-
17-
/**
18-
* HTTP status codes indicating a redirect
19-
*/
20-
const REDIRECT_STATUS_MIN = 300;
21-
const REDIRECT_STATUS_MAX = 399;
22-
23-
/**
24-
* Only these ports are allowed for webhook delivery
25-
*/
26-
const ALLOWED_PORTS: Record<string, number> = {
27-
'http:': 80,
28-
'https:': 443,
29-
};
30-
31-
/**
32-
* Hostnames blocked regardless of DNS resolution
33-
*/
34-
const BLOCKED_HOSTNAMES: RegExp[] = [
35-
/^localhost$/i,
36-
/\.local$/i,
37-
/\.internal$/i,
38-
/\.lan$/i,
39-
/\.localdomain$/i,
40-
];
41-
42-
/**
43-
* Regex patterns matching private/reserved IP ranges:
44-
*
45-
* IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
46-
* 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
47-
* 255.255.255.255 (broadcast), 224-239.x (multicast),
48-
* 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
49-
*
50-
* IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
51-
*
52-
* Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
53-
*/
54-
const PRIVATE_IP_PATTERNS: RegExp[] = [
55-
/^0\./,
56-
/^10\./,
57-
/^127\./,
58-
/^169\.254\./,
59-
/^172\.(1[6-9]|2\d|3[01])\./,
60-
/^192\.168\./,
61-
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
62-
/^255\.255\.255\.255$/,
63-
/^2(2[4-9]|3\d)\./,
64-
/^192\.0\.2\./,
65-
/^198\.51\.100\./,
66-
/^203\.0\.113\./,
67-
/^198\.1[89]\./,
68-
/^::1$/,
69-
/^::$/,
70-
/^fe80/i,
71-
/^f[cd]/i,
72-
/^ff[0-9a-f]{2}:/i,
73-
/^::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,
74-
];
75-
76-
/**
77-
* Checks whether an IPv4 or IPv6 address belongs to a private/reserved range.
78-
* Handles plain IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:x.x.x.x).
79-
*
80-
* @param ip - IP address string (v4 or v6)
81-
*/
82-
export function isPrivateIP(ip: string): boolean {
83-
const bare = ip.split('%')[0];
84-
85-
return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare));
86-
}
87-
88-
/**
89-
* Checks whether a hostname is in the blocked list
90-
*
91-
* @param hostname - hostname to check
92-
*/
93-
function isBlockedHostname(hostname: string): boolean {
94-
return BLOCKED_HOSTNAMES.some((pattern) => pattern.test(hostname));
95-
}
96-
97-
/**
98-
* Resolves hostname to all IPs, validates every one is public,
99-
* and returns the first safe address to pin the request to.
100-
* Throws if any address is private or DNS fails.
101-
*
102-
* @param hostname - hostname to resolve
103-
*/
104-
async function resolveAndValidate(hostname: string): Promise<string> {
105-
const results = await dns.promises.lookup(hostname, { all: true });
106-
107-
for (const { address } of results) {
108-
if (isPrivateIP(address)) {
109-
throw new Error(`resolves to private IP ${address}`);
110-
}
111-
}
112-
113-
return results[0].address;
114-
}
10+
const DELIVERY_TIMEOUT_MS = MS_IN_SEC * 10;
11511

11612
/**
11713
* Deliverer sends JSON POST requests to external webhook endpoints.
11814
*
119-
* SSRF mitigations:
120-
* - Protocol whitelist (http/https only)
121-
* - Port whitelist (80/443 only)
122-
* - Hostname blocklist (localhost, *.local, *.internal, *.lan)
123-
* - Private IP detection for raw IPs in URL
124-
* - DNS resolution with `all: true` — every A/AAAA record checked
125-
* - Request pinned to resolved IP (prevents DNS rebinding)
126-
* - SNI preserved via `servername` for HTTPS
127-
* - Redirects explicitly rejected (3xx + Location)
15+
* SSRF validation is performed at the API layer when the endpoint is saved —
16+
* this class trusts the stored URL and only handles delivery.
12817
*/
12918
export default class WebhookDeliverer {
13019
/**
@@ -147,87 +36,42 @@ export default class WebhookDeliverer {
14736

14837
/**
14938
* Sends webhook delivery to the endpoint via HTTP POST.
150-
* Pins the connection to a validated IP to prevent DNS rebinding.
39+
* Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event).
15140
*
15241
* @param endpoint - URL to POST to
15342
* @param delivery - webhook delivery { type, payload }
15443
*/
15544
public async deliver(endpoint: string, delivery: WebhookDelivery): Promise<void> {
15645
const body = JSON.stringify(delivery);
15746
const url = new URL(endpoint);
158-
159-
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
160-
this.logger.log('error', `Webhook blocked — unsupported protocol: ${url.protocol} for ${endpoint}`);
161-
162-
return;
163-
}
164-
165-
const requestedPort = url.port ? Number(url.port) : ALLOWED_PORTS[url.protocol];
166-
167-
if (requestedPort !== ALLOWED_PORTS[url.protocol]) {
168-
this.logger.log('error', `Webhook blocked — port ${requestedPort} not allowed for ${endpoint}`);
169-
170-
return;
171-
}
172-
173-
const originalHostname = url.hostname;
174-
175-
if (isBlockedHostname(originalHostname)) {
176-
this.logger.log('error', `Webhook blocked — hostname "${originalHostname}" is in blocklist`);
177-
178-
return;
179-
}
180-
181-
if (isPrivateIP(originalHostname)) {
182-
this.logger.log('error', `Webhook blocked — private IP in URL: ${endpoint}`);
183-
184-
return;
185-
}
186-
187-
let pinnedAddress: string;
188-
189-
try {
190-
pinnedAddress = await resolveAndValidate(originalHostname);
191-
} catch (e) {
192-
this.logger.log('error', `Webhook blocked — ${originalHostname} ${(e as Error).message}`);
193-
194-
return;
195-
}
196-
19747
const transport = url.protocol === 'https:' ? https : http;
19848

19949
return new Promise<void>((resolve) => {
20050
const req = transport.request(
51+
url,
20152
{
202-
hostname: pinnedAddress,
203-
port: requestedPort,
204-
path: url.pathname + url.search,
20553
method: 'POST',
20654
headers: {
207-
'Host': originalHostname,
20855
'Content-Type': 'application/json',
20956
'User-Agent': 'Hawk-Webhook/1.0',
21057
'X-Hawk-Notification': delivery.type,
21158
'Content-Length': Buffer.byteLength(body),
21259
},
21360
timeout: DELIVERY_TIMEOUT_MS,
214-
...(url.protocol === 'https:'
215-
? { servername: originalHostname, rejectUnauthorized: true }
216-
: {}),
21761
},
21862
(res) => {
21963
res.resume();
22064

22165
const status = res.statusCode || 0;
22266

223-
if (status >= REDIRECT_STATUS_MIN && status <= REDIRECT_STATUS_MAX) {
67+
if (status >= HttpStatusCode.MultipleChoices && status <= HttpStatusCode.PermanentRedirect) {
22468
this.logger.log('error', `Webhook blocked — redirect ${status} to ${res.headers.location} from ${endpoint}`);
22569
resolve();
22670

22771
return;
22872
}
22973

230-
if (status >= HTTP_ERROR_STATUS) {
74+
if (status >= HttpStatusCode.BadRequest) {
23175
this.logger.log('error', `Webhook delivery failed: ${status} ${res.statusMessage} for ${endpoint}`);
23276
}
23377

0 commit comments

Comments
 (0)