diff options
| author | Fuwn <[email protected]> | 2026-02-09 23:41:01 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-09 23:41:01 -0800 |
| commit | 56244758d94c14349540bd0951339fa939156204 (patch) | |
| tree | 3fba880cda09c0e8d913dc30884182df5e6a73ee | |
| parent | fix: use online networkMode for offline mutations instead of offlineFirst (diff) | |
| download | asa.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)
| -rw-r--r-- | FIXES_AND_RECOMMENDATIONS.md | 100 | ||||
| -rw-r--r-- | apps/web/app/api/account/data/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/account/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/billing/create-checkout-session/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/billing/create-portal-session/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/billing/webhook/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/export/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/share/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/v1/entries/route.ts | 1 | ||||
| -rw-r--r-- | apps/web/app/api/v1/keys/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/webhook-config/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/api/webhook-config/test/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/lib/api-auth.ts | 2 | ||||
| -rw-r--r-- | apps/web/lib/rate-limit.ts | 36 | ||||
| -rw-r--r-- | supabase/schema.sql | 46 |
15 files changed, 175 insertions, 30 deletions
diff --git a/FIXES_AND_RECOMMENDATIONS.md b/FIXES_AND_RECOMMENDATIONS.md new file mode 100644 index 0000000..6afded5 --- /dev/null +++ b/FIXES_AND_RECOMMENDATIONS.md @@ -0,0 +1,100 @@ +# asa.news Fixes and Recommended Changes + +Date: 2026-02-10 + +This note captures all fixes and changes identified during the codebase analysis. + +## P0: Fix Immediately (Correctness / Security) + +- [x] Fix plan-limit mismatch for developer custom feeds. + - Problem: app constants allow 1000 custom feeds for `developer`, but DB trigger fallback effectively allows 1. + - Files: + - `packages/shared/source/index.ts` + - `supabase/schema.sql` (`check_custom_feed_limit`) + - Action: update SQL case to explicitly include `when 'developer' then 1000`. + +- [x] Scope `user_entry_states` join in API v1 entries to the authenticated user. + - Problem: `user_entry_states!left(read, saved)` join is not user-filtered while using a service-role client. + - File: `apps/web/app/api/v1/entries/route.ts` + - Action: join/filter by `user_id = authResult.user.userIdentifier` (or fetch states in a second query keyed by entry IDs and user ID). + +- [x] Replace in-memory rate limiting with shared/distributed rate limiting. + - Problem: current `Map`-based limiter is per-process only and unreliable across instances/serverless. + - Files: + - `apps/web/lib/rate-limit.ts` + - all API routes using `rateLimit(...)` + - Action: move to Redis/Upstash/Postgres-backed limiter with per-route policies and standard headers. + +## P1: Must Fix for Green Lint / Stable Main + +- [ ] Resolve current ESLint errors (not warnings). + - Errors observed in: + - `apps/web/app/reader/_components/entry-detail-panel.tsx` (`set-state-in-effect`) + - `apps/web/app/reader/_components/reader-layout-shell.tsx` (`react-hooks/refs`, 2 errors) + - `apps/web/app/reader/_components/reader-shell.tsx` (`react-hooks/refs`) + - `apps/web/app/reader/_components/search-overlay.tsx` (`set-state-in-effect`) + - `apps/web/app/reader/settings/_components/api-settings.tsx` (`set-state-in-effect`) + - `apps/web/app/shared/[token]/page.tsx` (`no-html-link-for-pages`) + - `apps/web/lib/hooks/use-is-mobile.ts` (`set-state-in-effect`) + - `apps/web/public/sw.js` (`no-this-alias` in generated file) + - Action: fix hooks/ref patterns, replace root `<a href="/">` with `<Link href="/">`, and stop linting generated `public/sw.js` (or regenerate with lint-safe output and ignore generated bundles). + +## P2: Security Hardening + +- [ ] Reduce service-role usage in user-facing API handlers where not required. + - Problem: several authenticated routes use `createSupabaseAdminClient()` when user-scoped RLS would be safer. + - Files (examples): + - `apps/web/app/api/v1/feeds/route.ts` + - `apps/web/app/api/v1/entries/route.ts` + - `apps/web/app/api/v1/entries/[entryIdentifier]/route.ts` + - `apps/web/app/api/v1/profile/route.ts` + - `apps/web/app/api/v1/folders/route.ts` + - Action: prefer `createSupabaseServerClient()` + RLS for read paths; keep service role only for admin-only operations. + +- [ ] Harden webhook URL validation in web app paths to match worker-grade SSRF protections. + - Problem: `webhook-config` checks are hostname-string based; test webhook route performs outbound fetches. + - Files: + - `apps/web/app/api/webhook-config/route.ts` + - `apps/web/app/api/webhook-config/test/route.ts` + - Action: use DNS resolution + private/reserved IP block checks (including IPv6/link-local) before outbound requests. + +## P2: Data/Business Logic Consistency + +- [ ] Keep tier limits in one source of truth and verify parity between TS constants and SQL triggers/functions. + - Files: + - `packages/shared/source/index.ts` + - `supabase/schema.sql` + - Action: add a parity check script or migration checklist so limits never drift again. + +## P3: Quality and Maintainability + +- [ ] Reduce current lint warning load (120 warnings observed). + - Categories to address: + - missing hook dependencies (`react-hooks/exhaustive-deps`) + - unused variables + - `<img>` usage warnings (`@next/next/no-img-element`) + - generated file noise from `public/sw.js` + - Action: handle in batches and enforce warning budget in CI. + +- [ ] Add automated tests (currently no meaningful test coverage). + - Go worker: `go test ./...` passes but all packages report `[no test files]`. + - Web app: no unit/integration/e2e suite present. + - Action: + - worker: add unit tests for fetcher/parser/writer logic and error classification + - web: add API route tests + critical hook/component tests + at least one e2e happy path + +- [ ] Add CI gates for `lint`, `typecheck`, and tests. + - Problem: no `.github/workflows` found. + - Action: create CI to block regressions before merge. + +- [ ] Refresh stale app documentation. + - Problem: web README is still default create-next-app boilerplate. + - File: `apps/web/README.md` + - Action: document real setup, env vars, worker dependency, Supabase + Stripe requirements, local dev flow. + +## Execution Order Recommendation + +1. P0 fixes (correctness/security). +2. P1 lint errors to restore green checks. +3. P2 hardening (service-role reduction + webhook SSRF parity). +4. P3 testing + CI + docs. 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, + } } diff --git a/supabase/schema.sql b/supabase/schema.sql index 6603c36..fb5f3ab 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -946,7 +946,7 @@ begin select tier, custom_feed_count into current_tier, current_count from public.user_profiles where id = new.user_id; - maximum_allowed := case current_tier when 'free' then 1 when 'pro' then 1000 else 1 end; + maximum_allowed := case current_tier when 'free' then 1 when 'pro' then 1000 when 'developer' then 1000 else 1 end; if current_count >= maximum_allowed then raise exception 'You have reached the maximum number of custom feeds for your plan (% on % tier)', maximum_allowed, current_tier; @@ -1200,3 +1200,47 @@ SELECT cron.schedule('purge-orphaned-feeds', '30 4 * * *', $$ and subscriber_count = 0 and updated_at < now() - interval '7 days'; $$); + +-- every 5 minutes: cleanup expired rate limit windows +SELECT cron.schedule('cleanup-rate-limits', '*/5 * * * *', $$DELETE FROM public.rate_limits WHERE window_start < now() - interval '10 minutes'$$); + +-- ============================================================================= +-- rate limiting +-- ============================================================================= + +CREATE UNLOGGED TABLE public.rate_limits ( + identifier text NOT NULL, + window_start timestamptz NOT NULL, + request_count integer NOT NULL DEFAULT 1, + PRIMARY KEY (identifier, window_start) +); + +CREATE OR REPLACE FUNCTION public.check_rate_limit( + p_identifier text, + p_limit integer, + p_window_seconds integer +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE + v_window_start timestamptz; + v_current_count integer; +BEGIN + v_window_start := to_timestamp( + floor(extract(epoch from now()) / p_window_seconds) * p_window_seconds + ); + + INSERT INTO public.rate_limits (identifier, window_start, request_count) + VALUES (p_identifier, v_window_start, 1) + ON CONFLICT (identifier, window_start) + DO UPDATE SET request_count = rate_limits.request_count + 1 + RETURNING request_count INTO v_current_count; + + RETURN jsonb_build_object( + 'success', v_current_count <= p_limit, + 'remaining', greatest(p_limit - v_current_count, 0) + ); +END; +$function$; |