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
This commit is contained in:
Leon Serfaty
2026-06-20 20:59:03 -04:00
parent cd1d6a1a28
commit 51c541ad22
21 changed files with 489 additions and 152 deletions
+5 -2
View File
@@ -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}`,
+12 -1
View File
@@ -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: {
+17 -9
View File
@@ -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({
+8 -2
View File
@@ -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");
}
}
+29 -15
View File
@@ -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);
+3 -1
View File
@@ -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<boolean> {
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). */
+14 -3
View File
@@ -12,19 +12,30 @@ const FROM = process.env.EMAIL_FROM ?? "Podcast Distribution AI <noreply@podcast
* console (useful in local dev before RESEND_API_KEY is set).
*/
export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise<void> {
// 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, "&amp;")
.replace(/</g, "&lt;")
+31 -17
View File
@@ -1,5 +1,7 @@
/** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */
import { z } from "zod";
export const QUEUES = {
generateEpisode: "episode.generate",
generateSeries: "series.generate",
@@ -10,29 +12,41 @@ export const QUEUES = {
export type QueueName = (typeof QUEUES)[keyof typeof QUEUES];
export type GenerationType = "full" | "script" | "audio" | "art" | "section" | "repurpose";
export const generationTypeSchema = z.enum([
"full",
"script",
"audio",
"art",
"section",
"repurpose",
]);
export type GenerationType = z.infer<typeof generationTypeSchema>;
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<typeof generateEpisodePayloadSchema>;
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<typeof repurposePayloadSchema>;
export interface GenerateSeriesPayload {
seriesId: string;
}
export const generateSeriesPayloadSchema = z.object({
seriesId: z.string().min(1),
});
export type GenerateSeriesPayload = z.infer<typeof generateSeriesPayloadSchema>;
export interface EchoPayload {
message: string;
episodeId?: string;
}
export const echoPayloadSchema = z.object({
message: z.string(),
episodeId: z.string().optional(),
});
export type EchoPayload = z.infer<typeof echoPayloadSchema>;
/** All queues that must exist before send/work. */
export const ALL_QUEUES: QueueName[] = Object.values(QUEUES);
+52 -6
View File
@@ -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<string, RateLimiterMemory>();
/**
* 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<string, RateLimiterAbstract>();
// 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;
+12 -1
View File
@@ -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<boolean> {
try {
await fs.access(resolveSafe(key));
+10
View File
@@ -9,6 +9,16 @@ export interface StorageProvider {
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
/** Read the full object as a Buffer. */
get(key: string): Promise<Buffer>;
/**
* 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<boolean>;
/** Remove the object (no-op if missing). */