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.
This commit is contained in:
Richard R 2026-06-10 13:57:57 -06:00
parent cecb2489e1
commit 2d205008ef
2 changed files with 35 additions and 2 deletions

View file

@ -210,9 +210,23 @@ async function runWithReplicateGate<T>(
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;
}

View file

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