1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
import { resolve4 as defaultResolve4, resolve6 as defaultResolve6 } from "dns/promises"
export interface DnsResolver {
resolve4: (hostname: string) => Promise<string[]>
resolve6: (hostname: string) => Promise<string[]>
}
const defaultResolver: DnsResolver = {
resolve4: defaultResolve4,
resolve6: defaultResolve6,
}
const PRIVATE_IPV4_RANGES: Array<[number, number, number]> = [
[0, 0, 8],
[10, 0, 8],
[100, 64, 10],
[127, 0, 8],
[169, 254, 16],
[172, 16, 12],
[192, 0, 24],
[192, 168, 16],
[198, 18, 15],
[224, 0, 4],
[240, 0, 4],
]
function parseIPv4(address: string): number | null {
const parts = address.split(".")
if (parts.length !== 4) return null
let result = 0
for (const part of parts) {
const octet = Number(part)
if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null
result = (result << 8) | octet
}
return result >>> 0
}
function isPrivateIPv4(address: string): boolean {
const numeric = parseIPv4(address)
if (numeric === null) return true
for (const [base, offset, prefix] of PRIVATE_IPV4_RANGES) {
const networkBase = ((base << 24) | (offset << 16)) >>> 0
const mask = (0xffffffff << (32 - prefix)) >>> 0
if ((numeric & mask) === (networkBase & mask)) return true
}
return false
}
function isPrivateIPv6(address: string): boolean {
const normalized = address.toLowerCase()
if (normalized === "::1") return true
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true
if (normalized.startsWith("fe80")) return true
if (normalized.startsWith("::ffff:")) {
const ipv4Part = normalized.slice(7)
if (ipv4Part.includes(".")) return isPrivateIPv4(ipv4Part)
}
return false
}
export async function validateWebhookUrl(
rawUrl: string,
resolver: DnsResolver = defaultResolver
): Promise<{
valid: true
url: string
} | {
valid: false
error: string
}> {
const trimmedUrl = rawUrl.trim()
if (!trimmedUrl) {
return { valid: false, error: "webhook url is required" }
}
let parsedUrl: URL
try {
parsedUrl = new URL(trimmedUrl)
} catch {
return { valid: false, error: "invalid url format" }
}
if (parsedUrl.protocol !== "https:") {
return { valid: false, error: "webhook url must use https" }
}
const hostname = parsedUrl.hostname
if (hostname === "localhost" || hostname === "[::1]") {
return { valid: false, error: "webhook url must not point to internal addresses" }
}
const ipv4Direct = parseIPv4(hostname)
if (ipv4Direct !== null) {
if (isPrivateIPv4(hostname)) {
return { valid: false, error: "webhook url must not point to internal addresses" }
}
return { valid: true, url: trimmedUrl }
}
let resolvedAddresses: string[] = []
try {
const ipv4Addresses = await resolver.resolve4(hostname)
resolvedAddresses = resolvedAddresses.concat(ipv4Addresses)
} catch {
}
try {
const ipv6Addresses = await resolver.resolve6(hostname)
resolvedAddresses = resolvedAddresses.concat(ipv6Addresses)
} catch {
}
if (resolvedAddresses.length === 0) {
return { valid: false, error: "webhook hostname could not be resolved" }
}
for (const address of resolvedAddresses) {
if (address.includes(":")) {
if (isPrivateIPv6(address)) {
return { valid: false, error: "webhook url must not resolve to internal addresses" }
}
} else {
if (isPrivateIPv4(address)) {
return { valid: false, error: "webhook url must not resolve to internal addresses" }
}
}
}
return { valid: true, url: trimmedUrl }
}
|