diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 1cb84748caa..c708ad1a21e 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -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).test_oversized_operation_input = mockTool @@ -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') } diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 6ae970f13ad..6c58fc22b8e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -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 + } const deadline = serializeExecutionDeadlineHeader(signal) if (deadline) headers.set(INTERNAL_EXECUTION_DEADLINE_HEADER, deadline) const billingAttribution = context.billingAttribution diff --git a/apps/sim/tools/request-body-size.server.test.ts b/apps/sim/tools/request-body-size.server.test.ts new file mode 100644 index 00000000000..5ed74979c97 --- /dev/null +++ b/apps/sim/tools/request-body-size.server.test.ts @@ -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) + }) +}) diff --git a/apps/sim/tools/request-body-size.server.ts b/apps/sim/tools/request-body-size.server.ts new file mode 100644 index 00000000000..34e2fdd9e84 --- /dev/null +++ b/apps/sim/tools/request-body-size.server.ts @@ -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() + 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 +}