diff --git a/extensions/elevenlabs/realtime-transcription-provider.ts b/extensions/elevenlabs/realtime-transcription-provider.ts index 9f918396c2ab..07ee3d6b73f2 100644 --- a/extensions/elevenlabs/realtime-transcription-provider.ts +++ b/extensions/elevenlabs/realtime-transcription-provider.ts @@ -14,7 +14,7 @@ import { parseFiniteNumber as readFiniteNumber, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js"; -import { normalizeElevenLabsBaseUrl } from "./shared.js"; +import { normalizeElevenLabsRealtimeBaseUrl } from "./shared.js"; type ElevenLabsRealtimeTranscriptionProviderConfig = { apiKey?: string; @@ -130,12 +130,6 @@ function normalizeProviderConfig( }; } -function normalizeElevenLabsRealtimeBaseUrl(value?: string): string { - const url = new URL(normalizeElevenLabsBaseUrl(value)); - url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; - return url.toString().replace(/\/+$/, ""); -} - function toElevenLabsRealtimeWsUrl(config: ElevenLabsRealtimeTranscriptionSessionConfig): string { const url = new URL( `${normalizeElevenLabsRealtimeBaseUrl(config.baseUrl)}/v1/speech-to-text/realtime`, @@ -277,7 +271,7 @@ export function buildElevenLabsRealtimeTranscriptionProvider(): RealtimeTranscri return createElevenLabsRealtimeTranscriptionSession({ ...req, apiKey, - baseUrl: normalizeElevenLabsBaseUrl(config.baseUrl), + baseUrl: normalizeElevenLabsRealtimeBaseUrl(config.baseUrl), modelId: config.modelId ?? ELEVENLABS_REALTIME_DEFAULT_MODEL, audioFormat: config.audioFormat ?? ELEVENLABS_REALTIME_DEFAULT_AUDIO_FORMAT, sampleRate: config.sampleRate ?? ELEVENLABS_REALTIME_DEFAULT_SAMPLE_RATE, diff --git a/extensions/elevenlabs/shared.test.ts b/extensions/elevenlabs/shared.test.ts new file mode 100644 index 000000000000..e60c03e105d3 --- /dev/null +++ b/extensions/elevenlabs/shared.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ELEVENLABS_BASE_URL, + normalizeElevenLabsBaseUrl, + normalizeElevenLabsRealtimeBaseUrl, +} from "./shared.js"; + +describe("normalizeElevenLabsBaseUrl", () => { + it("returns the default when the base URL is missing or blank", () => { + expect(normalizeElevenLabsBaseUrl(undefined)).toBe(DEFAULT_ELEVENLABS_BASE_URL); + expect(normalizeElevenLabsBaseUrl(" ")).toBe(DEFAULT_ELEVENLABS_BASE_URL); + }); + + it("trims and strips trailing slashes from a valid URL", () => { + expect(normalizeElevenLabsBaseUrl(" https://custom.example.com/ ")).toBe( + "https://custom.example.com", + ); + expect(normalizeElevenLabsBaseUrl("http://localhost:8080")).toBe("http://localhost:8080"); + }); + + it("rejects an explicit malformed override instead of silently retargeting", () => { + // An operator's explicit endpoint must not be swapped for the default; fail + // actionably here so the request cannot target an unintended host, and so a + // downstream `new URL(...)` never throws an opaque TypeError. + expect(() => normalizeElevenLabsBaseUrl("not a url")).toThrow(/Invalid ElevenLabs baseUrl/); + expect(() => normalizeElevenLabsBaseUrl("////")).toThrow(/Invalid ElevenLabs baseUrl/); + }); + + it("rejects a parseable but unsupported (non-HTTP(S)) scheme", () => { + // `new URL()` accepts ftp:/data:/custom schemes, but downstream fetch and + // WebSocket paths only support http(s) ElevenLabs endpoints. + expect(() => normalizeElevenLabsBaseUrl("ftp://files.example.com")).toThrow( + /unsupported scheme/, + ); + expect(() => normalizeElevenLabsBaseUrl("data:text/plain,x")).toThrow(/unsupported scheme/); + }); + + it("does not leak URL credentials or sensitive query values in validation errors", () => { + // Rejection errors may reach logs/diagnostics; they must not echo userinfo + // or credential-bearing query parameters from the configured baseUrl. + const nonHttp = "ftp://user:sup3r-secret@files.example.com/x?api_key=leak-me"; + expect(() => normalizeElevenLabsBaseUrl(nonHttp)).toThrow(/unsupported scheme/); + try { + normalizeElevenLabsBaseUrl(nonHttp); + } catch (error) { + const message = (error as Error).message; + expect(message).not.toContain("sup3r-secret"); + expect(message).not.toContain("leak-me"); + expect(message).not.toContain("api_key"); + } + // A malformed value that embeds a token must not be echoed either. + const malformed = "http://:not a url token=abcd1234secret"; + try { + normalizeElevenLabsBaseUrl(malformed); + } catch (error) { + expect((error as Error).message).not.toContain("abcd1234secret"); + } + }); + + it("keeps every accepted result parseable as an http(s) URL", () => { + for (const input of ["https://ok.example.com/", "http://a.b:9000"]) { + const normalized = normalizeElevenLabsBaseUrl(input); + const url = new URL(normalized); + expect(["http:", "https:"]).toContain(url.protocol); + } + }); + + it("maps HTTP endpoints and preserves explicit WebSocket endpoints for realtime", () => { + expect(normalizeElevenLabsRealtimeBaseUrl("https://api.example.com/")).toBe( + "wss://api.example.com", + ); + expect(normalizeElevenLabsRealtimeBaseUrl("wss://realtime.example.com/")).toBe( + "wss://realtime.example.com", + ); + expect(normalizeElevenLabsRealtimeBaseUrl("ws://localhost:8080/")).toBe("ws://localhost:8080"); + }); +}); diff --git a/extensions/elevenlabs/shared.ts b/extensions/elevenlabs/shared.ts index 370e5844330b..839743b7a02e 100644 --- a/extensions/elevenlabs/shared.ts +++ b/extensions/elevenlabs/shared.ts @@ -5,7 +5,45 @@ export function isValidElevenLabsVoiceId(voiceId: string): boolean { return /^[a-zA-Z0-9]{10,40}$/.test(voiceId); } -export function normalizeElevenLabsBaseUrl(baseUrl?: string): string { +function normalizeElevenLabsBaseUrlWithProtocols( + baseUrl: string | undefined, + allowedProtocols: readonly string[], +): string { const trimmed = baseUrl?.trim(); - return trimmed?.replace(/\/+$/, "") || DEFAULT_ELEVENLABS_BASE_URL; + // Only an absent/blank value falls back to the default endpoint. An explicit + // custom endpoint is operator intent, so never silently retarget it. + if (!trimmed) { + return DEFAULT_ELEVENLABS_BASE_URL; + } + const normalized = trimmed.replace(/\/+$/, ""); + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + // Do not interpolate the raw value: an explicit baseUrl may embed userinfo + // (https://user:token@host) or credential-bearing query params that would + // otherwise leak into logs/diagnostics via this error. + throw new Error("Invalid ElevenLabs baseUrl: value is not a valid URL"); + } + if (!allowedProtocols.includes(parsed.protocol)) { + // Only the scheme is safe to surface; the rest of the URL may carry secrets. + throw new Error( + `Invalid ElevenLabs baseUrl: unsupported scheme "${parsed.protocol}" (expected ${allowedProtocols.join(" or ")})`, + ); + } + return normalized; +} + +export function normalizeElevenLabsBaseUrl(baseUrl?: string): string { + return normalizeElevenLabsBaseUrlWithProtocols(baseUrl, ["http:", "https:"]); +} + +export function normalizeElevenLabsRealtimeBaseUrl(baseUrl?: string): string { + const url = new URL( + normalizeElevenLabsBaseUrlWithProtocols(baseUrl, ["http:", "https:", "ws:", "wss:"]), + ); + if (url.protocol === "http:" || url.protocol === "https:") { + url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + } + return url.toString().replace(/\/+$/, ""); }