summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-09 23:41:01 -0800
committerFuwn <[email protected]>2026-02-09 23:41:01 -0800
commit56244758d94c14349540bd0951339fa939156204 (patch)
tree3fba880cda09c0e8d913dc30884182df5e6a73ee /apps
parentfix: use online networkMode for offline mutations instead of offlineFirst (diff)
downloadasa.news-56244758d94c14349540bd0951339fa939156204.tar.xz
asa.news-56244758d94c14349540bd0951339fa939156204.zip
fix: P0 correctness and security fixes
- Add missing 'developer' case to check_custom_feed_limit trigger (was falling through to else 1) - Scope user_entry_states join to authenticated user in /api/v1/entries (admin client bypasses RLS) - Replace in-memory rate limiting with Supabase-backed solution (UNLOGGED table + check_rate_limit RPC + pg_cron cleanup)
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/api/account/data/route.ts2
-rw-r--r--apps/web/app/api/account/route.ts2
-rw-r--r--apps/web/app/api/billing/create-checkout-session/route.ts2
-rw-r--r--apps/web/app/api/billing/create-portal-session/route.ts2
-rw-r--r--apps/web/app/api/billing/webhook/route.ts2
-rw-r--r--apps/web/app/api/export/route.ts2
-rw-r--r--apps/web/app/api/share/route.ts2
-rw-r--r--apps/web/app/api/v1/entries/route.ts1
-rw-r--r--apps/web/app/api/v1/keys/route.ts2
-rw-r--r--apps/web/app/api/webhook-config/route.ts2
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts2
-rw-r--r--apps/web/lib/api-auth.ts2
-rw-r--r--apps/web/lib/rate-limit.ts36
13 files changed, 30 insertions, 29 deletions
diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts
index f3a61ec..20a7b8c 100644
--- a/apps/web/app/api/account/data/route.ts
+++ b/apps/web/app/api/account/data/route.ts
@@ -12,7 +12,7 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`gdpr-export:${user.id}`, 3, 86_400_000)
+ const rateLimitResult = await rateLimit(`gdpr-export:${user.id}`, 3, 86_400_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts
index 83502c2..abf2ca7 100644
--- a/apps/web/app/api/account/route.ts
+++ b/apps/web/app/api/account/route.ts
@@ -19,7 +19,7 @@ export async function DELETE() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`account-delete:${user.id}`, 3, 60_000)
+ const rateLimitResult = await rateLimit(`account-delete:${user.id}`, 3, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
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 fae66b8..b3a5bf7 100644
--- a/apps/web/app/api/billing/create-checkout-session/route.ts
+++ b/apps/web/app/api/billing/create-checkout-session/route.ts
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`checkout:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`checkout:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts
index de67b40..7ad7219 100644
--- a/apps/web/app/api/billing/create-portal-session/route.ts
+++ b/apps/web/app/api/billing/create-portal-session/route.ts
@@ -20,7 +20,7 @@ export async function POST() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`portal:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`portal:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts
index 285afdc..552c27b 100644
--- a/apps/web/app/api/billing/webhook/route.ts
+++ b/apps/web/app/api/billing/webhook/route.ts
@@ -136,7 +136,7 @@ async function handleInvoicePaid(invoice: Stripe.Invoice) {
export async function POST(request: Request) {
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"
- const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000)
+ const rateLimitResult = await rateLimit(`webhook:${clientIp}`, 60, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts
index 195d444..dff7582 100644
--- a/apps/web/app/api/export/route.ts
+++ b/apps/web/app/api/export/route.ts
@@ -12,7 +12,7 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`export:${user.id}`, 5, 3_600_000)
+ const rateLimitResult = await rateLimit(`export:${user.id}`, 5, 3_600_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts
index 5f67bc6..ca8f8e2 100644
--- a/apps/web/app/api/share/route.ts
+++ b/apps/web/app/api/share/route.ts
@@ -28,7 +28,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`share:${user.id}`, 30, 60_000)
+ const rateLimitResult = await rateLimit(`share:${user.id}`, 30, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts
index 8a2de62..47789f1 100644
--- a/apps/web/app/api/v1/entries/route.ts
+++ b/apps/web/app/api/v1/entries/route.ts
@@ -43,6 +43,7 @@ export async function GET(request: Request) {
)
.in("feed_id", subscribedFeedIdentifiers)
.is("owner_id", null)
+ .eq("user_entry_states.user_id", authResult.user.userIdentifier)
.order("published_at", { ascending: false })
.limit(limit + 1)
diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts
index de63a46..67bad66 100644
--- a/apps/web/app/api/v1/keys/route.ts
+++ b/apps/web/app/api/v1/keys/route.ts
@@ -54,7 +54,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`api-keys:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`api-keys:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts
index eefa9f2..aa63d0d 100644
--- a/apps/web/app/api/webhook-config/route.ts
+++ b/apps/web/app/api/webhook-config/route.ts
@@ -59,7 +59,7 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`webhook-config:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts
index 5e58c9c..ae17c5b 100644
--- a/apps/web/app/api/webhook-config/test/route.ts
+++ b/apps/web/app/api/webhook-config/test/route.ts
@@ -21,7 +21,7 @@ export async function POST() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000)
+ const rateLimitResult = await rateLimit(`webhook-test:${user.id}`, 5, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
index e491c11..7c1d009 100644
--- a/apps/web/lib/api-auth.ts
+++ b/apps/web/lib/api-auth.ts
@@ -57,7 +57,7 @@ export async function authenticateApiRequest(
}
}
- const rateLimitResult = rateLimit(`api:${keyRow.user_id}`, 100, 60_000)
+ const rateLimitResult = await rateLimit(`api:${keyRow.user_id}`, 100, 60_000)
if (!rateLimitResult.success) {
return {
diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts
index 506511d..c68f02c 100644
--- a/apps/web/lib/rate-limit.ts
+++ b/apps/web/lib/rate-limit.ts
@@ -1,26 +1,26 @@
-const requestTimestamps = new Map<string, number[]>()
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
-export function rateLimit(
+export async function rateLimit(
identifier: string,
limit: number,
windowMilliseconds: number
-): { success: boolean; remaining: number } {
- const now = Date.now()
- const timestamps = requestTimestamps.get(identifier) ?? []
- const windowStart = now - windowMilliseconds
- const recentTimestamps = timestamps.filter(
- (timestamp) => timestamp > windowStart
- )
+): Promise<{ success: boolean; remaining: number }> {
+ const windowSeconds = Math.max(Math.floor(windowMilliseconds / 1000), 1)
+ const adminClient = createSupabaseAdminClient()
- if (recentTimestamps.length === 0) {
- requestTimestamps.delete(identifier)
- } else if (recentTimestamps.length >= limit) {
- requestTimestamps.set(identifier, recentTimestamps)
- return { success: false, remaining: 0 }
- }
+ const { data, error } = await adminClient.rpc("check_rate_limit", {
+ p_identifier: identifier,
+ p_limit: limit,
+ p_window_seconds: windowSeconds,
+ })
- recentTimestamps.push(now)
- requestTimestamps.set(identifier, recentTimestamps)
+ if (error) {
+ console.error("rate limit check failed:", error)
+ return { success: true, remaining: limit }
+ }
- return { success: true, remaining: limit - recentTimestamps.length }
+ return {
+ success: data.success as boolean,
+ remaining: data.remaining as number,
+ }
}