Files
property-management-network/lib/webhooks/ssrf.ts
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

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