From 2d205008efeab330c9f157e248bd8bf41a5d35db Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 10 Jun 2026 13:57:57 -0600 Subject: [PATCH] fix(tts): restrict audio output URLs to replicate.delivery to prevent SSRF Add explicit host validation to only allow audio output URLs from replicate.delivery and its subdomains, blocking potential SSRF attacks from malicious model outputs. Update tests to cover allowed and disallowed host scenarios. --- src/lib/server/tts/generate.ts | 23 +++++++++++++++++++++-- tests/unit/tts-generate.vitest.spec.ts | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index dcd8bae..7f359bd 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -210,9 +210,23 @@ async function runWithReplicateGate( return operation(); } +// Replicate serves all model output files from replicate.delivery and its +// subdomains (https://replicate.com/docs/topics/predictions/output-files). +// The extraction walker below picks up any URL string a model emits in its +// output, so a malicious third-party model could otherwise return an internal +// address (e.g. http://169.254.169.254/...) and turn this into an SSRF read. +// Restricting fetchable hosts to replicate.delivery closes that without +// affecting legitimate audio outputs, which always come from there. +const REPLICATE_OUTPUT_HOST = 'replicate.delivery'; + +function isAllowedReplicateOutputHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host === REPLICATE_OUTPUT_HOST || host.endsWith(`.${REPLICATE_OUTPUT_HOST}`); +} + function normalizeReplicateUrlCandidate(value: unknown): string | null { if (value instanceof URL) { - return value.toString(); + return isAllowedReplicateOutputHost(value.hostname) ? value.toString() : null; } if (typeof value !== 'string') { @@ -224,13 +238,18 @@ function normalizeReplicateUrlCandidate(value: unknown): string | null { return null; } + // data: URIs are resolved inline by fetch with no network egress, so they + // carry no SSRF risk and need no host check. if (trimmed.startsWith('data:')) { return trimmed; } try { const parsed = new URL(trimmed); - return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? trimmed : null; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + return isAllowedReplicateOutputHost(parsed.hostname) ? trimmed : null; } catch { return null; } diff --git a/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts index e32f6d3..fe3dd99 100644 --- a/tests/unit/tts-generate.vitest.spec.ts +++ b/tests/unit/tts-generate.vitest.spec.ts @@ -46,6 +46,20 @@ describe('replicate output URL extraction', () => { const output = { status: 'ok', value: 123 }; expect(extractReplicateAudioUrl(output)).toBeNull(); }); + + test('allows replicate.delivery subdomains', () => { + expect(extractReplicateAudioUrl('https://pbxt.replicate.delivery/out-0.wav')).toBe( + 'https://pbxt.replicate.delivery/out-0.wav' + ); + }); + + test('rejects URLs from non-replicate hosts (SSRF guard)', () => { + expect(extractReplicateAudioUrl('http://169.254.169.254/latest/meta-data/')).toBeNull(); + expect(extractReplicateAudioUrl('https://evil.example.com/audio.mp3')).toBeNull(); + // a host that merely embeds the allowed host as a substring must not pass + expect(extractReplicateAudioUrl('https://replicate.delivery.evil.com/x.mp3')).toBeNull(); + expect(extractReplicateAudioUrl({ url: () => new URL('http://localhost:6379/') })).toBeNull(); + }); }); describe('TTS upstream cache identity', () => {