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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,121 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user