Skip to content

Commit 8acee14

Browse files
committed
feat(webhook): add private IP checks and DNS validation for webhook delivery
1 parent c89717b commit 8acee14

File tree

1 file changed

+60
-0
lines changed

1 file changed

+60
-0
lines changed

workers/webhook/src/deliverer.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import https from 'https';
22
import http from 'http';
3+
import dns from 'dns';
34
import { createLogger, format, Logger, transports } from 'winston';
45
import { WebhookDelivery } from '../types/template';
56

@@ -13,6 +14,36 @@ const DELIVERY_TIMEOUT_MS = 10000;
1314
*/
1415
const HTTP_ERROR_STATUS = 400;
1516

17+
/**
18+
* Checks whether an IPv4 or IPv6 address belongs to a private/reserved range.
19+
* Blocks loopback, link-local, RFC1918, metadata IPs and IPv6 equivalents.
20+
*/
21+
function isPrivateIP(ip: string): boolean {
22+
const parts = ip.split('.').map(Number);
23+
24+
if (parts.length === 4 && parts.every((p) => p >= 0 && p <= 255)) {
25+
return (
26+
parts[0] === 127 ||
27+
parts[0] === 10 ||
28+
parts[0] === 0 ||
29+
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
30+
(parts[0] === 192 && parts[1] === 168) ||
31+
(parts[0] === 169 && parts[1] === 254) ||
32+
(parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
33+
);
34+
}
35+
36+
const lower = ip.toLowerCase();
37+
38+
return (
39+
lower === '::1' ||
40+
lower.startsWith('fe80') ||
41+
lower.startsWith('fc') ||
42+
lower.startsWith('fd') ||
43+
lower === '::')
44+
;
45+
}
46+
1647
/**
1748
* Deliverer sends JSON POST requests to external webhook endpoints
1849
*/
@@ -45,6 +76,35 @@ export default class WebhookDeliverer {
4576
public async deliver(endpoint: string, delivery: WebhookDelivery): Promise<void> {
4677
const body = JSON.stringify(delivery);
4778
const url = new URL(endpoint);
79+
80+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
81+
this.logger.log('error', `Webhook blocked — unsupported protocol: ${url.protocol} for ${endpoint}`);
82+
83+
return;
84+
}
85+
86+
const hostname = url.hostname;
87+
88+
if (isPrivateIP(hostname)) {
89+
this.logger.log('error', `Webhook blocked — private IP in URL: ${endpoint}`);
90+
91+
return;
92+
}
93+
94+
try {
95+
const { address } = await dns.promises.lookup(hostname);
96+
97+
if (isPrivateIP(address)) {
98+
this.logger.log('error', `Webhook blocked — ${hostname} resolves to private IP ${address}`);
99+
100+
return;
101+
}
102+
} catch (e) {
103+
this.logger.log('error', `Webhook blocked — DNS lookup failed for ${hostname}: ${(e as Error).message}`);
104+
105+
return;
106+
}
107+
48108
const transport = url.protocol === 'https:' ? https : http;
49109

50110
return new Promise<void>((resolve) => {

0 commit comments

Comments
 (0)