Skip to content

Commit 00e2320

Browse files
committed
fix(security): meter and throttle the deployed-chat TTS relay
POST /api/proxy/tts/stream treated "a live public chat exists" as authorization to spend the platform ElevenLabs key. A public chat id is handed to every visitor, so any anonymous caller could synthesize speech with no length cap, no rate limit and no usage accounting. Bring the relay in line with its STT sibling (/api/speech/token): - Resolve the chat's workspace and bill synthesized characters to that payer via a new `voice-output` usage source, so spend is attributable and counts against the plan's usage limit (402 once exceeded). - Throttle per IP before any database work, and per chat afterwards, to bound both one caller hammering many chats and many callers hammering one chat. - Cap `text` at 2000 characters and allowlist `voiceId`/`modelId`, so the caller can no longer choose an unbounded charge, a premium or cloned voice, or the billing model. - Drop `Access-Control-Allow-Origin: *`, which let any third-party page read the audio; deployed chat and the Office embed are same-origin.
1 parent b741176 commit 00e2320

11 files changed

Lines changed: 18829 additions & 21 deletions

File tree

apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { generateId } from '@sim/utils/id'
6+
import { DEFAULT_TTS_VOICE_ID } from '@/lib/api/contracts/media/tts-stream'
67
import { noop } from '@/lib/core/utils/request'
78
import {
89
AGENT_STREAM_PROTOCOL_HEADER,
@@ -49,7 +50,7 @@ interface ChatRequestPayload {
4950
}
5051

5152
const DEFAULT_VOICE_SETTINGS = {
52-
voiceId: 'cgSgspJ2msm6clMCkdW9', // Default ElevenLabs voice (Jessica) — Flash v2.5-optimized
53+
voiceId: DEFAULT_TTS_VOICE_ID,
5354
}
5455

5556
/**
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
queueTableRows,
7+
resetDbChainMock,
8+
resetEnvMock,
9+
schemaMock,
10+
setEnv,
11+
} from '@sim/testing'
12+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const {
15+
mockRecordUsage,
16+
mockCheckActorUsageLimits,
17+
mockResolveSystemBillingAttribution,
18+
mockCheckAttributedUsageLimits,
19+
mockToBillingContext,
20+
mockCheckAndBillPayerOverageThreshold,
21+
mockCheckRateLimitDirect,
22+
} = vi.hoisted(() => ({
23+
mockRecordUsage: vi.fn(),
24+
mockCheckActorUsageLimits: vi.fn(),
25+
mockResolveSystemBillingAttribution: vi.fn(),
26+
mockCheckAttributedUsageLimits: vi.fn(),
27+
mockToBillingContext: vi.fn(),
28+
mockCheckAndBillPayerOverageThreshold: vi.fn(),
29+
mockCheckRateLimitDirect: vi.fn(),
30+
}))
31+
32+
const SYSTEM_BILLING_ATTRIBUTION = {
33+
actorUserId: 'payer-1',
34+
workspaceId: 'ws-1',
35+
organizationId: 'org-1',
36+
billedAccountUserId: 'payer-1',
37+
billingEntity: { type: 'organization' as const, id: 'org-1' },
38+
billingPeriod: {
39+
start: '2026-07-01T00:00:00.000Z',
40+
end: '2026-08-01T00:00:00.000Z',
41+
},
42+
payerSubscription: null,
43+
}
44+
45+
vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage }))
46+
47+
vi.mock('@/lib/billing/core/billing-attribution', () => ({
48+
resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
49+
checkAttributedUsageLimits: mockCheckAttributedUsageLimits,
50+
toBillingContext: mockToBillingContext,
51+
}))
52+
53+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
54+
checkActorUsageLimits: mockCheckActorUsageLimits,
55+
}))
56+
57+
vi.mock('@/lib/billing/threshold-billing', () => ({
58+
checkAndBillPayerOverageThreshold: mockCheckAndBillPayerOverageThreshold,
59+
}))
60+
61+
vi.mock('@/lib/core/rate-limiter', () => ({
62+
RateLimiter: class {
63+
checkRateLimitDirect = mockCheckRateLimitDirect
64+
},
65+
}))
66+
67+
vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: vi.fn(() => false) }))
68+
69+
import { DEFAULT_TTS_VOICE_ID, MAX_TTS_TEXT_LENGTH } from '@/lib/api/contracts/media/tts-stream'
70+
import { POST } from '@/app/api/proxy/tts/stream/route'
71+
72+
const publicChatRow = {
73+
id: 'chat-1',
74+
userId: 'owner-1',
75+
isActive: true,
76+
authType: 'public',
77+
password: null,
78+
workspaceId: 'ws-1',
79+
}
80+
81+
function validBody(overrides: Record<string, unknown> = {}) {
82+
return {
83+
text: 'Hello from the deployed chat.',
84+
voiceId: DEFAULT_TTS_VOICE_ID,
85+
chatId: 'chat-1',
86+
...overrides,
87+
}
88+
}
89+
90+
/** Minimal ElevenLabs stub returning a readable audio body. */
91+
function mockElevenLabsAudio() {
92+
global.fetch = vi.fn().mockResolvedValue({
93+
ok: true,
94+
status: 200,
95+
statusText: 'OK',
96+
body: new ReadableStream({
97+
start(controller) {
98+
controller.enqueue(new Uint8Array([0x49, 0x44, 0x33]))
99+
controller.close()
100+
},
101+
}),
102+
// double-cast-allowed: minimal fetch stub for the ElevenLabs stream call
103+
}) as unknown as typeof fetch
104+
}
105+
106+
beforeEach(() => {
107+
vi.clearAllMocks()
108+
resetDbChainMock()
109+
setEnv({ ELEVENLABS_API_KEY: 'test-key' })
110+
mockCheckRateLimitDirect.mockResolvedValue({ allowed: true, remaining: 10 })
111+
mockRecordUsage.mockResolvedValue(undefined)
112+
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
113+
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
114+
mockResolveSystemBillingAttribution.mockResolvedValue(SYSTEM_BILLING_ATTRIBUTION)
115+
mockToBillingContext.mockImplementation(
116+
(attribution: { billingEntity: { type: 'organization' | 'user'; id: string } }) => ({
117+
billingEntity: attribution.billingEntity,
118+
billingPeriod: {
119+
start: new Date('2026-07-01T00:00:00.000Z'),
120+
end: new Date('2026-08-01T00:00:00.000Z'),
121+
},
122+
})
123+
)
124+
mockElevenLabsAudio()
125+
})
126+
127+
afterAll(() => {
128+
resetDbChainMock()
129+
resetEnvMock()
130+
})
131+
132+
describe('POST /api/proxy/tts/stream — spend controls', () => {
133+
it('caps text length so one request cannot bill unbounded characters', async () => {
134+
const res = await POST(
135+
createMockRequest('POST', validBody({ text: 'a'.repeat(MAX_TTS_TEXT_LENGTH + 1) }))
136+
)
137+
138+
expect(res.status).toBe(400)
139+
expect(global.fetch).not.toHaveBeenCalled()
140+
expect(mockRecordUsage).not.toHaveBeenCalled()
141+
})
142+
143+
it('rejects a voice outside the allowlist so the caller cannot pick a premium voice', async () => {
144+
const res = await POST(
145+
createMockRequest('POST', validBody({ voiceId: '21m00Tcm4TlvDq8ikWAM' }))
146+
)
147+
148+
expect(res.status).toBe(400)
149+
expect(global.fetch).not.toHaveBeenCalled()
150+
})
151+
152+
it('rejects a model outside the allowlist so the caller cannot pick the billing model', async () => {
153+
const res = await POST(
154+
createMockRequest('POST', validBody({ modelId: 'eleven_multilingual_v2' }))
155+
)
156+
157+
expect(res.status).toBe(400)
158+
expect(global.fetch).not.toHaveBeenCalled()
159+
})
160+
161+
it('throttles per IP before any chat lookup so a flood cannot be amplified into queries', async () => {
162+
mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 30_000 })
163+
164+
const res = await POST(createMockRequest('POST', validBody()))
165+
166+
expect(res.status).toBe(429)
167+
expect(res.headers.get('Retry-After')).toBe('30')
168+
expect(mockCheckRateLimitDirect.mock.calls[0][0]).toContain('tts-stream:ip:')
169+
expect(global.fetch).not.toHaveBeenCalled()
170+
})
171+
172+
it('throttles per chat so many callers cannot drain one public chat', async () => {
173+
queueTableRows(schemaMock.chat, [publicChatRow])
174+
mockCheckRateLimitDirect
175+
.mockResolvedValueOnce({ allowed: true, remaining: 10 })
176+
.mockResolvedValueOnce({ allowed: false, retryAfterMs: 60_000 })
177+
178+
const res = await POST(createMockRequest('POST', validBody()))
179+
180+
expect(res.status).toBe(429)
181+
expect(mockCheckRateLimitDirect.mock.calls[1][0]).toBe('tts-stream:chat:chat-1')
182+
expect(global.fetch).not.toHaveBeenCalled()
183+
expect(mockRecordUsage).not.toHaveBeenCalled()
184+
})
185+
})
186+
187+
describe('POST /api/proxy/tts/stream — attribution', () => {
188+
it('meters synthesized characters against the chat workspace payer', async () => {
189+
queueTableRows(schemaMock.chat, [publicChatRow])
190+
const text = 'a'.repeat(1000)
191+
192+
const res = await POST(createMockRequest('POST', validBody({ text })))
193+
194+
expect(res.status).toBe(200)
195+
expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('ws-1')
196+
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(SYSTEM_BILLING_ATTRIBUTION)
197+
expect(mockRecordUsage).toHaveBeenCalledTimes(1)
198+
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({
199+
userId: 'payer-1',
200+
workspaceId: 'ws-1',
201+
billingEntity: { type: 'organization', id: 'org-1' },
202+
})
203+
expect(mockRecordUsage.mock.calls[0][0].entries[0]).toMatchObject({
204+
category: 'fixed',
205+
source: 'voice-output',
206+
cost: 0.1,
207+
})
208+
expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
209+
type: 'organization',
210+
id: 'org-1',
211+
})
212+
})
213+
214+
it('falls back to the chat owner when the workflow has no workspace', async () => {
215+
queueTableRows(schemaMock.chat, [{ ...publicChatRow, workspaceId: null }])
216+
217+
const res = await POST(createMockRequest('POST', validBody()))
218+
219+
expect(res.status).toBe(200)
220+
expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
221+
expect(mockCheckActorUsageLimits).toHaveBeenCalledWith('owner-1')
222+
expect(mockRecordUsage.mock.calls[0][0]).toMatchObject({ userId: 'owner-1' })
223+
})
224+
225+
it('refuses to spend when the payer is over its usage limit', async () => {
226+
queueTableRows(schemaMock.chat, [publicChatRow])
227+
mockCheckAttributedUsageLimits.mockResolvedValue({
228+
isExceeded: true,
229+
message: 'Usage limit exceeded.',
230+
})
231+
232+
const res = await POST(createMockRequest('POST', validBody()))
233+
234+
expect(res.status).toBe(402)
235+
expect(global.fetch).not.toHaveBeenCalled()
236+
expect(mockRecordUsage).not.toHaveBeenCalled()
237+
})
238+
239+
it('rejects an unknown chat without touching the platform key', async () => {
240+
queueTableRows(schemaMock.chat, [])
241+
242+
const res = await POST(createMockRequest('POST', validBody()))
243+
244+
expect(res.status).toBe(401)
245+
expect(global.fetch).not.toHaveBeenCalled()
246+
expect(mockRecordUsage).not.toHaveBeenCalled()
247+
})
248+
249+
it('does not expose the audio stream to arbitrary origins', async () => {
250+
queueTableRows(schemaMock.chat, [publicChatRow])
251+
252+
const res = await POST(createMockRequest('POST', validBody()))
253+
254+
expect(res.status).toBe(200)
255+
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
256+
})
257+
})

0 commit comments

Comments
 (0)