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:
@@ -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}`,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
|
||||
+31
-17
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user