Files
property-management-network/lib/webhooks/ssrf.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00

176 lines
6.7 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
}
/**
* Expand an IPv6 literal to its eight numeric hextets, or null if unparseable.
* Handles "::" compression and a trailing dotted-quad (::ffff:1.2.3.4).
*/
function ipv6Hextets(input: string): number[] | null {
let s = input.toLowerCase().split("%")[0].replace(/^\[|\]$/g, "")
// Fold a trailing dotted-quad into two hextets so one code path handles both
// spellings of an IPv4-mapped address.
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (dotted && dotted.index !== undefined) {
const parts = dotted[1].split(".").map((n) => parseInt(n, 10))
if (parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null
s =
s.slice(0, dotted.index) +
(((parts[0] << 8) | parts[1]) >>> 0).toString(16) +
":" +
(((parts[2] << 8) | parts[3]) >>> 0).toString(16)
}
const halves = s.split("::")
if (halves.length > 2) return null
const split = (chunk: string) => (chunk ? chunk.split(":").filter(Boolean) : [])
const head = split(halves[0])
const tail = halves.length === 2 ? split(halves[1]) : []
const groups =
halves.length === 2
? [...head, ...Array(Math.max(0, 8 - head.length - tail.length)).fill("0"), ...tail]
: head
if (groups.length !== 8) return null
const out: number[] = []
for (const g of groups) {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null
out.push(parseInt(g, 16))
}
return out
}
/**
* True for IPv6 loopback, unspecified, ULA, link-local, multicast, or any
* address embedding a private IPv4 address.
*
* The embedded-IPv4 check works on the NUMERIC hextets, not on the text. Node's
* URL parser rewrites `::ffff:169.254.169.254` to `::ffff:a9fe:a9fe`, so a
* previous version that only matched the dotted-quad spelling let the cloud
* metadata endpoint — and every private range — straight through.
*/
function isPrivateIPv6(ip: string): boolean {
const h = ipv6Hextets(ip)
if (!h) return true // unparseable → treat as unsafe
// ::/96 (covers :: and ::1) and ::ffff:0:0/96 both carry an IPv4 address in
// the low 32 bits. Decode it and reuse the IPv4 rules.
const embedsIPv4 =
h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 &&
(h[5] === 0 || h[5] === 0xffff)
if (embedsIPv4) {
const v4 = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff].join(".")
return isPrivateIPv4(v4)
}
const first = h[0]
if ((first & 0xffc0) === 0xfe80) return true // fe80::/10 link-local
if ((first & 0xfe00) === 0xfc00) return true // fc00::/7 unique-local
if ((first & 0xff00) === 0xff00) 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
}
}