fix(elevenlabs): reject malformed base URL overrides (#105163)

* fix(elevenlabs): fall back to default base URL when config value is malformed

* fix(elevenlabs): reject malformed and non-http(s) base URL overrides

* fix(elevenlabs): redact configured URL from base URL validation errors

* fix(elevenlabs): preserve realtime WebSocket overrides

* test(elevenlabs): avoid stale realtime test overlap

* style(elevenlabs): format realtime URL assertion

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
dwc1997
2026-07-18 14:08:45 +08:00
committed by GitHub
parent 2612728b3b
commit 8b03d5ce24
3 changed files with 119 additions and 10 deletions
@@ -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,
+77
View File
@@ -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");
});
});
+40 -2
View File
@@ -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(/\/+$/, "");
}