mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user