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>
176 lines
6.7 KiB
TypeScript
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
|
|
}
|
|
}
|