122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
import { lookup } from "dns/promises"
|
|||
|
|
import { isIP } from "net"
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// SSRF protection for outbound webhooks.
|
||
|
|
//
|
||
|
|
// Webhook URLs are attacker-controllable input that the server dials on a
|
||
|
|
// schedule. Without guardrails a tenant could point one at http://169.254.169.254
|
||
|
|
// (cloud metadata) or an internal service and use our servers as a proxy. We:
|
||
|
|
// 1. require https (http allowed only outside production, for local testing);
|
||
|
|
// 2. reject credentials / non-default-ish shapes;
|
||
|
|
// 3. reject hostnames that ARE private/reserved IP literals; and
|
||
|
|
// 4. resolve the hostname and reject if ANY resolved address is private.
|
||
|
|
//
|
||
|
|
// Set WEBHOOKS_ALLOW_PRIVATE_HOSTS=true to bypass (1) https-in-prod is still
|
||
|
|
// enforced) and the private-range checks — intended ONLY for local dev where the
|
||
|
|
// receiver runs on localhost.
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
const ALLOW_PRIVATE = process.env.WEBHOOKS_ALLOW_PRIVATE_HOSTS === "true"
|
||
|
|
|
||
|
|
export class WebhookUrlError extends Error {}
|
||
|
|
|
||
|
|
/** True for IPv4 addresses in a private, loopback, link-local or reserved range. */
|
||
|
|
function isPrivateIPv4(ip: string): boolean {
|
||
|
|
const parts = ip.split(".").map((n) => parseInt(n, 10))
|
||
|
|
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true
|
||
|
|
const [a, b] = parts
|
||
|
|
if (a === 0) return true // 0.0.0.0/8 "this network"
|
||
|
|
if (a === 10) return true // private
|
||
|
|
if (a === 127) return true // loopback
|
||
|
|
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64.0.0/10
|
||
|
|
if (a === 169 && b === 254) return true // link-local (incl. 169.254.169.254 metadata)
|
||
|
|
if (a === 172 && b >= 16 && b <= 31) return true // private 172.16.0.0/12
|
||
|
|
if (a === 192 && b === 0) return true // 192.0.0.0/24 IETF protocol assignments
|
||
|
|
if (a === 192 && b === 168) return true // private
|
||
|
|
if (a === 198 && (b === 18 || b === 19)) return true // benchmarking 198.18.0.0/15
|
||
|
|
if (a >= 224) return true // multicast (224/4) + reserved (240/4) + broadcast
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
/** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */
|
||
|
|
function isPrivateIPv6(ip: string): boolean {
|
||
|
|
const addr = ip.toLowerCase().split("%")[0] // strip zone id
|
||
|
|
if (addr === "::1" || addr === "::") return true
|
||
|
|
// IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part.
|
||
|
|
const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
||
|
|
if (mapped) return isPrivateIPv4(mapped[1])
|
||
|
|
const head = addr.replace(/^\[|\]$/g, "")
|
||
|
|
if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb"))
|
||
|
|
return true // fe80::/10 link-local
|
||
|
|
if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local
|
||
|
|
if (head.startsWith("ff")) return true // ff00::/8 multicast
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
function isPrivateAddress(ip: string): boolean {
|
||
|
|
const kind = isIP(ip)
|
||
|
|
if (kind === 4) return isPrivateIPv4(ip)
|
||
|
|
if (kind === 6) return isPrivateIPv6(ip)
|
||
|
|
return true // not a parseable IP → treat as unsafe
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate a user-supplied webhook URL and, unless private hosts are allowed,
|
||
|
|
* resolve it to confirm it does not point at an internal address. Throws
|
||
|
|
* WebhookUrlError with a user-facing message on any violation.
|
||
|
|
*/
|
||
|
|
export async function assertSafeWebhookUrl(raw: string): Promise<void> {
|
||
|
|
let url: URL
|
||
|
|
try {
|
||
|
|
url = new URL(raw)
|
||
|
|
} catch {
|
||
|
|
throw new WebhookUrlError("Enter a valid absolute URL.")
|
||
|
|
}
|
||
|
|
|
||
|
|
const isProd = process.env.NODE_ENV === "production"
|
||
|
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && !isProd)) {
|
||
|
|
throw new WebhookUrlError("Webhook URLs must use https://")
|
||
|
|
}
|
||
|
|
if (url.username || url.password) {
|
||
|
|
throw new WebhookUrlError("Webhook URLs must not contain credentials.")
|
||
|
|
}
|
||
|
|
|
||
|
|
const host = url.hostname.replace(/^\[|\]$/g, "")
|
||
|
|
|
||
|
|
if (ALLOW_PRIVATE) return
|
||
|
|
|
||
|
|
if (host.toLowerCase() === "localhost" || host.toLowerCase().endsWith(".localhost")) {
|
||
|
|
throw new WebhookUrlError("Webhook URLs must be publicly reachable, not localhost.")
|
||
|
|
}
|
||
|
|
|
||
|
|
// If the host is an IP literal, check it directly.
|
||
|
|
if (isIP(host)) {
|
||
|
|
if (isPrivateAddress(host)) {
|
||
|
|
throw new WebhookUrlError("Webhook URLs must not point at private or reserved IP addresses.")
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Otherwise resolve it and reject if any address is internal.
|
||
|
|
let addresses: { address: string }[]
|
||
|
|
try {
|
||
|
|
addresses = await lookup(host, { all: true })
|
||
|
|
} catch {
|
||
|
|
throw new WebhookUrlError("Could not resolve the webhook host.")
|
||
|
|
}
|
||
|
|
if (!addresses.length || addresses.some((a) => isPrivateAddress(a.address))) {
|
||
|
|
throw new WebhookUrlError("Webhook host resolves to a private or reserved address.")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Non-throwing variant used at delivery time. */
|
||
|
|
export async function isSafeWebhookUrl(raw: string): Promise<boolean> {
|
||
|
|
try {
|
||
|
|
await assertSafeWebhookUrl(raw)
|
||
|
|
return true
|
||
|
|
} catch {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|