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: {
|
||||
|
||||
Reference in New Issue
Block a user