Skip to content

Commit 0bc4fb4

Browse files
authored
fix(security): meter and throttle the deployed-chat TTS relay (#6212)
* 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. * fix(security): correct TTS metering, pricing and body cap Follow-up review of the previous commit found five defects in it: - Usage rows collided. `usage_log.event_key` is unique and inserts are conflict-do-nothing, and the key is derived from the entry's stable fields. With no explicit sourceReference, two synthesis calls of equal character count in the same workspace produced the same key, so every repeat length went unbilled — defeating the metering this change is for. Each call now carries a unique sourceReference. - Priced at $0.10 per 1k characters, twice the published ElevenLabs Flash/Turbo rate of $0.05, which would have overcharged customers 2x. - No body cap, so an anonymous caller could make the route buffer up to the shared 50 MB default before validation. Now 16 KB, as the STT sibling does. - Threshold settlement ran per sentence: several queries and a possible Stripe call on a realtime path. The workflow execution that produced the text already settles the payer. - The per-IP bucket was described as preventing database amplification. getClientIp trusts the leftmost X-Forwarded-For, so an attacker rotates past it; the comment now says the per-chat bucket is load-bearing. * refactor(chat): share the deployed-chat auth gate across voice routes Review of the previous commits surfaced duplication and one more gap: - The TTS and STT routes had grown near-identical copies of the chat auth + payer lookup. Extracted to resolveDeployedChatCaller, so the gate and the payer resolve together and cannot drift per route — that duplication is how the unmetered TTS path shipped in the first place. - Neither copy filtered chat.archivedAt, so an archived chat could still authorize spend against its former owner's workspace. The shared lookup now filters it, fixing both routes at once. Note: not covered by a test — the db chain mock does not evaluate WHERE clauses, so an assertion here could not fail. - Replaced the route's hand-rolled 429 builder with the existing enforceIpRateLimit helper, and added enforceChatRateLimit alongside the per-user/IP/workspace helpers. Gains the standard Retry-After and X-RateLimit-Reset headers plus throttle logging. - Dropped a test that asserted a module the route no longer imports was never called: it could not fail. - Narrowed the contract: unexported the single-use allowlists and dropped .passthrough() now that the body is a closed shape. * fix(security): fail closed when voice-output usage cannot be recorded Review round 1 findings: - A ledger write failure previously logged and streamed the audio anyway, leaving the spend unrecorded and the payer's usage understated. The caller is anonymous, so serving audio we could not charge for is the unmetered spend this route exists to prevent — it now returns 500. - Use generateId() from @sim/utils/id rather than crypto.randomUUID, per the AGENTS.md ID rule. generateId returns a full UUID v4, so the per-call uniqueness the usage_log event_key depends on is unchanged. * fix(chat): split long TTS text so the relay cap cannot drop audio The client sentence-splits on Western `.!?` only, so text that never matches — CJK punctuation, or a list with no terminal punctuation — accumulates and is flushed as one block at the end of the stream. Against the new 2000-character relay cap that block is rejected and the whole message plays no audio, a regression introduced by adding the cap. Split to cap-sized pieces at the single point that enqueues synthesis, so both the per-sentence path and the end-of-stream flush are covered. Prefers a whitespace or CJK punctuation boundary, falling back to a hard cut when a block has none. The server cap stays as the enforcement point. * fix(security): release the vendor stream when metering rejects the request The fail-closed branch returned 500 with the ElevenLabs response body still open, so synthesis and download kept consuming vendor and runtime resources for a caller that was already rejected. Cancel it before returning, and assert the cancellation in the test.
1 parent 806ea0c commit 0bc4fb4

16 files changed

Lines changed: 19038 additions & 113 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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { MAX_TTS_TEXT_LENGTH } from '@/lib/api/contracts/media/tts-stream'
6+
import { splitForSynthesis } from '@/app/(interfaces)/chat/hooks/use-audio-streaming'
7+
8+
describe('splitForSynthesis', () => {
9+
it('leaves text within the relay cap untouched', () => {
10+
expect(splitForSynthesis('Short answer.')).toEqual(['Short answer.'])
11+
})
12+
13+
/**
14+
* The caller only sentence-splits on Western `.!?`, so CJK punctuation never
15+
* matches and the whole answer arrives as one block. Before splitting, the
16+
* relay rejected it and the message played no audio at all.
17+
*/
18+
it('splits CJK text that never matches the Western sentence split', () => {
19+
const text = '这是一个很长的回答。'.repeat(400)
20+
expect(text.length).toBeGreaterThan(MAX_TTS_TEXT_LENGTH)
21+
22+
const chunks = splitForSynthesis(text)
23+
24+
expect(chunks.length).toBeGreaterThan(1)
25+
for (const chunk of chunks) {
26+
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
27+
}
28+
})
29+
30+
it('splits a long list that has no terminal punctuation', () => {
31+
const text = Array.from({ length: 300 }, (_, i) => `- item number ${i}`).join('\n')
32+
expect(text.length).toBeGreaterThan(MAX_TTS_TEXT_LENGTH)
33+
34+
const chunks = splitForSynthesis(text)
35+
36+
for (const chunk of chunks) {
37+
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
38+
}
39+
})
40+
41+
it('preserves the spoken content across chunks', () => {
42+
const text = Array.from({ length: 500 }, (_, i) => `word${i}`).join(' ')
43+
44+
const chunks = splitForSynthesis(text)
45+
46+
expect(chunks.join(' ').replace(/\s+/g, ' ')).toBe(text)
47+
})
48+
49+
it('still caps text with no break opportunity at all', () => {
50+
const chunks = splitForSynthesis('a'.repeat(MAX_TTS_TEXT_LENGTH * 2 + 5))
51+
52+
expect(chunks.length).toBe(3)
53+
for (const chunk of chunks) {
54+
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
55+
}
56+
})
57+
})

apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,47 @@
22

33
import { type RefObject, useCallback, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { DEFAULT_TTS_MODEL_ID, MAX_TTS_TEXT_LENGTH } from '@/lib/api/contracts/media/tts-stream'
56

67
const logger = createLogger('UseAudioStreaming')
78

9+
/** Prefer breaking on a boundary this far into the chunk before splitting mid-word. */
10+
const MIN_SPLIT_RATIO = 0.6
11+
12+
/**
13+
* Splits text into pieces the TTS relay will accept.
14+
*
15+
* The caller sentence-splits on Western `.!?` only, so text that never matches
16+
* — CJK punctuation, or a list with no terminal punctuation — reaches this hook
17+
* as one accumulated block that can exceed the relay's per-request cap. Without
18+
* splitting, the relay rejects it and the whole message plays no audio.
19+
*/
20+
export function splitForSynthesis(text: string, max: number = MAX_TTS_TEXT_LENGTH): string[] {
21+
if (text.length <= max) return [text]
22+
23+
const chunks: string[] = []
24+
let rest = text
25+
26+
while (rest.length > max) {
27+
const window = rest.slice(0, max)
28+
const boundary = Math.max(
29+
window.lastIndexOf(' '),
30+
window.lastIndexOf('\n'),
31+
window.lastIndexOf('。'),
32+
window.lastIndexOf(','),
33+
window.lastIndexOf('、')
34+
)
35+
const cut = boundary >= max * MIN_SPLIT_RATIO ? boundary + 1 : max
36+
const piece = rest.slice(0, cut).trim()
37+
if (piece) chunks.push(piece)
38+
rest = rest.slice(cut)
39+
}
40+
41+
const tail = rest.trim()
42+
if (tail) chunks.push(tail)
43+
return chunks
44+
}
45+
846
declare global {
947
interface Window {
1048
webkitAudioContext?: typeof AudioContext
@@ -79,7 +117,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
79117
const { text, options } = item
80118
const {
81119
voiceId,
82-
modelId = 'eleven_flash_v2_5',
120+
modelId = DEFAULT_TTS_MODEL_ID,
83121
chatId,
84122
onAudioStart,
85123
onAudioEnd,
@@ -156,7 +194,9 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
156194
abortControllerRef.current = new AbortController()
157195
}
158196

159-
audioQueueRef.current.push({ text, options })
197+
for (const piece of splitForSynthesis(text)) {
198+
audioQueueRef.current.push({ text: piece, options })
199+
}
160200
processAudioQueue()
161201
},
162202
[processAudioQueue]

0 commit comments

Comments
 (0)