Skip to content

Commit 38c9fe7

Browse files
committed
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.
1 parent 4ab1b11 commit 38c9fe7

2 files changed

Lines changed: 66 additions & 15 deletions

File tree

apps/sim/app/api/proxy/tts/stream/route.test.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,14 @@ describe('POST /api/proxy/tts/stream — spend controls', () => {
140140
expect(mockRecordUsage).not.toHaveBeenCalled()
141141
})
142142

143+
it('rejects an oversized body before buffering it', async () => {
144+
const res = await POST(createMockRequest('POST', validBody({ padding: 'x'.repeat(32 * 1024) })))
145+
146+
expect(res.status).toBe(413)
147+
expect(global.fetch).not.toHaveBeenCalled()
148+
expect(mockRecordUsage).not.toHaveBeenCalled()
149+
})
150+
143151
it('rejects a voice outside the allowlist so the caller cannot pick a premium voice', async () => {
144152
const res = await POST(
145153
createMockRequest('POST', validBody({ voiceId: '21m00Tcm4TlvDq8ikWAM' }))
@@ -203,14 +211,33 @@ describe('POST /api/proxy/tts/stream — attribution', () => {
203211
expect(mockRecordUsage.mock.calls[0][0].entries[0]).toMatchObject({
204212
category: 'fixed',
205213
source: 'voice-output',
206-
cost: 0.1,
207-
})
208-
expect(mockCheckAndBillPayerOverageThreshold).toHaveBeenCalledWith({
209-
type: 'organization',
210-
id: 'org-1',
214+
cost: 0.05,
211215
})
212216
})
213217

218+
it('gives each call a unique source reference so equal-length calls are not deduplicated', async () => {
219+
const text = 'Same length text.'
220+
221+
queueTableRows(schemaMock.chat, [publicChatRow])
222+
await POST(createMockRequest('POST', validBody({ text })))
223+
queueTableRows(schemaMock.chat, [publicChatRow])
224+
await POST(createMockRequest('POST', validBody({ text })))
225+
226+
expect(mockRecordUsage).toHaveBeenCalledTimes(2)
227+
const first = mockRecordUsage.mock.calls[0][0].entries[0].sourceReference
228+
const second = mockRecordUsage.mock.calls[1][0].entries[0].sourceReference
229+
expect(first).toBeDefined()
230+
expect(first).not.toBe(second)
231+
})
232+
233+
it('does not run per-request threshold settlement on the realtime path', async () => {
234+
queueTableRows(schemaMock.chat, [publicChatRow])
235+
236+
await POST(createMockRequest('POST', validBody()))
237+
238+
expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
239+
})
240+
214241
it('falls back to the chat owner when the workflow has no workspace', async () => {
215242
queueTableRows(schemaMock.chat, [{ ...publicChatRow, workspaceId: null }])
216243

apps/sim/app/api/proxy/tts/stream/route.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from 'node:crypto'
12
import { db } from '@sim/db'
23
import { chat, workflow } from '@sim/db/schema'
34
import { createLogger } from '@sim/logger'
@@ -13,7 +14,6 @@ import {
1314
toBillingContext,
1415
} from '@/lib/billing/core/billing-attribution'
1516
import { recordUsage } from '@/lib/billing/core/usage-log'
16-
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
1717
import { env } from '@/lib/core/config/env'
1818
import { getCostMultiplier } from '@/lib/core/config/env-flags'
1919
import { RateLimiter } from '@/lib/core/rate-limiter'
@@ -27,8 +27,14 @@ const rateLimiter = new RateLimiter()
2727

2828
/**
2929
* Public chats hand their id to every visitor, so the id alone cannot gate
30-
* spend on the platform ElevenLabs key. Two buckets bound the two abuse shapes:
31-
* one caller hammering many chats, and many callers hammering one chat.
30+
* spend on the platform ElevenLabs key.
31+
*
32+
* The per-IP bucket only filters naive floods: `getClientIp` trusts the
33+
* leftmost `X-Forwarded-For` value, which the caller controls, so a deliberate
34+
* attacker rotates past it. The per-chat bucket is the load-bearing control —
35+
* it is keyed on server-held state and bounds total spend per chat regardless
36+
* of how many source addresses the traffic claims to come from.
37+
*
3238
* Deployed chat synthesizes sentence by sentence, so a real conversation issues
3339
* several requests per answer — hence the generous burst.
3440
*/
@@ -45,11 +51,19 @@ const TTS_CHAT_RATE_LIMIT = {
4551
} as const
4652

4753
/**
48-
* Platform ElevenLabs rate for the Flash v2.5 model, in USD per 1,000
49-
* characters. Synthesis is billed to the chat's workspace payer so the spend is
50-
* attributable and counts against that plan's usage limit.
54+
* Published ElevenLabs API rate for Flash/Turbo text-to-speech, in USD per
55+
* 1,000 characters. This is the vendor cost; `getCostMultiplier()` applies the
56+
* platform markup, matching how other metered sources are priced.
5157
*/
52-
const TTS_COST_PER_1K_CHARS = 0.1
58+
const TTS_COST_PER_1K_CHARS = 0.05
59+
60+
/**
61+
* The body carries at most `MAX_TTS_TEXT_LENGTH` characters of text plus two
62+
* short ids, so a tight cap keeps an anonymous caller from making the route
63+
* buffer a large payload before validation runs. Without it the shared default
64+
* is 50 MB.
65+
*/
66+
const MAX_TTS_BODY_BYTES = 16 * 1024
5367

5468
interface ChatAuthResult {
5569
valid: boolean
@@ -132,6 +146,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
132146
request,
133147
{},
134148
{
149+
maxBodyBytes: MAX_TTS_BODY_BYTES,
135150
invalidJsonResponse: () => new NextResponse('Invalid request body', { status: 400 }),
136151
validationErrorResponse: (error) => {
137152
if (error.issues.some((issue) => issue.path[0] === 'chatId')) {
@@ -224,6 +239,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
224239
/**
225240
* Meter once ElevenLabs has accepted the request — the characters are billed
226241
* to us at that point regardless of whether the client drains the stream.
242+
*
243+
* `sourceReference` must be unique per call. `usage_log.event_key` is
244+
* unique and inserts conflict-do-nothing, and the key is derived from the
245+
* entry's stable fields — without this, two synthesis calls of equal length
246+
* in the same workspace would collide and the second would silently go
247+
* unbilled. Each call is a separate charge from ElevenLabs, so each needs
248+
* its own row rather than being deduplicated.
249+
*
250+
* No threshold settlement here: it runs per metered event elsewhere and is
251+
* far too heavy for a per-sentence realtime path. The workflow execution
252+
* that produced this text already settles the payer.
227253
*/
228254
if (actorUserId) {
229255
try {
@@ -237,12 +263,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
237263
source: 'voice-output',
238264
description: `Voice output (${text.length} characters)`,
239265
cost: (text.length / 1000) * TTS_COST_PER_1K_CHARS * getCostMultiplier(),
266+
sourceReference: `voice-output:${chatId}:${randomUUID()}`,
240267
},
241268
],
242269
})
243-
if (billingAttribution) {
244-
await checkAndBillPayerOverageThreshold(billingAttribution.billingEntity)
245-
}
246270
} catch (err) {
247271
logger.warn('Failed to record voice output usage, continuing:', err)
248272
}

0 commit comments

Comments
 (0)