From 51c541ad2209f60b42550637843f2b57154ee777 Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:59:03 -0400 Subject: [PATCH] Security & robustness hardening pass Cross-cutting input-validation, isolation, and DoS-resistance fixes across the app, API, billing, queue, and infra layers. - Runtime validation (zod) for client-supplied admin actions (role/plan/ limits), series generation index, and all pg-boss queue payloads - Auth: require email verification before sign-in; reject weak/placeholder/ short BETTER_AUTH_SECRET in production - Billing: sanitize Stripe/PayPal errors (log server-side, generic to client); race-safe subscription upsert; only count "processed" webhook events as handled; verify org membership in getEffectivePlan to block plan escalation - Series generation: reserve usage up front and refund on failure; bill the owning org, not the caller's active org - Injection defenses: HTML-escape user fields in emails, strip CR/LF from subject/recipient, validate ElevenLabs voiceId before URL interpolation - Media routes: stream off disk instead of buffering whole files; rate-limit anonymous public audio/cover endpoints by client IP --- app/(admin)/admin/actions.ts | 75 +++++++++++++--- app/(app)/billing/actions.ts | 6 +- app/(app)/series/actions.ts | 81 +++++++++++------ app/api/assets/[...key]/route.ts | 13 +-- .../public/episodes/[shareId]/audio/route.ts | 37 ++++++-- .../public/episodes/[shareId]/cover/route.ts | 34 +++++-- lib/ai/pipeline/generate-episode.ts | 7 +- lib/ai/providers/elevenlabs-audio.ts | 13 ++- lib/auth/auth.ts | 26 ++++-- lib/billing/paypal.ts | 10 ++- lib/billing/subscription.ts | 44 ++++++---- lib/billing/webhook-log.ts | 4 +- lib/email/index.ts | 17 +++- lib/queue/jobs.ts | 48 ++++++---- lib/ratelimit/index.ts | 58 ++++++++++-- lib/storage/local.ts | 13 ++- lib/storage/types.ts | 10 +++ middleware.ts | 88 +++++++++++++++---- next.config.mjs | 19 +--- prisma/schema.prisma | 12 ++- worker/index.ts | 26 +++++- 21 files changed, 489 insertions(+), 152 deletions(-) diff --git a/app/(admin)/admin/actions.ts b/app/(admin)/admin/actions.ts index f5f11c6..79c33bb 100644 --- a/app/(admin)/admin/actions.ts +++ b/app/(admin)/admin/actions.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { Prisma } from "@prisma/client"; import { getServerSession } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; @@ -43,8 +44,13 @@ export async function banUserAction(userId: string, ban: boolean): Promise<{ ok: export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> { const s = await adminSession(); if (!s) return { ok: false, error: "Not allowed." }; - await prisma.user.update({ where: { id: userId }, data: { role } }); - await audit(s.user.id, "user.role", userId, { role }); + // Don't trust the TS union at runtime — reject anything outside the allowed set. + const parsedRole = z.enum(["admin", "user"]).safeParse(role); + if (!parsedRole.success) return { ok: false, error: "Invalid role." }; + // Self-guard: an admin must not demote/remove their own admin role and lock out. + if (userId === s.user.id) return { ok: false, error: "You can't change your own role." }; + await prisma.user.update({ where: { id: userId }, data: { role: parsedRole.data } }); + await audit(s.user.id, "user.role", userId, { role: parsedRole.data }); revalidatePath("/admin/users"); return { ok: true }; } @@ -87,6 +93,16 @@ export async function compPlanAction( const s = await adminSession(); if (!s) return { ok: false, error: "Not allowed." }; + // Validate the client-supplied plan/interval at runtime, not just via the TS union. + const parsed = z + .object({ + plan: z.enum(["creator", "pro", "agency"]), + interval: z.enum(["month", "year"]), + }) + .safeParse({ plan, interval }); + if (!parsed.success) return { ok: false, error: "Invalid plan or interval." }; + ({ plan, interval } = parsed.data); + const now = new Date(); const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS); @@ -309,26 +325,65 @@ export interface PlanUpdateInput { }; } +// A monthly metric cap: a finite integer that is either a real non-negative cap +// or UNLIMITED (-1). Mirrors the canonical PlanLimits shape in lib/billing/plans.ts. +const quotaCap = z.number().int().gte(-1); +// Counts that can't be "unlimited": must be finite non-negative integers. +const nonNegInt = z.number().int().nonnegative(); + +// Validate the limits object against the known keys only — `.strict()` rejects +// extra/unknown keys so a client can't mass-assign arbitrary JSON into the column. +const planLimitsSchema = z + .object({ + script: quotaCap, + audio: quotaCap, + art: quotaCap, + repurpose: quotaCap, + seats: nonNegInt, + maxEpisodeMinutes: nonNegInt, + }) + .strict(); + +const planUpdateSchema = z.object({ + key: z.string().min(1), + priceMonthly: z.number().int().nonnegative(), + priceYearly: z.number().int().nonnegative(), + limits: planLimitsSchema, +}); + /** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */ export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise { const s = await adminSession(); if (!s) return { ok: false, error: "Not allowed." }; - const existing = await prisma.plan.findUnique({ where: { key } }); + // Validate ALL client args (key + prices + limits) before touching the DB so + // nothing unvalidated reaches the JSON column. Prices arrive as cents; round + // to ints first to keep the existing tolerance for fractional input. + const parsed = planUpdateSchema.safeParse({ + key, + priceMonthly: Math.round(input.priceMonthly), + priceYearly: Math.round(input.priceYearly), + limits: input.limits, + }); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid plan input." }; + } + + const existing = await prisma.plan.findUnique({ where: { key: parsed.data.key } }); if (!existing) return { ok: false, error: "Plan not found." }; await prisma.plan.update({ - where: { key }, + where: { key: parsed.data.key }, data: { - priceMonthly: Math.max(0, Math.round(input.priceMonthly)), - priceYearly: Math.max(0, Math.round(input.priceYearly)), - limits: input.limits as unknown as Prisma.InputJsonValue, + priceMonthly: parsed.data.priceMonthly, + priceYearly: parsed.data.priceYearly, + limits: parsed.data.limits as unknown as Prisma.InputJsonValue, }, }); - await audit(s.user.id, "plan.update", key, { - priceMonthly: input.priceMonthly, - priceYearly: input.priceYearly, + await audit(s.user.id, "plan.update", parsed.data.key, { + priceMonthly: parsed.data.priceMonthly, + priceYearly: parsed.data.priceYearly, }); revalidatePath("/admin/settings"); return { ok: true }; diff --git a/app/(app)/billing/actions.ts b/app/(app)/billing/actions.ts index 540f37d..e6ae30b 100644 --- a/app/(app)/billing/actions.ts +++ b/app/(app)/billing/actions.ts @@ -20,8 +20,12 @@ import type { PlanKey } from "@/lib/billing/plans"; type ActionResult = { ok: true; url?: string } | { ok: false; error: string }; +// Billing calls hit upstream providers (Stripe/PayPal) whose error messages can +// contain sensitive/verbose detail. Log the real error server-side, but return a +// single generic message to the client so nothing upstream leaks to end users. function errMsg(e: unknown): string { - return e instanceof Error ? e.message : "Something went wrong"; + console.error("[billing] action failed", e); + return "Something went wrong with billing. Please try again."; } export async function startStripeCheckoutAction( diff --git a/app/(app)/series/actions.ts b/app/(app)/series/actions.ts index cefba6a..495f4f8 100644 --- a/app/(app)/series/actions.ts +++ b/app/(app)/series/actions.ts @@ -5,9 +5,11 @@ import { z } from "zod"; import type { Prisma } from "@prisma/client"; import { getServerSession } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; -import { subjectHasFeature } from "@/lib/billing/subscription"; -import { enforceLimit, LimitExceededError } from "@/lib/usage/limits"; +import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription"; +import { reserveLimit, LimitExceededError } from "@/lib/usage/limits"; +import { refundUsage } from "@/lib/usage/meter"; import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; +import type { UsageMetric } from "@/lib/billing/plans"; import { FORMAT_SPEAKERS } from "@/lib/episodes/options"; import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices"; import { isFlagEnabled } from "@/lib/flags"; @@ -62,17 +64,41 @@ export async function generateFromSeriesAction( return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." }; } + // `index` is client-supplied and only TS-typed — validate it at runtime. + if (!Number.isInteger(index) || index < 0) { + return { ok: false, error: "Invalid episode index." }; + } + const series = await prisma.series.findUnique({ where: { id: seriesId } }); - if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." }; + if (!series || (series.userId !== session.user.id && session.user.role !== "admin")) { + return { ok: false, error: "Not allowed." }; + } const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? []; const item = episodes[index]; if (!item) return { ok: false, error: "Episode not found in plan." }; + // Bill the generation against the org that OWNS the series (the resource being + // acted on), and stamp the new episode with that same org, so the billing + // subject and the episode's organizationId are always consistent — regardless + // of the caller's currently-active org. getEffectivePlan verifies membership of + // series.organizationId internally and falls back to the user subject otherwise. + const orgId = series.organizationId ?? undefined; + const { subjectId, subjectType } = await getEffectivePlan(session.user.id, orgId); + + // Reserve quota atomically up front (a series generation consumes script + + // audio). The worker won't re-meter; refund below if create/enqueue fails. + const reserved: UsageMetric[] = []; + const refundReserved = async () => { + for (const m of reserved) await refundUsage(subjectId, subjectType, m); + }; try { - await enforceLimit(session.user.id, "script", session.session.activeOrganizationId); - await enforceLimit(session.user.id, "audio", session.session.activeOrganizationId); + await reserveLimit(session.user.id, "script", orgId); + reserved.push("script"); + await reserveLimit(session.user.id, "audio", orgId); + reserved.push("audio"); } catch (err) { + await refundReserved(); if (err instanceof LimitExceededError) { return { ok: false, error: `Monthly ${err.check.metric} limit reached.` }; } @@ -85,25 +111,30 @@ export async function generateFromSeriesAction( elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id, })); - const episode = await prisma.episode.create({ - data: { - userId: session.user.id, - organizationId: series.organizationId ?? undefined, - seriesId: series.id, - title: item.title, - topic: item.topic, - tone: "Conversational", - format: "SOLO", - language: "en", - targetLengthMin: 10, - status: "QUEUED", - stage: "Queued for generation", - speakers: { create: speakers }, - jobs: { create: { type: "full", status: "queued" } }, - }, - }); - await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" }); + try { + const episode = await prisma.episode.create({ + data: { + userId: session.user.id, + organizationId: orgId, + seriesId: series.id, + title: item.title, + topic: item.topic, + tone: "Conversational", + format: "SOLO", + language: "en", + targetLengthMin: 10, + status: "QUEUED", + stage: "Queued for generation", + speakers: { create: speakers }, + jobs: { create: { type: "full", status: "queued" } }, + }, + }); + await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" }); - revalidatePath(`/series/${seriesId}`); - return { ok: true, episodeId: episode.id }; + revalidatePath(`/series/${seriesId}`); + return { ok: true, episodeId: episode.id }; + } catch (err) { + await refundReserved(); + throw err; + } } diff --git a/app/api/assets/[...key]/route.ts b/app/api/assets/[...key]/route.ts index 6e87ce8..944b75a 100644 --- a/app/api/assets/[...key]/route.ts +++ b/app/api/assets/[...key]/route.ts @@ -1,3 +1,4 @@ +import { Readable } from "node:stream"; import { NextRequest } from "next/server"; import { getServerSession } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; @@ -42,20 +43,22 @@ export async function GET( const isAdmin = session.user.role === "admin"; if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 }); - const exists = await storage().exists(key); - if (!exists) return new Response("Not found", { status: 404 }); + // Stream off disk instead of buffering the whole file into memory. + const total = await storage().size(key); + if (total === null) return new Response("Not found", { status: 404 }); - const data = await storage().get(key); const ext = key.split(".").pop()?.toLowerCase() ?? ""; const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; const download = req.nextUrl.searchParams.get("download"); const filename = key.split("/").pop() ?? "asset"; - return new Response(data as BodyInit, { + const node = storage().createReadStream!(key); + const body = Readable.toWeb(node as Readable) as unknown as BodyInit; + return new Response(body, { headers: { "Content-Type": contentType, - "Content-Length": String(data.byteLength), + "Content-Length": String(total), "Cache-Control": "private, max-age=3600", ...(download ? { "Content-Disposition": `attachment; filename="${filename}"` } diff --git a/app/api/public/episodes/[shareId]/audio/route.ts b/app/api/public/episodes/[shareId]/audio/route.ts index e647e20..87a2689 100644 --- a/app/api/public/episodes/[shareId]/audio/route.ts +++ b/app/api/public/episodes/[shareId]/audio/route.ts @@ -1,20 +1,39 @@ +import { Readable } from "node:stream"; import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; import { storage } from "@/lib/storage"; export const dynamic = "force-dynamic"; +/** Best-effort client IP for anonymous rate limiting. */ +function clientKey(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") ?? "anon"; +} + /** * Stream an episode's MP3 to anonymous visitors, authorized purely by a valid, * still-enabled public `shareId` (NOT a session). Returns 404 when the share is * disabled or the audio is missing so we never disclose private episode state. * - * Supports HTTP Range requests so the audio element can seek/scrub. + * Supports HTTP Range requests so the audio element can seek/scrub. The file is + * streamed off disk (never buffered whole) to avoid memory-amplification DoS. */ export async function GET( req: NextRequest, { params }: { params: Promise<{ shareId: string }> } ) { + // Rate-limit by client IP (never by shareId alone). + const rl = await rateLimit("public-audio", clientKey(req), LIMITS.publicMedia); + if (!rl.ok) { + return new Response("Too many requests", { + status: 429, + headers: { "Retry-After": String(rl.retryAfterSec ?? 1) }, + }); + } + const { shareId } = await params; const episode = await prisma.episode.findUnique({ @@ -24,10 +43,9 @@ export async function GET( const key = episode?.audioAsset?.storageKey; if (!key) return new Response("Not found", { status: 404 }); - if (!(await storage().exists(key))) return new Response("Not found", { status: 404 }); + const total = await storage().size(key); + if (total === null) return new Response("Not found", { status: 404 }); - const data = await storage().get(key); - const total = data.byteLength; const contentType = "audio/mpeg"; const range = req.headers.get("range"); @@ -37,12 +55,13 @@ export async function GET( const start = Number(match[1]); const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1; if (start <= end && start < total) { - const chunk = data.subarray(start, end + 1); - return new Response(chunk as BodyInit, { + const node = storage().createReadStream!(key, { start, end }); + const body = Readable.toWeb(node as Readable) as unknown as BodyInit; + return new Response(body, { status: 206, headers: { "Content-Type": contentType, - "Content-Length": String(chunk.byteLength), + "Content-Length": String(end - start + 1), "Content-Range": `bytes ${start}-${end}/${total}`, "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=3600", @@ -52,7 +71,9 @@ export async function GET( } } - return new Response(data as BodyInit, { + const node = storage().createReadStream!(key); + const body = Readable.toWeb(node as Readable) as unknown as BodyInit; + return new Response(body, { headers: { "Content-Type": contentType, "Content-Length": String(total), diff --git a/app/api/public/episodes/[shareId]/cover/route.ts b/app/api/public/episodes/[shareId]/cover/route.ts index 3dc1a0a..df23bab 100644 --- a/app/api/public/episodes/[shareId]/cover/route.ts +++ b/app/api/public/episodes/[shareId]/cover/route.ts @@ -1,5 +1,7 @@ +import { Readable } from "node:stream"; import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; import { storage } from "@/lib/storage"; export const dynamic = "force-dynamic"; @@ -11,15 +13,32 @@ const CONTENT_TYPES: Record = { webp: "image/webp", }; +/** Best-effort client IP for anonymous rate limiting. */ +function clientKey(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") ?? "anon"; +} + /** * Serve an episode's cover art to anonymous visitors, authorized by a valid, * still-enabled public `shareId`. Used as a fallback when the storage provider - * doesn't expose a directly-fetchable public URL for cover art. + * doesn't expose a directly-fetchable public URL for cover art. The file is + * streamed off disk rather than buffered whole to avoid memory-amplification DoS. */ export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ shareId: string }> } ) { + // Rate-limit by client IP (never by shareId alone). + const rl = await rateLimit("public-cover", clientKey(req), LIMITS.publicMedia); + if (!rl.ok) { + return new Response("Too many requests", { + status: 429, + headers: { "Retry-After": String(rl.retryAfterSec ?? 1) }, + }); + } + const { shareId } = await params; const episode = await prisma.episode.findUnique({ @@ -28,14 +47,17 @@ export async function GET( }); const key = episode?.coverArt?.storageKey; if (!key) return new Response("Not found", { status: 404 }); - if (!(await storage().exists(key))) return new Response("Not found", { status: 404 }); - const data = await storage().get(key); + const total = await storage().size(key); + if (total === null) return new Response("Not found", { status: 404 }); + const ext = key.split(".").pop()?.toLowerCase() ?? "png"; - return new Response(data as BodyInit, { + const node = storage().createReadStream!(key); + const body = Readable.toWeb(node as Readable) as unknown as BodyInit; + return new Response(body, { headers: { "Content-Type": CONTENT_TYPES[ext] ?? "image/png", - "Content-Length": String(data.byteLength), + "Content-Length": String(total), "Cache-Control": "public, max-age=3600", }, }); diff --git a/lib/ai/pipeline/generate-episode.ts b/lib/ai/pipeline/generate-episode.ts index da11760..b495479 100644 --- a/lib/ai/pipeline/generate-episode.ts +++ b/lib/ai/pipeline/generate-episode.ts @@ -10,7 +10,7 @@ import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/co import { refundUsage } from "@/lib/usage/meter"; import { isFlagEnabled } from "@/lib/flags"; import { moderateText, moderationReason } from "@/lib/ai/moderation"; -import { sendEmail, emailLayout } from "@/lib/email"; +import { sendEmail, emailLayout, escapeHtml } from "@/lib/email"; import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices"; import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types"; import type { GenerationType } from "@/lib/queue/jobs"; @@ -262,13 +262,16 @@ async function generateArt(episode: EpisodeWithRelations) { async function notifyReady(episode: EpisodeWithRelations) { const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + // `emailLayout` interpolates the body as RAW HTML, so user-controlled fields + // (the episode title) must be escaped before being placed into it. + const safeTitle = escapeHtml(episode.title ?? ""); try { await sendEmail({ to: episode.user.email, subject: `🎙️ "${episode.title}" is ready`, html: emailLayout( "Your episode is ready", - `“${episode.title}” has finished generating — script, audio, and cover art are all set.`, + `“${safeTitle}” has finished generating — script, audio, and cover art are all set.`, { label: "Open episode", url: `${appUrl}/episodes/${episode.id}` } ), text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`, diff --git a/lib/ai/providers/elevenlabs-audio.ts b/lib/ai/providers/elevenlabs-audio.ts index bd5f259..b4a48bb 100644 --- a/lib/ai/providers/elevenlabs-audio.ts +++ b/lib/ai/providers/elevenlabs-audio.ts @@ -5,12 +5,23 @@ const TTS_MODEL = process.env.ELEVENLABS_TTS_MODEL ?? "eleven_multilingual_v2"; const DIALOGUE_MODEL = process.env.ELEVENLABS_DIALOGUE_MODEL ?? "eleven_v3"; const OUTPUT_FORMAT = "mp3_44100_128"; +/** ElevenLabs voice IDs are opaque alphanumeric tokens; reject anything else. */ +const VOICE_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + function apiKey(): string { const k = process.env.ELEVENLABS_API_KEY; if (!k) throw new Error("ELEVENLABS_API_KEY is not set"); return k; } +/** Validate a voice ID before it is interpolated into a request URL path. */ +function safeVoiceId(voiceId: string): string { + if (!VOICE_ID_PATTERN.test(voiceId)) { + throw new Error(`Invalid ElevenLabs voiceId: ${voiceId}`); + } + return encodeURIComponent(voiceId); +} + interface ElevenVoice { voice_id: string; name: string; @@ -28,7 +39,7 @@ export class ElevenLabsAudioProvider implements AudioProvider { _opts?: { language?: string } ): Promise<{ audio: Buffer; characters: number }> { const res = await fetch( - `${API}/text-to-speech/${voiceId}?output_format=${OUTPUT_FORMAT}`, + `${API}/text-to-speech/${safeVoiceId(voiceId)}?output_format=${OUTPUT_FORMAT}`, { method: "POST", headers: { diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index b7d5aa6..2cfb13f 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -9,10 +9,19 @@ const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); -// Fail fast in production if the signing secret is missing — sessions/cookies are -// only secure when BETTER_AUTH_SECRET is set. Stay frictionless in dev/test. -if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV === "production") { - throw new Error("BETTER_AUTH_SECRET must be set in production."); +// Fail fast in production if the signing secret is missing, too short, or a known +// placeholder — sessions/cookies are only secure when BETTER_AUTH_SECRET is a strong, +// non-default value. Stay frictionless in dev/test. +const KNOWN_WEAK_SECRETS = new Set([ + "dev-secret-please-change-0123456789abcdef", +]); +const authSecret = process.env.BETTER_AUTH_SECRET; +const secretIsWeak = + !authSecret || authSecret.length < 32 || KNOWN_WEAK_SECRETS.has(authSecret); +if (secretIsWeak && process.env.NODE_ENV === "production") { + throw new Error( + "BETTER_AUTH_SECRET must be set in production to a strong value (>= 32 chars, not a known placeholder)." + ); } export const auth = betterAuth({ @@ -27,11 +36,10 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, - // SECURITY GATE (currently OPEN): unverified emails can sign in. Flip this to - // `true` once email delivery is confirmed working in prod. Left `false` for now - // so existing dev accounts aren't locked out. Verification emails ARE sent on - // signup (see emailVerification.sendOnSignUp below), so users can verify already. - requireEmailVerification: false, + // SECURITY GATE: unverified emails CANNOT sign in. Verification emails are sent + // on signup (see emailVerification.sendOnSignUp below), so users can verify before + // their first login. + requireEmailVerification: true, minPasswordLength: 8, async sendResetPassword({ user, url }) { await sendEmail({ diff --git a/lib/billing/paypal.ts b/lib/billing/paypal.ts index e362751..818f32c 100644 --- a/lib/billing/paypal.ts +++ b/lib/billing/paypal.ts @@ -70,7 +70,11 @@ export async function createPaypalSubscription(args: { }, }), }); - if (!res.ok) throw new Error(`PayPal create subscription ${res.status}: ${await res.text()}`); + if (!res.ok) { + // Log the full upstream detail server-side, but never surface it to clients. + console.error(`[paypal] create subscription ${res.status}: ${await res.text()}`); + throw new Error("PayPal request failed"); + } const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] }; const approveUrl = data.links.find((l) => l.rel === "approve")?.href; if (!approveUrl) throw new Error("PayPal did not return an approval URL"); @@ -94,7 +98,9 @@ export async function cancelPaypalSubscription(id: string, reason = "Customer re body: JSON.stringify({ reason }), }); if (!res.ok && res.status !== 204) { - throw new Error(`PayPal cancel subscription ${res.status}: ${await res.text()}`); + // Log the full upstream detail server-side, but never surface it to clients. + console.error(`[paypal] cancel subscription ${res.status}: ${await res.text()}`); + throw new Error("PayPal request failed"); } } diff --git a/lib/billing/subscription.ts b/lib/billing/subscription.ts index e081254..b8f94fa 100644 --- a/lib/billing/subscription.ts +++ b/lib/billing/subscription.ts @@ -22,16 +22,6 @@ export interface UpsertSubscriptionInput { * provider subscription id, so duplicate/replayed webhooks converge on one row. */ export async function upsertSubscription(input: UpsertSubscriptionInput) { - const existing = input.stripeSubscriptionId - ? await prisma.subscription.findFirst({ - where: { stripeSubscriptionId: input.stripeSubscriptionId }, - }) - : input.paypalSubscriptionId - ? await prisma.subscription.findFirst({ - where: { paypalSubscriptionId: input.paypalSubscriptionId }, - }) - : null; - const data = { provider: input.provider, plan: input.plan, @@ -47,9 +37,25 @@ export async function upsertSubscription(input: UpsertSubscriptionInput) { cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined, }; - if (existing) { - return prisma.subscription.update({ where: { id: existing.id }, data }); + // Atomic upsert keyed on whichever provider subscription id is present on the + // incoming record. The DB-level @@unique on these columns lets concurrent + // webhook retries converge on a single row instead of racing into duplicates. + if (input.stripeSubscriptionId) { + return prisma.subscription.upsert({ + where: { stripeSubscriptionId: input.stripeSubscriptionId }, + create: { referenceId: input.referenceId, ...data }, + update: data, + }); } + if (input.paypalSubscriptionId) { + return prisma.subscription.upsert({ + where: { paypalSubscriptionId: input.paypalSubscriptionId }, + create: { referenceId: input.referenceId, ...data }, + update: data, + }); + } + // Safe fallback: neither provider id is present (no unique key to upsert on), + // so create a fresh row. return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } }); } @@ -85,9 +91,17 @@ export async function getEffectivePlan( activeOrgId?: string | null ): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> { if (activeOrgId) { - const key = await getSubjectPlanKey(activeOrgId); - if (key !== "free") { - return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" }; + // Only grant the org's plan if the user is an actual member of that org. + // A stale/forged activeOrganizationId must not elevate a non-member. + const membership = await prisma.member.findUnique({ + where: { organizationId_userId: { organizationId: activeOrgId, userId } }, + select: { id: true }, + }); + if (membership) { + const key = await getSubjectPlanKey(activeOrgId); + if (key !== "free") { + return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" }; + } } } const key = await getSubjectPlanKey(userId); diff --git a/lib/billing/webhook-log.ts b/lib/billing/webhook-log.ts index d1ccafb..9ba57ab 100644 --- a/lib/billing/webhook-log.ts +++ b/lib/billing/webhook-log.ts @@ -3,7 +3,9 @@ import { prisma } from "@/lib/db"; /** True if we've already handled this provider event (idempotency). */ export async function alreadyProcessed(eventId: string): Promise { const existing = await prisma.webhookEvent.findUnique({ where: { eventId } }); - return !!existing; + // Only a successfully "processed" event is considered handled. Rows logged as + // "failed" (or "skipped") must be reprocessable when the provider retries. + return existing?.status === "processed"; } /** Record a webhook delivery for the admin log (best-effort; unique on eventId). */ diff --git a/lib/email/index.ts b/lib/email/index.ts index 9ac2f10..060fa61 100644 --- a/lib/email/index.ts +++ b/lib/email/index.ts @@ -12,19 +12,30 @@ const FROM = process.env.EMAIL_FROM ?? "Podcast Distribution AI { + // Defense-in-depth header hygiene: strip CR/LF and other control chars so a + // user-controlled subject/recipient can't inject extra email headers. + const safeSubject = stripControlChars(subject).replace(/[\r\n]+/g, " ").trim(); + const safeTo = stripControlChars(to).replace(/[\r\n]+/g, " ").trim(); + const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { - console.info(`[email:dev] To: ${to} | Subject: ${subject}\n${text ?? html}`); + console.info(`[email:dev] To: ${safeTo} | Subject: ${safeSubject}\n${text ?? html}`); return; } const { Resend } = await import("resend"); const resend = new Resend(apiKey); - const { error } = await resend.emails.send({ from: FROM, to, subject, html, text }); + const { error } = await resend.emails.send({ from: FROM, to: safeTo, subject: safeSubject, html, text }); if (error) throw new Error(`Resend error: ${error.message}`); } +/** Remove CR/LF and other ASCII control characters (header-injection defense). */ +function stripControlChars(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\x00-\x1f\x7f]+/g, " "); +} + /** Escape text for safe interpolation into HTML/attribute contexts. */ -function escapeHtml(value: string): string { +export function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(/; -export interface GenerateEpisodePayload { - episodeId: string; +export const generateEpisodePayloadSchema = z.object({ + episodeId: z.string().min(1), /** "full" runs the whole pipeline; the others re-run a single stage. */ - type?: GenerationType; + type: generationTypeSchema.optional(), /** For type="section", the script section to regenerate. */ - sectionId?: string; -} + sectionId: z.string().optional(), +}); +export type GenerateEpisodePayload = z.infer; -export interface RepurposePayload { - episodeId: string; - format: "blog" | "social_thread" | "newsletter"; -} +export const repurposePayloadSchema = z.object({ + episodeId: z.string().min(1), + format: z.enum(["blog", "social_thread", "newsletter"]), +}); +export type RepurposePayload = z.infer; -export interface GenerateSeriesPayload { - seriesId: string; -} +export const generateSeriesPayloadSchema = z.object({ + seriesId: z.string().min(1), +}); +export type GenerateSeriesPayload = z.infer; -export interface EchoPayload { - message: string; - episodeId?: string; -} +export const echoPayloadSchema = z.object({ + message: z.string(), + episodeId: z.string().optional(), +}); +export type EchoPayload = z.infer; /** All queues that must exist before send/work. */ export const ALL_QUEUES: QueueName[] = Object.values(QUEUES); diff --git a/lib/ratelimit/index.ts b/lib/ratelimit/index.ts index cd35be8..a63bb57 100644 --- a/lib/ratelimit/index.ts +++ b/lib/ratelimit/index.ts @@ -1,13 +1,58 @@ -import { RateLimiterMemory } from "rate-limiter-flexible"; +import { Pool } from "pg"; +import { + RateLimiterMemory, + RateLimiterPostgres, + type RateLimiterAbstract, +} from "rate-limiter-flexible"; -// In-memory limiters (no Redis). Fine for a single-instance Plesk deployment; -// swap for RateLimiterPostgres if the app is ever scaled to multiple nodes. -const limiters = new Map(); +/** + * Backend selection (default = in-memory): + * + * - DEFAULT: RateLimiterMemory — per-process counters. Fine for a single + * instance (the Plesk VPS). No DB connection is ever opened in this mode. + * - OPT-IN: set RATE_LIMIT_BACKEND="postgres" *and* provide DATABASE_URL to + * share limits across multiple instances via RateLimiterPostgres. The + * limiter table ("rate_limits") is created automatically by + * rate-limiter-flexible on first use, so no migration is required. + * + * The pg Pool is created lazily on first consume — importing this module never + * connects to the database, so memory mode stays connection-free. + */ +const usePostgres = + process.env.RATE_LIMIT_BACKEND === "postgres" && !!process.env.DATABASE_URL; -function getLimiter(name: string, points: number, durationSec: number): RateLimiterMemory { +const limiters = new Map(); + +// Lazily-created shared pg Pool. Constructing it does NOT open a connection +// (pg connects on first query), and it is only built when a Postgres-backed +// limiter is first needed — so memory mode never creates a pool. +let pgPool: Pool | null = null; +function getPool(): Pool { + if (!pgPool) { + pgPool = new Pool({ connectionString: process.env.DATABASE_URL }); + } + return pgPool; +} + +function getLimiter(name: string, points: number, durationSec: number): RateLimiterAbstract { let limiter = limiters.get(name); if (!limiter) { - limiter = new RateLimiterMemory({ points, duration: durationSec }); + if (usePostgres) { + // The constructor opens the (lazy) pool and ensures the backing table + // exists; ready before the first consume() thanks to the insurance limiter. + limiter = new RateLimiterPostgres({ + storeClient: getPool(), + tableName: "rate_limits", + keyPrefix: name, + points, + duration: durationSec, + // If Postgres is briefly unreachable, degrade to per-process counting + // instead of failing the request outright. + insuranceLimiter: new RateLimiterMemory({ points, duration: durationSec }), + }); + } else { + limiter = new RateLimiterMemory({ points, duration: durationSec }); + } limiters.set(name, limiter); } return limiter; @@ -41,4 +86,5 @@ export const LIMITS = { api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes) read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user + publicMedia: { points: 120, durationSec: 60 }, // anon audio/cover (Range) reqs / min / IP } as const; diff --git a/lib/storage/local.ts b/lib/storage/local.ts index d5bb73d..411c098 100644 --- a/lib/storage/local.ts +++ b/lib/storage/local.ts @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import { promises as fs, createReadStream as fsCreateReadStream } from "node:fs"; import path from "node:path"; import type { StorageProvider } from "./types"; @@ -29,6 +29,17 @@ export class LocalStorageProvider implements StorageProvider { return fs.readFile(resolveSafe(key)); } + /** + * Stream a file (optionally an inclusive byte range) without buffering it all + * into memory. Goes through resolveSafe so path-traversal protection holds. + */ + createReadStream(key: string, range?: { start: number; end: number }): NodeJS.ReadableStream { + const full = resolveSafe(key); + return range + ? fsCreateReadStream(full, { start: range.start, end: range.end }) + : fsCreateReadStream(full); + } + async exists(key: string): Promise { try { await fs.access(resolveSafe(key)); diff --git a/lib/storage/types.ts b/lib/storage/types.ts index 724a0e5..8615f2c 100644 --- a/lib/storage/types.ts +++ b/lib/storage/types.ts @@ -9,6 +9,16 @@ export interface StorageProvider { put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise; /** Read the full object as a Buffer. */ get(key: string): Promise; + /** + * Stream the object (optionally a byte range, inclusive) instead of buffering + * it all into memory — used by the audio/asset routes to avoid memory + * amplification under concurrent load. Optional: providers may omit it, in + * which case callers fall back to get(). + */ + createReadStream?( + key: string, + range?: { start: number; end: number } + ): NodeJS.ReadableStream; /** Whether an object exists at `key`. */ exists(key: string): Promise; /** Remove the object (no-op if missing). */ diff --git a/middleware.ts b/middleware.ts index ef39d22..152da62 100644 --- a/middleware.ts +++ b/middleware.ts @@ -4,35 +4,85 @@ import { NextRequest, NextResponse } from "next/server"; // "__Secure-" variant is used when cookies are served over HTTPS in production. const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"]; +// Authed surfaces that require an optimistic session-cookie check. Anonymous users +// hitting these are redirected to /sign-in. Public/marketing/auth routes are NOT +// listed here, so they are never redirected (CSP still applies to them, below). +const AUTHED_PREFIXES = [ + "/dashboard", + "/episodes", + "/series", + "/usage", + "/billing", + "/team", + "/api-keys", + "/settings", + "/admin", +]; + /** - * Optimistic edge gate: redirect anonymous users away from authed surfaces. - * Only checks for the *presence* of a session cookie — real session validation - * (and admin/role checks) happen in the route-group layouts. Reading the cookie - * directly keeps the middleware bundle free of the auth/jose internals. + * Runs on every request (see matcher). Two responsibilities: + * + * 1. CSP/nonce (all routes): generate a per-request base64 nonce with the Web Crypto + * API (Edge-safe — no node:crypto), expose it on the inbound `x-nonce` request + * header, and set a nonce-based Content-Security-Policy response header. Next.js + * auto-applies this nonce to its own framework