summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-08 09:21:50 -0800
committerFuwn <[email protected]>2026-02-08 09:21:50 -0800
commit396c3f450e0f17b77478a0525029aa2534e764a9 (patch)
treeac6d5f30c4cac6a7e9d07045d15b8388cd19791d /apps/web
parentsecurity: harden database functions and policies (diff)
downloadasa.news-396c3f450e0f17b77478a0525029aa2534e764a9.tar.xz
asa.news-396c3f450e0f17b77478a0525029aa2534e764a9.zip
security: harden API routes
- Add rate limiting to /api/share (30/min), /api/export (5/hr), /api/account/data (3/day) - Add client-side 30s throttle to forgot-password form - Remove immediate tier upgrade on plan change; let invoice.paid webhook handle tier promotion to prevent free upgrades on payment failure - Add SSRF validation to webhook URLs: block localhost, private IPs, link-local, and metadata endpoints - Log Stripe webhook signature verification errors instead of swallowing silently - Mask webhook secret in GET response (show first/last 4 chars only) - Add error logging to API key last_used_at update - Remove internal error message leaking from checkout session route
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/(auth)/forgot-password/page.tsx9
-rw-r--r--apps/web/app/api/account/data/route.ts6
-rw-r--r--apps/web/app/api/billing/create-checkout-session/route.ts10
-rw-r--r--apps/web/app/api/billing/webhook/route.ts3
-rw-r--r--apps/web/app/api/export/route.ts6
-rw-r--r--apps/web/app/api/share/route.ts6
-rw-r--r--apps/web/app/api/webhook-config/route.ts50
-rw-r--r--apps/web/lib/api-auth.ts6
8 files changed, 80 insertions, 16 deletions
diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx
index ad302bc..dd972b6 100644
--- a/apps/web/app/(auth)/forgot-password/page.tsx
+++ b/apps/web/app/(auth)/forgot-password/page.tsx
@@ -9,11 +9,20 @@ export default function ForgotPasswordPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isEmailSent, setIsEmailSent] = useState(false)
+ const [lastSubmittedAt, setLastSubmittedAt] = useState(0)
async function handleResetRequest(event: React.FormEvent) {
event.preventDefault()
+
+ const now = Date.now()
+ if (now - lastSubmittedAt < 30_000) {
+ setErrorMessage("please wait 30 seconds between requests")
+ return
+ }
+
setIsSubmitting(true)
setErrorMessage(null)
+ setLastSubmittedAt(now)
const supabaseClient = createSupabaseBrowserClient()
diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts
index bec6ab9..f3a61ec 100644
--- a/apps/web/app/api/account/data/route.ts
+++ b/apps/web/app/api/account/data/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { rateLimit } from "@/lib/rate-limit"
export async function GET() {
const supabaseClient = await createSupabaseServerClient()
@@ -11,6 +12,11 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
+ const rateLimitResult = rateLimit(`gdpr-export:${user.id}`, 3, 86_400_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
+ }
+
const [
profileResult,
subscriptionsResult,
diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts
index 4e3e6f6..fae66b8 100644
--- a/apps/web/app/api/billing/create-checkout-session/route.ts
+++ b/apps/web/app/api/billing/create-checkout-session/route.ts
@@ -100,12 +100,6 @@ export async function POST(request: Request) {
}
)
- const adminClient = createSupabaseAdminClient()
- await adminClient
- .from("user_profiles")
- .update({ tier: targetTier })
- .eq("id", user.id)
-
return NextResponse.json({ upgraded: true })
}
@@ -126,9 +120,9 @@ export async function POST(request: Request) {
.eq("id", user.id)
if (updateError) {
- console.error("Admin client update error:", updateError)
+ console.error("failed to save stripe customer identifier:", updateError)
return NextResponse.json(
- { error: "failed to save customer: " + updateError.message },
+ { error: "failed to save customer" },
{ status: 500 }
)
}
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts
index 37944c2..285afdc 100644
--- a/apps/web/app/api/billing/webhook/route.ts
+++ b/apps/web/app/api/billing/webhook/route.ts
@@ -156,7 +156,8 @@ export async function POST(request: Request) {
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
- } catch {
+ } catch (verificationError) {
+ console.error("stripe webhook signature verification failed:", verificationError)
return NextResponse.json({ error: "invalid signature" }, { status: 400 })
}
diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts
index 4d15c5a..195d444 100644
--- a/apps/web/app/api/export/route.ts
+++ b/apps/web/app/api/export/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { createSupabaseServerClient } from "@/lib/supabase/server"
+import { rateLimit } from "@/lib/rate-limit"
export async function GET() {
const supabaseClient = await createSupabaseServerClient()
@@ -11,6 +12,11 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
+ const rateLimitResult = rateLimit(`export:${user.id}`, 5, 3_600_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
+ }
+
const { data: profile } = await supabaseClient
.from("user_profiles")
.select("tier, display_name")
diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts
index 324d1e9..5f67bc6 100644
--- a/apps/web/app/api/share/route.ts
+++ b/apps/web/app/api/share/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { randomBytes } from "crypto"
import { createSupabaseServerClient } from "@/lib/supabase/server"
import { checkBotId } from "botid/server"
+import { rateLimit } from "@/lib/rate-limit"
const MAX_NOTE_LENGTH = 1000
@@ -27,6 +28,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
+ const rateLimitResult = rateLimit(`share:${user.id}`, 30, 60_000)
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "too many requests" }, { status: 429 })
+ }
+
const { data: userProfile } = await supabaseClient
.from("user_profiles")
.select("tier")
diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts
index dcfd052..eefa9f2 100644
--- a/apps/web/app/api/webhook-config/route.ts
+++ b/apps/web/app/api/webhook-config/route.ts
@@ -31,9 +31,14 @@ export async function GET() {
)
}
+ const maskedSecret = profile.webhook_secret
+ ? profile.webhook_secret.slice(0, 4) + "••••" + profile.webhook_secret.slice(-4)
+ : null
+
return NextResponse.json({
webhookUrl: profile.webhook_url,
- webhookSecret: profile.webhook_secret,
+ webhookSecret: maskedSecret,
+ webhookSecretConfigured: !!profile.webhook_secret,
webhookEnabled: profile.webhook_enabled,
consecutiveFailures: profile.webhook_consecutive_failures,
})
@@ -83,11 +88,44 @@ export async function PUT(request: Request) {
if (typeof body.webhookUrl === "string") {
const trimmedUrl = body.webhookUrl.trim()
- if (trimmedUrl && !trimmedUrl.startsWith("https://")) {
- return NextResponse.json(
- { error: "webhook url must use https" },
- { status: 400 }
- )
+ if (trimmedUrl) {
+ let parsedUrl: URL
+ try {
+ parsedUrl = new URL(trimmedUrl)
+ } catch {
+ return NextResponse.json(
+ { error: "invalid url format" },
+ { status: 400 }
+ )
+ }
+ if (parsedUrl.protocol !== "https:") {
+ 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" },
+ { status: 400 }
+ )
+ }
}
updates.webhook_url = trimmedUrl || null
}
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
index d2efdd7..e491c11 100644
--- a/apps/web/lib/api-auth.ts
+++ b/apps/web/lib/api-auth.ts
@@ -71,7 +71,11 @@ export async function authenticateApiRequest(
.from("api_keys")
.update({ last_used_at: new Date().toISOString() })
.eq("key_hash", keyHash)
- .then(() => {})
+ .then(({ error: updateError }) => {
+ if (updateError) {
+ console.error("failed to update api key last_used_at:", updateError)
+ }
+ })
return {
authenticated: true,