fix: address code review issues (SSRF, saveCallLog, deduplication)

- Add path traversal validation for ElevenLabs voice_id and HuggingFace
  model_id URL concatenation (prevents SSRF via ../ sequences)
- Add saveCallLog usage tracking to video and music handlers for
  consistent analytics with imageGeneration.ts
- Extract shared upstreamErrorResponse() and audioStreamResponse()
  helpers to reduce error handling duplication in audioSpeech.ts
- Extract shared upstreamErrorResponse() and isValidPathSegment()
  helpers in audioTranscription.ts
- Add explicit format: "openai" to qwen TTS and STT provider entries
- Remove unused modelId parameter from handleCoquiSpeech and
  handleTortoiseSpeech
- Filter cloud video/music providers by active status in models route
  (local providers with authType: "none" always listed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
duongvdo
2026-03-01 14:34:10 +07:00
parent 4a779dfe3c
commit d97a11a54f
6 changed files with 167 additions and 164 deletions

View File

@@ -99,6 +99,7 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record<string, AudioProvider> = {
baseUrl: "http://localhost:8000/v1/audio/transcriptions",
authType: "none",
authHeader: "none",
format: "openai",
models: [
{ id: "qwen3-asr", name: "Qwen3 ASR" },
],
@@ -203,6 +204,7 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
baseUrl: "http://localhost:8000/v1/audio/speech",
authType: "none",
authHeader: "none",
format: "openai",
models: [
{ id: "qwen3-tts", name: "Qwen3 TTS" },
],

View File

@@ -20,6 +20,42 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts"
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { errorResponse } from "../utils/error.ts";
/**
* Return a CORS error response from an upstream fetch failure
*/
function upstreamErrorResponse(res, errText) {
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
/**
* Return a CORS audio stream response
*/
function audioStreamResponse(res, defaultContentType = "audio/mpeg") {
const contentType = res.headers.get("content-type") || defaultContentType;
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
}
/**
* Validate a path segment to prevent path traversal / SSRF.
* Returns true if safe, false if it contains traversal sequences.
*/
function isValidPathSegment(segment: string): boolean {
return !segment.includes("..") && !segment.includes("//");
}
/**
* Handle Hyperbolic TTS (returns base64 audio in JSON)
*/
@@ -34,14 +70,7 @@ async function handleHyperbolicSpeech(providerConfig, body, token) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const data = await res.json();
@@ -74,25 +103,10 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "audio/mpeg";
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
return audioStreamResponse(res);
}
/**
@@ -103,6 +117,9 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
async function handleElevenLabsSpeech(providerConfig, body, modelId, token) {
// ElevenLabs uses voice_id in URL path; default to "21m00Tcm4TlvDq8ikWAM" (Rachel)
const voiceId = body.voice || "21m00Tcm4TlvDq8ikWAM";
if (!isValidPathSegment(voiceId)) {
return errorResponse(400, "Invalid voice ID");
}
const url = `${providerConfig.baseUrl}/${voiceId}`;
const res = await fetch(url, {
@@ -118,25 +135,10 @@ async function handleElevenLabsSpeech(providerConfig, body, modelId, token) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "audio/mpeg";
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
return audioStreamResponse(res);
}
/**
@@ -158,25 +160,10 @@ async function handleNvidiaTtsSpeech(providerConfig, body, modelId, token) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "audio/wav";
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
return audioStreamResponse(res, "audio/wav");
}
/**
@@ -184,6 +171,9 @@ async function handleNvidiaTtsSpeech(providerConfig, body, modelId, token) {
* POST {baseUrl}/{model_id} with { inputs: text } → audio binary
*/
async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token) {
if (!isValidPathSegment(modelId)) {
return errorResponse(400, "Invalid model ID");
}
const url = `${providerConfig.baseUrl}/${modelId}`;
const res = await fetch(url, {
@@ -196,32 +186,17 @@ async function handleHuggingFaceTtsSpeech(providerConfig, body, modelId, token)
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "audio/wav";
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
return audioStreamResponse(res, "audio/wav");
}
/**
* Handle Coqui TTS (local, no auth)
* POST {baseUrl} with { text, speaker_id } → WAV audio
*/
async function handleCoquiSpeech(providerConfig, body, modelId) {
async function handleCoquiSpeech(providerConfig, body) {
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -232,14 +207,7 @@ async function handleCoquiSpeech(providerConfig, body, modelId) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "audio/wav";
@@ -256,7 +224,7 @@ async function handleCoquiSpeech(providerConfig, body, modelId) {
* Handle Tortoise TTS (local, no auth)
* POST {baseUrl} with { text, voice } → audio binary
*/
async function handleTortoiseSpeech(providerConfig, body, modelId) {
async function handleTortoiseSpeech(providerConfig, body) {
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -267,14 +235,7 @@ async function handleTortoiseSpeech(providerConfig, body, modelId) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const contentType = res.headers.get("content-type") || "audio/wav";
@@ -343,11 +304,11 @@ export async function handleAudioSpeech({ body, credentials }) {
}
if (providerConfig.format === "coqui") {
return handleCoquiSpeech(providerConfig, body, modelId);
return handleCoquiSpeech(providerConfig, body);
}
if (providerConfig.format === "tortoise") {
return handleTortoiseSpeech(providerConfig, body, modelId);
return handleTortoiseSpeech(providerConfig, body);
}
// Default: OpenAI-compatible JSON → audio stream proxy (also used by Qwen3)
@@ -367,26 +328,10 @@ export async function handleAudioSpeech({ body, credentials }) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
// Stream audio response back to client
const contentType = res.headers.get("content-type") || "audio/mpeg";
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": contentType,
"Access-Control-Allow-Origin": getCorsOrigin(),
"Transfer-Encoding": "chunked",
},
});
return audioStreamResponse(res);
} catch (err) {
return errorResponse(500, `Speech request failed: ${err.message}`);
}

View File

@@ -17,6 +17,26 @@ import { getTranscriptionProvider, parseTranscriptionModel } from "../config/aud
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { errorResponse } from "../utils/error.ts";
/**
* Return a CORS error response from an upstream fetch failure
*/
function upstreamErrorResponse(res, errText) {
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
}
/**
* Validate a path segment to prevent path traversal / SSRF.
*/
function isValidPathSegment(segment: string): boolean {
return !segment.includes("..") && !segment.includes("//");
}
/**
* Handle Deepgram transcription (raw binary audio, model via query param)
*/
@@ -37,14 +57,7 @@ async function handleDeepgramTranscription(providerConfig, file, modelId, token)
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const data = await res.json();
@@ -72,14 +85,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
});
if (!uploadRes.ok) {
const errText = await uploadRes.text();
return new Response(errText, {
status: uploadRes.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(uploadRes, await uploadRes.text());
}
const { upload_url } = await uploadRes.json();
@@ -99,14 +105,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke
});
if (!submitRes.ok) {
const errText = await submitRes.text();
return new Response(errText, {
status: submitRes.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(submitRes, await submitRes.text());
}
const { id: transcriptId } = await submitRes.json();
@@ -155,14 +154,7 @@ async function handleNvidiaTranscription(providerConfig, file, modelId, token) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const data = await res.json();
@@ -177,6 +169,9 @@ async function handleNvidiaTranscription(providerConfig, file, modelId, token) {
* POST raw binary audio to {baseUrl}/{model_id}, returns { text }
*/
async function handleHuggingFaceTranscription(providerConfig, file, modelId, token) {
if (!isValidPathSegment(modelId)) {
return errorResponse(400, "Invalid model ID");
}
const url = `${providerConfig.baseUrl}/${modelId}`;
const arrayBuffer = await file.arrayBuffer();
@@ -190,14 +185,7 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const data = await res.json();
@@ -291,14 +279,7 @@ export async function handleAudioTranscription({ formData, credentials }) {
});
if (!res.ok) {
const errText = await res.text();
return new Response(errText, {
status: res.status,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": getCorsOrigin(),
},
});
return upstreamErrorResponse(res, await res.text());
}
const data = await res.text();

View File

@@ -21,6 +21,7 @@ import {
fetchComfyOutput,
extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts";
import { saveCallLog } from "@/lib/usageDb";
/**
* Handle music generation request
@@ -57,6 +58,7 @@ export async function handleMusicGeneration({ body, credentials, log }) {
* Submits an audio generation workflow (Stable Audio / MusicGen), polls, fetches output
*/
async function handleComfyUIMusicGeneration({ model, provider, providerConfig, body, log }) {
const startTime = Date.now();
const duration = body.duration || 10; // seconds
// Audio generation workflow template for ComfyUI
@@ -127,12 +129,31 @@ async function handleComfyUIMusicGeneration({ model, provider, providerConfig, b
audioFiles.push({ b64_json: base64, format: "wav" });
}
saveCallLog({
method: "POST",
path: "/v1/music/generations",
status: 200,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
responseBody: { audio_count: audioFiles.length },
}).catch(() => {});
return {
success: true,
data: { created: Math.floor(Date.now() / 1000), data: audioFiles },
};
} catch (err) {
if (log) log.error("MUSIC", `${provider} comfyui error: ${err.message}`);
saveCallLog({
method: "POST",
path: "/v1/music/generations",
status: 502,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Music provider error: ${err.message}` };
}
}

View File

@@ -22,6 +22,7 @@ import {
fetchComfyOutput,
extractComfyOutputFiles,
} from "../utils/comfyuiClient.ts";
import { saveCallLog } from "@/lib/usageDb";
/**
* Handle video generation request
@@ -62,6 +63,7 @@ export async function handleVideoGeneration({ body, credentials, log }) {
* Submits an AnimateDiff or SVD workflow, polls for completion, fetches output video
*/
async function handleComfyUIVideoGeneration({ model, provider, providerConfig, body, log }) {
const startTime = Date.now();
const [width, height] = (body.size || "512x512").split("x").map(Number);
const frames = body.frames || 16;
@@ -137,12 +139,31 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b
videos.push({ b64_json: base64, format: "webp" });
}
saveCallLog({
method: "POST",
path: "/v1/videos/generations",
status: 200,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
responseBody: { videos_count: videos.length },
}).catch(() => {});
return {
success: true,
data: { created: Math.floor(Date.now() / 1000), data: videos },
};
} catch (err) {
if (log) log.error("VIDEO", `${provider} comfyui error: ${err.message}`);
saveCallLog({
method: "POST",
path: "/v1/videos/generations",
status: 502,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` };
}
}
@@ -152,6 +173,7 @@ async function handleComfyUIVideoGeneration({ model, provider, providerConfig, b
* POST to the AnimateDiff API endpoint
*/
async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, body, log }) {
const startTime = Date.now();
const [width, height] = (body.size || "512x512").split("x").map(Number);
const url = `${providerConfig.baseUrl}/animatediff/v1/generate`;
@@ -181,6 +203,15 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b
if (!response.ok) {
const errorText = await response.text();
if (log) log.error("VIDEO", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
saveCallLog({
method: "POST",
path: "/v1/videos/generations",
status: response.status,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: errorText.slice(0, 500),
}).catch(() => {});
return { success: false, status: response.status, error: errorText };
}
@@ -195,12 +226,31 @@ async function handleSDWebUIVideoGeneration({ model, provider, providerConfig, b
}
}
saveCallLog({
method: "POST",
path: "/v1/videos/generations",
status: 200,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
responseBody: { videos_count: videos.length },
}).catch(() => {});
return {
success: true,
data: { created: Math.floor(Date.now() / 1000), data: videos },
};
} catch (err) {
if (log) log.error("VIDEO", `${provider} sdwebui error: ${err.message}`);
saveCallLog({
method: "POST",
path: "/v1/videos/generations",
status: 502,
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
}).catch(() => {});
return { success: false, status: 502, error: `Video provider error: ${err.message}` };
}
}

View File

@@ -14,8 +14,8 @@ import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry.ts";
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts";
import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts";
import { getAllVideoModels, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
import { getAllMusicModels, getMusicProvider } from "@omniroute/open-sse/config/musicRegistry.ts";
const FALLBACK_ALIAS_TO_PROVIDER = {
ag: "antigravity",
@@ -314,6 +314,8 @@ export async function GET(request: Request) {
// Add video models (local providers always listed, cloud filtered by active)
for (const videoModel of getAllVideoModels()) {
const vConfig = getVideoProvider(videoModel.provider);
if (vConfig?.authType !== "none" && !isProviderActive(videoModel.provider)) continue;
models.push({
id: videoModel.id,
object: "model",
@@ -325,6 +327,8 @@ export async function GET(request: Request) {
// Add music models (local providers always listed, cloud filtered by active)
for (const musicModel of getAllMusicModels()) {
const mConfig = getMusicProvider(musicModel.provider);
if (mConfig?.authType !== "none" && !isProviderActive(musicModel.provider)) continue;
models.push({
id: musicModel.id,
object: "model",