Skip to content

Commit f39c0fd

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(tools): bound internal request serialization
1 parent 044660b commit f39c0fd

4 files changed

Lines changed: 173 additions & 2 deletions

File tree

apps/sim/tools/index.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3973,13 +3973,21 @@ describe('Internal Route Trust', () => {
39733973
})
39743974

39753975
it('rejects oversized operation input before invoking the in-process handler', async () => {
3976+
const later = vi.fn()
39763977
const mockTool = {
39773978
id: 'test_oversized_operation_input',
39783979
name: 'Test Oversized Operation Input',
39793980
description: 'Rejects operation input above the shared tool admission limit',
39803981
version: '1.0.0',
39813982
params: { payload: { type: 'string', required: true } },
3982-
operation: { input: (params: { payload: string }) => params },
3983+
operation: {
3984+
input: (params: { payload: string }) => ({
3985+
payload: params.payload,
3986+
get later() {
3987+
return later()
3988+
},
3989+
}),
3990+
},
39833991
}
39843992
;(tools as Record<string, unknown>).test_oversized_operation_input = mockTool
39853993

@@ -3995,6 +4003,7 @@ describe('Internal Route Trust', () => {
39954003
error: expect.stringContaining('Request body size limit exceeded (10MB)'),
39964004
})
39974005
expect(mockExecuteInternalToolOperation).not.toHaveBeenCalled()
4006+
expect(later).not.toHaveBeenCalled()
39984007
} finally {
39994008
Reflect.deleteProperty(tools, 'test_oversized_operation_input')
40004009
}

apps/sim/tools/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2688,7 +2688,13 @@ async function executeDeclaredInternalOperation({
26882688
if (privateToolMetadataType) {
26892689
headers.set(PRIVATE_TOOL_METADATA_REQUEST_HEADER, privateToolMetadataType)
26902690
}
2691-
validateRequestBodySize(JSON.stringify(operationInput), requestId, toolId)
2691+
const { stringifyRequestWithinLimit } = await import('@/tools/request-body-size.server')
2692+
try {
2693+
stringifyRequestWithinLimit(operationInput, MAX_REQUEST_BODY_SIZE_BYTES)
2694+
} catch (error) {
2695+
if (isPayloadSizeLimitError(error)) throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
2696+
throw error
2697+
}
26922698
const deadline = serializeExecutionDeadlineHeader(signal)
26932699
if (deadline) headers.set(INTERNAL_EXECUTION_DEADLINE_HEADER, deadline)
26942700
const billingAttribution = context.billingAttribution
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
6+
import { stringifyRequestWithinLimit } from '@/tools/request-body-size.server'
7+
8+
describe('stringifyRequestWithinLimit', () => {
9+
it.each([
10+
{ 'escaped"key\n': '\u0000é😀\ud800', empty: {}, array: [1, null, true] },
11+
{ missing: undefined, array: [undefined, () => 1, Symbol('omitted'), Number.NaN] },
12+
{ date: new Date('2026-01-01T00:00:00Z'), boxed: [Object(1), Object('é'), Object(false)] },
13+
])('preserves native JSON and its exact UTF-8 limit for %j', (value) => {
14+
const expected = JSON.stringify(value)
15+
const bytes = Buffer.byteLength(expected, 'utf8')
16+
expect(stringifyRequestWithinLimit(value, bytes)).toBe(expected)
17+
expect(() => stringifyRequestWithinLimit(value, bytes - 1)).toThrow(PayloadSizeLimitError)
18+
})
19+
20+
it('invokes getters, toJSON and boxed conversions only once', () => {
21+
const getter = vi.fn(() => ({ toJSON }))
22+
const toJSON = vi.fn(() => 'value')
23+
const numberConversion = vi.fn(() => 3)
24+
const stringConversion = vi.fn(() => 'text')
25+
const value = {
26+
get data() {
27+
return getter()
28+
},
29+
number: Object.assign(Object(1), { [Symbol.toPrimitive]: numberConversion }),
30+
string: Object.assign(Object('original'), { [Symbol.toPrimitive]: stringConversion }),
31+
}
32+
33+
expect(stringifyRequestWithinLimit(value, 100)).toBe(
34+
'{"data":"value","number":3,"string":"text"}'
35+
)
36+
for (const hook of [getter, toJSON, numberConversion, stringConversion]) {
37+
expect(hook).toHaveBeenCalledTimes(1)
38+
}
39+
})
40+
41+
it('stops native traversal before later getters on oversized strings', () => {
42+
const later = vi.fn()
43+
const value = {
44+
large: '\u0000'.repeat(100),
45+
get later() {
46+
return later()
47+
},
48+
}
49+
50+
expect(() => stringifyRequestWithinLimit(value, 50)).toThrow(PayloadSizeLimitError)
51+
expect(later).not.toHaveBeenCalled()
52+
})
53+
54+
it('preserves native omissions, shared objects, cycle errors and bigint conversion errors', () => {
55+
const shared = { a: 1 }
56+
expect(stringifyRequestWithinLimit([shared, shared], 100)).toBe(
57+
JSON.stringify([shared, shared])
58+
)
59+
expect(stringifyRequestWithinLimit(undefined, 100)).toBeUndefined()
60+
const cycle: { self?: unknown } = {}
61+
cycle.self = cycle
62+
expect(() => stringifyRequestWithinLimit(cycle, 100)).toThrow(TypeError)
63+
const number = Object.assign(Object(1), { [Symbol.toPrimitive]: () => 1n })
64+
expect(() => JSON.stringify(number)).toThrow(TypeError)
65+
expect(() => stringifyRequestWithinLimit(number, 100)).toThrow(TypeError)
66+
expect(() => stringifyRequestWithinLimit(1n, 100)).toThrow(TypeError)
67+
})
68+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { types } from 'node:util'
2+
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
3+
4+
/**
5+
* Uses native JSON traversal with an incremental UTF-8 budget, stopping before
6+
* the complete oversized JSON string is allocated. Getters and toJSON retain
7+
* native invocation semantics; allocations inside those hooks are not bounded.
8+
*/
9+
export function stringifyRequestWithinLimit(value: unknown, maxBytes: number): string | undefined {
10+
let bytes = 0
11+
const containers = new WeakMap<object, number>()
12+
const charge = (size: number): void => {
13+
bytes += size
14+
assertKnownSizeWithinLimit(bytes, maxBytes, 'Request body')
15+
}
16+
const chargeString = (text: string): void => {
17+
charge(2)
18+
for (let index = 0; index < text.length; index++) {
19+
const code = text.charCodeAt(index)
20+
if (code === 0x22 || code === 0x5c) {
21+
charge(2)
22+
} else if (code < 0x20) {
23+
charge(code === 8 || code === 9 || code === 10 || code === 12 || code === 13 ? 2 : 6)
24+
} else if (code < 0x80) {
25+
charge(1)
26+
} else if (code < 0x800) {
27+
charge(2)
28+
} else if (code >= 0xd800 && code <= 0xdfff) {
29+
const next = text.charCodeAt(index + 1)
30+
if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
31+
charge(4)
32+
index++
33+
} else {
34+
charge(6)
35+
}
36+
} else {
37+
charge(3)
38+
}
39+
}
40+
}
41+
const nativeJson = JSON as typeof JSON & {
42+
isRawJSON?: (input: unknown) => input is { rawJSON: string }
43+
}
44+
45+
const serialized = JSON.stringify(value, function (this: object, key, input: unknown) {
46+
/** Native stringify unboxes these after calling the replacer. */
47+
let current = input
48+
if (types.isNumberObject(current)) current = +current
49+
else if (types.isStringObject(current)) current = String(current)
50+
else if (types.isBooleanObject(current)) current = Boolean.prototype.valueOf.call(current)
51+
else if (types.isBigIntObject(current)) current = BigInt.prototype.valueOf.call(current)
52+
53+
const arrayItem = Array.isArray(this)
54+
const omitted =
55+
current === undefined || typeof current === 'function' || typeof current === 'symbol'
56+
if (omitted && !arrayItem) return current
57+
58+
const entries = containers.get(this)
59+
if (entries !== undefined) {
60+
if (entries > 0) charge(1)
61+
if (!arrayItem) {
62+
chargeString(key)
63+
charge(1)
64+
}
65+
containers.set(this, entries + 1)
66+
}
67+
68+
if (omitted || current === null) charge(4)
69+
else if (typeof current === 'string') chargeString(current)
70+
else if (typeof current === 'number')
71+
charge(Number.isFinite(current) ? String(current).length : 4)
72+
else if (typeof current === 'boolean') charge(current ? 4 : 5)
73+
else if (typeof current === 'object') {
74+
if (nativeJson.isRawJSON?.(current)) {
75+
charge(Buffer.byteLength(current.rawJSON, 'utf8'))
76+
} else {
77+
charge(2)
78+
containers.set(current, 0)
79+
}
80+
}
81+
return current
82+
})
83+
84+
if (serialized !== undefined) {
85+
assertKnownSizeWithinLimit(Buffer.byteLength(serialized, 'utf8'), maxBytes, 'Request body')
86+
}
87+
return serialized
88+
}

0 commit comments

Comments
 (0)