summaryrefslogtreecommitdiff
path: root/apps/web/lib
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 00:06:15 -0800
committerFuwn <[email protected]>2026-02-10 00:06:15 -0800
commit4dbc34c0261bb21d0109c31014b8b46abf7f20fd (patch)
tree6672ce9e30d45f78ab2b43f7270d3f81d78d9496 /apps/web/lib
parentfix: resolve Supabase security and performance advisories (diff)
downloadasa.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.ts124
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 }
+}