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', () => {