summaryrefslogtreecommitdiff
path: root/apps
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
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')
-rw-r--r--apps/web/app/api/webhook-config/route.ts57
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts13
-rw-r--r--apps/web/lib/validate-webhook-url.ts124
3 files changed, 143 insertions, 51 deletions
diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts
index aa63d0d..df2816f 100644
--- a/apps/web/app/api/webhook-config/route.ts
+++ b/apps/web/app/api/webhook-config/route.ts
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server"
import { createSupabaseServerClient } from "@/lib/supabase/server"
-import { createSupabaseAdminClient } from "@/lib/supabase/admin"
import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
import { rateLimit } from "@/lib/rate-limit"
+import { validateWebhookUrl } from "@/lib/validate-webhook-url"
import { checkBotId } from "botid/server"
export async function GET() {
@@ -15,8 +15,7 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const adminClient = createSupabaseAdminClient()
- const { data: profile, error } = await adminClient
+ const { data: profile, error } = await supabaseClient
.from("user_profiles")
.select(
"tier, webhook_url, webhook_secret, webhook_enabled, webhook_consecutive_failures"
@@ -64,11 +63,9 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
- const adminClient = createSupabaseAdminClient()
-
- const { data: profile } = await adminClient
+ const { data: profile } = await supabaseClient
.from("user_profiles")
- .select("tier")
+ .select("tier, webhook_url")
.eq("id", user.id)
.single()
@@ -89,40 +86,10 @@ export async function PUT(request: Request) {
if (typeof body.webhookUrl === "string") {
const trimmedUrl = body.webhookUrl.trim()
if (trimmedUrl) {
- let parsedUrl: URL
- try {
- parsedUrl = new URL(trimmedUrl)
- } catch {
- return NextResponse.json(
- { error: "invalid url format" },
- { status: 400 }
- )
- }
- if (parsedUrl.protocol !== "https:") {
+ const validationResult = await validateWebhookUrl(trimmedUrl)
+ if (!validationResult.valid) {
return NextResponse.json(
- { error: "webhook url must use https" },
- { status: 400 }
- )
- }
- const hostname = parsedUrl.hostname
- if (
- hostname === "localhost" ||
- hostname.startsWith("127.") ||
- hostname.startsWith("10.") ||
- hostname.startsWith("192.168.") ||
- hostname.startsWith("172.16.") ||
- hostname.startsWith("172.17.") ||
- hostname.startsWith("172.18.") ||
- hostname.startsWith("172.19.") ||
- hostname.startsWith("172.2") ||
- hostname.startsWith("172.30.") ||
- hostname.startsWith("172.31.") ||
- hostname === "169.254.169.254" ||
- hostname === "[::1]" ||
- hostname === "0.0.0.0"
- ) {
- return NextResponse.json(
- { error: "webhook url must not point to internal addresses" },
+ { error: validationResult.error },
{ status: 400 }
)
}
@@ -136,16 +103,10 @@ export async function PUT(request: Request) {
if (typeof body.webhookEnabled === "boolean") {
if (body.webhookEnabled) {
- const { data: currentProfile } = await adminClient
- .from("user_profiles")
- .select("webhook_url")
- .eq("id", user.id)
- .single()
-
const effectiveUrl =
typeof body.webhookUrl === "string"
? body.webhookUrl.trim()
- : currentProfile?.webhook_url
+ : profile.webhook_url
if (!effectiveUrl) {
return NextResponse.json(
@@ -164,7 +125,7 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: "no updates provided" }, { status: 400 })
}
- const { error } = await adminClient
+ const { error } = await supabaseClient
.from("user_profiles")
.update(updates)
.eq("id", user.id)
diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts
index ae17c5b..81c3942 100644
--- a/apps/web/app/api/webhook-config/test/route.ts
+++ b/apps/web/app/api/webhook-config/test/route.ts
@@ -1,9 +1,9 @@
import { NextResponse } from "next/server"
import { createHmac } from "crypto"
import { createSupabaseServerClient } from "@/lib/supabase/server"
-import { createSupabaseAdminClient } from "@/lib/supabase/admin"
import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared"
import { rateLimit } from "@/lib/rate-limit"
+import { validateWebhookUrl } from "@/lib/validate-webhook-url"
import { checkBotId } from "botid/server"
export async function POST() {
@@ -26,8 +26,7 @@ export async function POST() {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
- const adminClient = createSupabaseAdminClient()
- const { data: profile } = await adminClient
+ const { data: profile } = await supabaseClient
.from("user_profiles")
.select(
"tier, webhook_url, webhook_secret, webhook_enabled"
@@ -52,6 +51,14 @@ export async function POST() {
)
}
+ const validationResult = await validateWebhookUrl(profile.webhook_url)
+ if (!validationResult.valid) {
+ return NextResponse.json(
+ { error: validationResult.error },
+ { status: 400 }
+ )
+ }
+
const testPayload = {
event: "test",
timestamp: new Date().toISOString(),
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 }
+}