Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/sim/tools/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3973,13 +3973,21 @@ describe('Internal Route Trust', () => {
})

it('rejects oversized operation input before invoking the in-process handler', async () => {
const later = vi.fn()
const mockTool = {
id: 'test_oversized_operation_input',
name: 'Test Oversized Operation Input',
description: 'Rejects operation input above the shared tool admission limit',
version: '1.0.0',
params: { payload: { type: 'string', required: true } },
operation: { input: (params: { payload: string }) => params },
operation: {
input: (params: { payload: string }) => ({
payload: params.payload,
get later() {
return later()
},
}),
},
}
;(tools as Record<string, unknown>).test_oversized_operation_input = mockTool

Expand All @@ -3995,6 +4003,7 @@ describe('Internal Route Trust', () => {
error: expect.stringContaining('Request body size limit exceeded (10MB)'),
})
expect(mockExecuteInternalToolOperation).not.toHaveBeenCalled()
expect(later).not.toHaveBeenCalled()
} finally {
Reflect.deleteProperty(tools, 'test_oversized_operation_input')
}
Expand Down
14 changes: 13 additions & 1 deletion apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2688,7 +2688,19 @@ async function executeDeclaredInternalOperation({
if (privateToolMetadataType) {
headers.set(PRIVATE_TOOL_METADATA_REQUEST_HEADER, privateToolMetadataType)
}
validateRequestBodySize(JSON.stringify(operationInput), requestId, toolId)
const { stringifyRequestWithinLimit } = await import('@/tools/request-body-size.server')
try {
stringifyRequestWithinLimit(operationInput, MAX_REQUEST_BODY_SIZE_BYTES)
} catch (error) {
if (isPayloadSizeLimitError(error)) {
logger.error(`[${requestId}] Request body size exceeds limit for ${toolId}:`, {
bodySizeLowerBound: error.observedBytes,
maxSize: error.maxBytes,
})
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
}
throw error
Comment on lines +2692 to +2702

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Size telemetry is lost

Oversized internal-operation inputs now skip the structured log fields that recorded the observed body size and configured limit. The generic execution error is still logged, but without these measurements, request-limit incidents are harder to diagnose and quantify. Please preserve equivalent size-limit telemetry in the new bounded serialization path.

Knowledge Base Used: Integrations, connectors, and tools

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
const deadline = serializeExecutionDeadlineHeader(signal)
if (deadline) headers.set(INTERNAL_EXECUTION_DEADLINE_HEADER, deadline)
const billingAttribution = context.billingAttribution
Expand Down
68 changes: 68 additions & 0 deletions apps/sim/tools/request-body-size.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { stringifyRequestWithinLimit } from '@/tools/request-body-size.server'

describe('stringifyRequestWithinLimit', () => {
it.each([
{ 'escaped"key\n': '\u0000é😀\ud800', empty: {}, array: [1, null, true] },
{ missing: undefined, array: [undefined, () => 1, Symbol('omitted'), Number.NaN] },
{ date: new Date('2026-01-01T00:00:00Z'), boxed: [Object(1), Object('é'), Object(false)] },
])('preserves native JSON and its exact UTF-8 limit for %j', (value) => {
const expected = JSON.stringify(value)
const bytes = Buffer.byteLength(expected, 'utf8')
expect(stringifyRequestWithinLimit(value, bytes)).toBe(expected)
expect(() => stringifyRequestWithinLimit(value, bytes - 1)).toThrow(PayloadSizeLimitError)
})

it('invokes getters, toJSON and boxed conversions only once', () => {
const getter = vi.fn(() => ({ toJSON }))
const toJSON = vi.fn(() => 'value')
const numberConversion = vi.fn(() => 3)
const stringConversion = vi.fn(() => 'text')
const value = {
get data() {
return getter()
},
number: Object.assign(Object(1), { [Symbol.toPrimitive]: numberConversion }),
string: Object.assign(Object('original'), { [Symbol.toPrimitive]: stringConversion }),
}

expect(stringifyRequestWithinLimit(value, 100)).toBe(
'{"data":"value","number":3,"string":"text"}'
)
for (const hook of [getter, toJSON, numberConversion, stringConversion]) {
expect(hook).toHaveBeenCalledTimes(1)
}
})

it('stops native traversal before later getters on oversized strings', () => {
const later = vi.fn()
const value = {
large: '\u0000'.repeat(100),
get later() {
return later()
},
}

expect(() => stringifyRequestWithinLimit(value, 50)).toThrow(PayloadSizeLimitError)
expect(later).not.toHaveBeenCalled()
})

it('preserves native omissions, shared objects, cycle errors and bigint conversion errors', () => {
const shared = { a: 1 }
expect(stringifyRequestWithinLimit([shared, shared], 100)).toBe(
JSON.stringify([shared, shared])
)
expect(stringifyRequestWithinLimit(undefined, 100)).toBeUndefined()
const cycle: { self?: unknown } = {}
cycle.self = cycle
expect(() => stringifyRequestWithinLimit(cycle, 100)).toThrow(TypeError)
const number = Object.assign(Object(1), { [Symbol.toPrimitive]: () => 1n })
expect(() => JSON.stringify(number)).toThrow(TypeError)
expect(() => stringifyRequestWithinLimit(number, 100)).toThrow(TypeError)
expect(() => stringifyRequestWithinLimit(1n, 100)).toThrow(TypeError)
})
})
88 changes: 88 additions & 0 deletions apps/sim/tools/request-body-size.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { types } from 'node:util'
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'

/**
* Uses native JSON traversal with an incremental UTF-8 budget, stopping before
* the complete oversized JSON string is allocated. Getters and toJSON retain
* native invocation semantics; allocations inside those hooks are not bounded.
*/
export function stringifyRequestWithinLimit(value: unknown, maxBytes: number): string | undefined {
let bytes = 0
const containers = new WeakMap<object, number>()
const charge = (size: number): void => {
bytes += size
assertKnownSizeWithinLimit(bytes, maxBytes, 'Request body')
}
const chargeString = (text: string): void => {
charge(2)
for (let index = 0; index < text.length; index++) {
const code = text.charCodeAt(index)
if (code === 0x22 || code === 0x5c) {
charge(2)
} else if (code < 0x20) {
charge(code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 2 : 6)
} else if (code < 0x80) {
charge(1)
} else if (code < 0x800) {
charge(2)
} else if (code >= 0xd800 && code <= 0xdfff) {
const next = text.charCodeAt(index + 1)
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
charge(4)
index++
} else {
charge(6)
}
} else {
charge(3)
}
}
}
const nativeJson = JSON as typeof JSON & {
isRawJSON?: (input: unknown) => input is { rawJSON: string }
}

const serialized = JSON.stringify(value, function (this: object, key, input: unknown) {
/** Native stringify unboxes these after calling the replacer. */
let current = input
if (types.isNumberObject(current)) current = +current
else if (types.isStringObject(current)) current = String(current)
else if (types.isBooleanObject(current)) current = Boolean.prototype.valueOf.call(current)
else if (types.isBigIntObject(current)) current = BigInt.prototype.valueOf.call(current)

const arrayItem = Array.isArray(this)
const omitted =
current === undefined || typeof current === 'function' || typeof current === 'symbol'
if (omitted && !arrayItem) return current

const entries = containers.get(this)
if (entries !== undefined) {
if (entries > 0) charge(1)
if (!arrayItem) {
chargeString(key)
charge(1)
}
containers.set(this, entries + 1)
}

if (omitted || current === null) charge(4)
else if (typeof current === 'string') chargeString(current)
else if (typeof current === 'number')
charge(Number.isFinite(current) ? String(current).length : 4)
else if (typeof current === 'boolean') charge(current ? 4 : 5)
else if (typeof current === 'object') {
if (nativeJson.isRawJSON?.(current)) {
charge(Buffer.byteLength(current.rawJSON, 'utf8'))
} else {
charge(2)
containers.set(current, 0)
}
}
return current
})

if (serialized !== undefined) {
assertKnownSizeWithinLimit(Buffer.byteLength(serialized, 'utf8'), maxBytes, 'Request body')
}
return serialized
}
Loading