summaryrefslogtreecommitdiff
path: root/apps/web/lib/validate-webhook-url.ts
blob: 60a41aa51732d399395290160f5c662239ab7347 (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
import { resolve4, resolve6 } from "dns/promises"

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): 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 resolve4(hostname)
    resolvedAddresses = resolvedAddresses.concat(ipv4Addresses)
  } catch {
    // no-op
  }

  try {
    const ipv6Addresses = await resolve6(hostname)
    resolvedAddresses = resolvedAddresses.concat(ipv6Addresses)
  } catch {
    // no-op
  }

  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 }
}