summaryrefslogtreecommitdiff
path: root/apps/web/lib/validate-webhook-url.ts
blob: c980770ab74d7fc925a9f6018cbb3afd3e4cbe10 (plain) (blame)
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 }
}