diff options
| author | Fuwn <[email protected]> | 2026-02-08 09:21:50 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-08 09:21:50 -0800 |
| commit | 396c3f450e0f17b77478a0525029aa2534e764a9 (patch) | |
| tree | ac6d5f30c4cac6a7e9d07045d15b8388cd19791d /apps | |
| parent | security: harden database functions and policies (diff) | |
| download | asa.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')
| -rw-r--r-- | apps/web/app/(auth)/forgot-password/page.tsx | 9 | ||||
| -rw-r--r-- | apps/web/app/api/account/data/route.ts | 6 | ||||
| -rw-r--r-- | apps/web/app/api/billing/create-checkout-session/route.ts | 10 | ||||
| -rw-r--r-- | apps/web/app/api/billing/webhook/route.ts | 3 | ||||
| -rw-r--r-- | apps/web/app/api/export/route.ts | 6 | ||||
| -rw-r--r-- | apps/web/app/api/share/route.ts | 6 | ||||
| -rw-r--r-- | apps/web/app/api/webhook-config/route.ts | 50 | ||||
| -rw-r--r-- | apps/web/lib/api-auth.ts | 6 |
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, |