diff --git a/changelog.d/fixes/12574-elevenlabs-policy-enforcement.md b/changelog.d/fixes/12574-elevenlabs-policy-enforcement.md new file mode 100644 index 0000000000..930c6ed52c --- /dev/null +++ b/changelog.d/fixes/12574-elevenlabs-policy-enforcement.md @@ -0,0 +1 @@ +- fix(api): enforce API key policy (budget/rate-limit/schedule/endpoint scoping) on the ElevenLabs speech-to-text, text-to-speech and voices proxy routes (#12574) diff --git a/src/app/api/v1/_shared/elevenLabsProxy.ts b/src/app/api/v1/_shared/elevenLabsProxy.ts index 2388764f87..564e6c1544 100644 --- a/src/app/api/v1/_shared/elevenLabsProxy.ts +++ b/src/app/api/v1/_shared/elevenLabsProxy.ts @@ -11,6 +11,7 @@ import { sanitizeErrorMessage, } from "@omniroute/open-sse/utils/error.ts"; import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1"; const ALLOWED_RESPONSE_HEADERS = [ @@ -48,6 +49,9 @@ export async function proxyElevenLabsRequest( pathname: string, init: Omit = {} ): Promise { + const policy = await enforceApiKeyPolicy(request, null); + if (policy.rejection) return policy.rejection; + const credentials = (await getProviderCredentialsWithQuotaPreflight( "elevenlabs" )) as ElevenLabsCredentials | null; diff --git a/src/shared/constants/endpointCategories.ts b/src/shared/constants/endpointCategories.ts index 9976af5120..fc3903d209 100644 --- a/src/shared/constants/endpointCategories.ts +++ b/src/shared/constants/endpointCategories.ts @@ -48,6 +48,12 @@ export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [ description: "Text-to-speech and speech-to-text", prefixes: ["/v1/audio"], }, + { + id: "elevenlabs", + label: "ElevenLabs Voice", + description: "Native ElevenLabs speech-to-text, text-to-speech and voices", + prefixes: ["/v1/speech-to-text", "/v1/text-to-speech", "/v1/voices"], + }, { id: "video", label: "Video", diff --git a/tests/unit/elevenlabs-policy.test.ts b/tests/unit/elevenlabs-policy.test.ts new file mode 100644 index 0000000000..06cc6bb23a --- /dev/null +++ b/tests/unit/elevenlabs-policy.test.ts @@ -0,0 +1,138 @@ +/** + * Regression tests for #12574: the ElevenLabs proxy routes (speech-to-text, + * text-to-speech/[voiceId], voices) must go through enforceApiKeyPolicy() + * like every other billable /v1/* route, and their endpoint category must + * be resolvable so allowedEndpoints scoping takes effect. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-elevenlabs-policy-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "elevenlabs-policy-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const readCache = await import("../../src/lib/db/readCache.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const costRules = await import("../../src/domain/costRules.ts"); +const voicesRoute = await import("../../src/app/api/v1/voices/route.ts"); +const speechRoute = await import("../../src/app/api/v1/text-to-speech/[voiceId]/route.ts"); +const transcriptionRoute = await import("../../src/app/api/v1/speech-to-text/route.ts"); + +const originalFetch = globalThis.fetch; +const ELEVENLABS_API_KEY = "test-elevenlabs-key"; + +function seedElevenLabsCredential() { + const now = new Date().toISOString(); + core + .getDbInstance() + .prepare( + `INSERT OR REPLACE INTO provider_connections + (id, provider, auth_type, is_active, api_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run("elevenlabs-policy-test", "elevenlabs", "apikey", 1, ELEVENLABS_API_KEY, now, now); + readCache.invalidateDbCache("connections"); +} + +function stubUpstream() { + let called = false; + globalThis.fetch = (async () => { + called = true; + return Response.json({ voices: [] }); + }) as typeof fetch; + return () => called; +} + +async function callAllThreeRoutes(apiKey: string) { + const auth = { Authorization: `Bearer ${apiKey}` }; + const voicesResponse = await voicesRoute.GET( + new Request("http://localhost/v1/voices", { headers: auth }) + ); + const speechResponse = await speechRoute.POST( + new Request("http://localhost/v1/text-to-speech/voice_123", { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: "{}", + }), + { params: Promise.resolve({ voiceId: "voice_123" }) } + ); + const transcriptionResponse = await transcriptionRoute.POST( + new Request("http://localhost/v1/speech-to-text", { + method: "POST", + headers: auth, + body: new FormData(), + }) + ); + return [voicesResponse, speechResponse, transcriptionResponse]; +} + +test.beforeEach(async () => { + await core.ensureDbInitialized(); + seedElevenLabsCredential(); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + apiKeysDb.resetApiKeyState(); + costRules.resetCostData(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("an over-budget key is rejected with 429 on all three ElevenLabs routes without reaching upstream", async () => { + const key = await apiKeysDb.createApiKey("EL Budget Key", "machine-elevenlabs-budget"); + costRules.setBudget(key.id, { dailyLimitUsd: 1, warningThreshold: 0.5 }); + costRules.recordCost(key.id, 2); + + const upstreamWasCalled = stubUpstream(); + const [voicesResponse, speechResponse, transcriptionResponse] = await callAllThreeRoutes( + key.key + ); + + for (const response of [voicesResponse, speechResponse, transcriptionResponse]) { + assert.equal(response.status, 429); + const body = (await response.json()) as { error?: { message?: string } }; + assert.match(body.error?.message ?? "", /Daily budget exceeded/); + } + assert.equal(upstreamWasCalled(), false); +}); + +test("a key scoped away from the elevenlabs category is rejected with 403 on all three routes", async () => { + const key = await apiKeysDb.createApiKey("EL Scoped Key", "machine-elevenlabs-scope"); + await apiKeysDb.updateApiKeyPermissions(key.id, { allowedEndpoints: ["chat"] }); + + const upstreamWasCalled = stubUpstream(); + const [voicesResponse, speechResponse, transcriptionResponse] = await callAllThreeRoutes( + key.key + ); + + for (const response of [voicesResponse, speechResponse, transcriptionResponse]) { + assert.equal(response.status, 403); + const body = (await response.json()) as { error?: { message?: string } }; + assert.match(body.error?.message ?? "", /elevenlabs.*not allowed/i); + } + assert.equal(upstreamWasCalled(), false); +}); + +test("an unrestricted key still proxies through to the upstream successfully", async () => { + const key = await apiKeysDb.createApiKey("EL Unrestricted Key", "machine-elevenlabs-ok"); + + const upstreamWasCalled = stubUpstream(); + const response = await voicesRoute.GET( + new Request("http://localhost/v1/voices", { + headers: { Authorization: `Bearer ${key.key}` }, + }) + ); + + assert.equal(response.status, 200); + assert.equal(upstreamWasCalled(), true); +}); diff --git a/tests/unit/endpoint-categories.test.ts b/tests/unit/endpoint-categories.test.ts index 3ff73d0af2..fd973573d5 100644 --- a/tests/unit/endpoint-categories.test.ts +++ b/tests/unit/endpoint-categories.test.ts @@ -104,6 +104,18 @@ test("resolveEndpointCategory: maps /v1/agents/tasks to 'agents'", () => { assert.equal(resolveEndpointCategory("/v1/agents/tasks"), "agents"); }); +test("resolveEndpointCategory: maps /v1/speech-to-text to 'elevenlabs'", () => { + assert.equal(resolveEndpointCategory("/v1/speech-to-text"), "elevenlabs"); +}); + +test("resolveEndpointCategory: maps /v1/text-to-speech/voice_123 to 'elevenlabs'", () => { + assert.equal(resolveEndpointCategory("/v1/text-to-speech/voice_123"), "elevenlabs"); +}); + +test("resolveEndpointCategory: maps /v1/voices to 'elevenlabs'", () => { + assert.equal(resolveEndpointCategory("/v1/voices"), "elevenlabs"); +}); + test("resolveEndpointCategory: returns null for unknown path", () => { assert.equal(resolveEndpointCategory("/v1/unknown"), null); });