diff options
| author | Fuwn <[email protected]> | 2026-02-10 00:06:15 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-10 00:06:15 -0800 |
| commit | 4dbc34c0261bb21d0109c31014b8b46abf7f20fd (patch) | |
| tree | 6672ce9e30d45f78ab2b43f7270d3f81d78d9496 /apps/web/lib | |
| parent | fix: resolve Supabase security and performance advisories (diff) | |
| download | asa.news-4dbc34c0261bb21d0109c31014b8b46abf7f20fd.tar.xz asa.news-4dbc34c0261bb21d0109c31014b8b46abf7f20fd.zip | |
fix: P2 security hardening and tier limit parity
Webhook routes switched from admin client to server client (RLS).
Added DNS-resolution SSRF protection for webhook URLs with private IP
blocking. Added tier limit parity check script.
Diffstat (limited to 'apps/web/lib')
| -rw-r--r-- | apps/web/lib/validate-webhook-url.ts | 124 |
1 files changed, 124 insertions, 0 deletions
diff --git a/apps/web/lib/validate-webhook-url.ts b/apps/web/lib/validate-webhook-url.ts new file mode 100644 index 0000000..60a41aa --- /dev/null +++ b/apps/web/lib/validate-webhook-url.ts @@ -0,0 +1,124 @@ +import { resolve4, resolve6 } from "dns/promises" + +const PRIVATE_IPV4_RANGES: Array<[number, number, number]> = [ + [0, 0, 8], + [10, 0, 8], + [100, 64, 10], + [127, 0, 8], + [169, 254, 16], + [172, 16, 12], + [192, 0, 24], + [192, 168, 16], + [198, 18, 15], + [224, 0, 4], + [240, 0, 4], +] + +function parseIPv4(address: string): number | null { + const parts = address.split(".") + if (parts.length !== 4) return null + let result = 0 + for (const part of parts) { + const octet = Number(part) + if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null + result = (result << 8) | octet + } + return result >>> 0 +} + +function isPrivateIPv4(address: string): boolean { + const numeric = parseIPv4(address) + if (numeric === null) return true + + for (const [base, offset, prefix] of PRIVATE_IPV4_RANGES) { + const networkBase = ((base << 24) | (offset << 16)) >>> 0 + const mask = (0xffffffff << (32 - prefix)) >>> 0 + if ((numeric & mask) === (networkBase & mask)) return true + } + + return false +} + +function isPrivateIPv6(address: string): boolean { + const normalized = address.toLowerCase() + if (normalized === "::1") return true + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true + if (normalized.startsWith("fe80")) return true + if (normalized.startsWith("::ffff:")) { + const ipv4Part = normalized.slice(7) + if (ipv4Part.includes(".")) return isPrivateIPv4(ipv4Part) + } + return false +} + +export async function validateWebhookUrl(rawUrl: string): Promise<{ + valid: true + url: string +} | { + valid: false + error: string +}> { + const trimmedUrl = rawUrl.trim() + if (!trimmedUrl) { + return { valid: false, error: "webhook url is required" } + } + + let parsedUrl: URL + try { + parsedUrl = new URL(trimmedUrl) + } catch { + return { valid: false, error: "invalid url format" } + } + + if (parsedUrl.protocol !== "https:") { + return { valid: false, error: "webhook url must use https" } + } + + const hostname = parsedUrl.hostname + + if (hostname === "localhost" || hostname === "[::1]") { + return { valid: false, error: "webhook url must not point to internal addresses" } + } + + const ipv4Direct = parseIPv4(hostname) + if (ipv4Direct !== null) { + if (isPrivateIPv4(hostname)) { + return { valid: false, error: "webhook url must not point to internal addresses" } + } + return { valid: true, url: trimmedUrl } + } + + let resolvedAddresses: string[] = [] + + try { + const ipv4Addresses = await resolve4(hostname) + resolvedAddresses = resolvedAddresses.concat(ipv4Addresses) + } catch { + // no-op + } + + try { + const ipv6Addresses = await resolve6(hostname) + resolvedAddresses = resolvedAddresses.concat(ipv6Addresses) + } catch { + // no-op + } + + if (resolvedAddresses.length === 0) { + return { valid: false, error: "webhook hostname could not be resolved" } + } + + for (const address of resolvedAddresses) { + if (address.includes(":")) { + if (isPrivateIPv6(address)) { + return { valid: false, error: "webhook url must not resolve to internal addresses" } + } + } else { + if (isPrivateIPv4(address)) { + return { valid: false, error: "webhook url must not resolve to internal addresses" } + } + } + } + + return { valid: true, url: trimmedUrl } +} |