fix(stt): validate custom base url

This commit is contained in:
Bohdan Triapitsyn
2026-04-11 22:51:37 +03:00
parent 3746d99a85
commit 36b2bcf4f9
4 changed files with 84 additions and 7 deletions
+3 -1
View File
@@ -5,10 +5,12 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API,
## Entrypoints and structure
- `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`.
- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*` and `/api/tts/*` endpoints.
- `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*`, `/api/tts/*`, and `/api/stt/*` endpoints.
- `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability.
- `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration.
- `packages/web/server/lib/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API.
- `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints.
- `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints.
## Public exports
+62
View File
@@ -0,0 +1,62 @@
const LOCAL_BASE_URL_HOSTS = new Set([
'localhost',
'127.0.0.1',
'::1',
'host.docker.internal',
]);
const isEnvFlagEnabled = (value) => {
if (value === true || value === 1) return true;
if (typeof value !== 'string') return false;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true';
};
const normalizeHostname = (hostname) => {
if (typeof hostname !== 'string') return '';
const trimmed = hostname.trim().toLowerCase();
if (!trimmed) return '';
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed.slice(1, -1);
}
return trimmed;
};
const isAllowedLocalHost = (hostname) => {
const normalized = normalizeHostname(hostname);
return LOCAL_BASE_URL_HOSTS.has(normalized);
};
export const normalizeCustomOpenAIBaseURL = (value) => {
if (typeof value !== 'string' || !value.trim()) {
return { value: undefined };
}
let parsed;
try {
parsed = new URL(value.trim());
} catch {
return { error: 'Custom server URL is invalid' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { error: 'Custom server URL must use http or https' };
}
if (parsed.username || parsed.password) {
return { error: 'Custom server URL must not include credentials' };
}
const allowRemote = isEnvFlagEnabled(process.env.OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS);
if (!allowRemote && !isAllowedLocalHost(parsed.hostname)) {
return {
error: 'Remote custom server URLs are disabled. Set OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS=true to allow this host.',
};
}
parsed.hash = '';
parsed.search = '';
const pathname = parsed.pathname.replace(/\/+$/, '');
const normalizedPath = pathname.length > 0 ? pathname : '';
return { value: `${parsed.protocol}//${parsed.host}${normalizedPath}` };
};
+7 -3
View File
@@ -234,9 +234,13 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
const { transcribeAudio } = await import('./stt.js');
const mimeType = (req.headers['content-type'] || 'audio/webm').split(',')[0].trim();
const baseURL = req.headers['x-base-url'];
const model = req.headers['x-model'] || 'deepdml/faster-whisper-large-v3-turbo-ct2';
const language = req.headers['x-language'] || undefined;
const baseURL = typeof req.headers['x-base-url'] === 'string' ? req.headers['x-base-url'].trim() : '';
const model = typeof req.headers['x-model'] === 'string' && req.headers['x-model'].trim().length > 0
? req.headers['x-model'].trim()
: 'deepdml/faster-whisper-large-v3-turbo-ct2';
const language = typeof req.headers['x-language'] === 'string' && req.headers['x-language'].trim().length > 0
? req.headers['x-language'].trim()
: undefined;
if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: 'Audio data is required' });
+12 -3
View File
@@ -6,6 +6,7 @@
*/
import OpenAI, { toFile } from 'openai';
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
/**
* Transcribe an audio buffer via an OpenAI-compatible /v1/audio/transcriptions endpoint.
@@ -19,12 +20,20 @@ import OpenAI, { toFile } from 'openai';
* @returns {Promise<string>} Transcribed text
*/
export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, language }) {
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
if (normalizedBaseURLResult.error) {
throw new Error(normalizedBaseURLResult.error);
}
const normalizedBaseURL = normalizedBaseURLResult.value;
if (!normalizedBaseURL) {
throw new Error('Custom server URL is required');
}
const clientOpts = {
apiKey: process.env.OPENAI_API_KEY || 'not-required',
};
if (baseURL) {
clientOpts.baseURL = baseURL;
}
clientOpts.baseURL = normalizedBaseURL;
const client = new OpenAI(clientOpts);