Skip to content

Commit c2b13e7

Browse files
committed
fix(review): address the five findings from the review round
Four are side effects of earlier fixes in this PR: - the '!= null' guard added so an explicit 0 would survive also admitted '', sending a bare 'timeout=' to Apify on a direct tool call - the pagination coercion turned a whitespace-only input into 0, slipping past the tool's own non-negative-integer check - the dot-segment guard trimmed before comparing, so a GCS object legally named ' ..' was rejected even though '%20..' cannot collapse a segment - the 7MiB media cap threw inside a try whose catch reported success with no file, making an over-limit recording look like an empty one; it now returns 413, and the other errors that catch handles are unchanged The fifth is pre-existing, exposed by the new validator: BUILT_IN_PATH is a plain object literal, so an inherited key like '__proto__' resolved up the prototype chain and threw a TypeError. Fixed in all three copies -- both routes and the poller, where 'in' walks the chain the same way.
1 parent df0aeb1 commit c2b13e7

16 files changed

Lines changed: 475 additions & 18 deletions

File tree

apps/sim/app/api/tools/google_vault/download-export-file/path-safety.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,20 @@ import { POST } from '@/app/api/tools/google_vault/download-export-file/route'
3636
const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
3737

3838
const PINNED_IP = '93.184.216.34'
39-
const REJECTED = ['..', '.', ' .. '] as const
39+
const REJECTED = ['..', '.'] as const
40+
41+
/**
42+
* Whitespace-padded dot segments. GCS documents any Unicode character as legal
43+
* in an object name, so `' ..'` is a real, addressable object — and the server
44+
* does not trim, so `' ..'` and `'..'` are *different* objects. Rejecting the
45+
* padded form (which trimming before the comparison did) is a false rejection,
46+
* and rewriting it would silently address the wrong object. It is safe to allow
47+
* because `encodeURIComponent` turns the padding into `%20`, and the WHATWG
48+
* parser only removes a segment that is *exactly* `.` or `..` — the same
49+
* argument `safeUrlPath`'s `preserveOuterWhitespace` option records for
50+
* Supabase Storage keys.
51+
*/
52+
const PADDED_DOT_NAMES = [' ..', '.. ', ' .. ', ' . ', '\t..'] as const
4053

4154
function downloadResponse() {
4255
return {
@@ -143,6 +156,74 @@ describe('POST /api/tools/google_vault/download-export-file traversal safety', (
143156
])
144157
})
145158

159+
it.each(PADDED_DOT_NAMES)(
160+
'accepts the legal padded object name %j and keeps the path shape intact',
161+
async (objectName) => {
162+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(downloadResponse())
163+
164+
const response = await POST(
165+
createMockRequest('POST', {
166+
accessToken: 'token-123',
167+
matterId: 'matter-1',
168+
bucketName: 'vault-bucket',
169+
objectName,
170+
})
171+
)
172+
expect(response.status).toBe(200)
173+
174+
const url = new URL(requestedUrl())
175+
expect(url.pathname.split('/')).toEqual([
176+
'',
177+
'storage',
178+
'v1',
179+
'b',
180+
'vault-bucket',
181+
'o',
182+
encodeURIComponent(objectName),
183+
])
184+
expect(decodeURIComponent(url.pathname.split('/')[6])).toBe(objectName)
185+
}
186+
)
187+
188+
it.each(PADDED_DOT_NAMES)(
189+
'accepts the legal padded bucket name %j and keeps the path shape intact',
190+
async (bucketName) => {
191+
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(downloadResponse())
192+
193+
const response = await POST(
194+
createMockRequest('POST', {
195+
accessToken: 'token-123',
196+
matterId: 'matter-1',
197+
bucketName,
198+
objectName: 'exports/file.zip',
199+
})
200+
)
201+
expect(response.status).toBe(200)
202+
203+
const url = new URL(requestedUrl())
204+
expect(url.pathname.split('/')).toEqual([
205+
'',
206+
'storage',
207+
'v1',
208+
'b',
209+
encodeURIComponent(bucketName),
210+
'o',
211+
'exports%2Ffile.zip',
212+
])
213+
}
214+
)
215+
216+
it('proves the padded forms cannot pop a segment while the bare forms can', () => {
217+
const build = (value: string) =>
218+
new URL(`https://storage.googleapis.com/storage/v1/b/bkt/o/${encodeURIComponent(value)}`)
219+
220+
for (const padded of PADDED_DOT_NAMES) {
221+
expect(build(padded).pathname.split('/')).toHaveLength(7)
222+
}
223+
expect(build('..').pathname.split('/')).toHaveLength(6)
224+
expect(build('.').pathname.split('/')[6]).toBe('')
225+
})
226+
146227
it('preserves an interior ".." component, which never forms a URL segment', async () => {
147228
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(downloadResponse())
148229

apps/sim/app/api/tools/google_vault/download-export-file/route.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ const logger = createLogger('GoogleVaultDownloadExportFileAPI')
2929
* token attached. Only rejection closes that, and only the whole value can do
3030
* it: an interior `..` is encoded into the same segment and stays inert.
3131
*
32-
* The check trims before comparing but the caller still sends the untrimmed
33-
* value, so no legitimate name is silently rewritten.
32+
* The comparison is against the **raw** value, deliberately not a trimmed one.
33+
* GCS documents any Unicode character as legal in an object name and does not
34+
* trim, so `' ..'` is a real object distinct from `'..'`; rejecting it is a
35+
* false rejection of a legal name. It is also unnecessary — the padding
36+
* encodes to `%20`/`%09` and the WHATWG parser removes only a segment that is
37+
* *exactly* `.` or `..`, so `%20..` stays inert text. This is the same
38+
* reasoning `safeUrlPath` records for its `preserveOuterWhitespace` option.
3439
*/
3540
function assertNotDotSegment(value: string, paramName: string): void {
36-
const trimmed = value.trim()
37-
if (trimmed === '.' || trimmed === '..') {
38-
throw new Error(`${paramName} cannot be "${trimmed}" (path traversal is not allowed)`)
41+
if (value === '.' || value === '..') {
42+
throw new Error(`${paramName} cannot be "${value}" (path traversal is not allowed)`)
3943
}
4044
}
4145

apps/sim/app/api/tools/hubspot/path_safety.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ const REJECTED = ['..', '.', '%2e%2e', 'a/b', ' .. ', '..%2f..', '../..'] as c
5353
*/
5454
const LEGITIMATE = ['p7878787_my_object', '2-123456', '0-1', 'my_object'] as const
5555

56+
/**
57+
* Keys that exist on `Object.prototype`, not on `BUILT_IN_PATH`. A plain object
58+
* literal resolves them through the prototype chain, so `BUILT_IN_PATH[key]`
59+
* returns a function or an object rather than `undefined` — the `?? objectType`
60+
* fallback never fires, and `validatePathSegment` then calls `.includes` on a
61+
* non-string and throws, turning caller-controlled input into a 500. Every one
62+
* of these is a syntactically legal HubSpot custom-object name, so the correct
63+
* behavior is to treat it as one: pass it through the validator as the string
64+
* it is.
65+
*/
66+
const PROTOTYPE_KEYS = [
67+
'constructor',
68+
'__proto__',
69+
'toString',
70+
'hasOwnProperty',
71+
'valueOf',
72+
] as const
73+
5674
let fetchMock: ReturnType<typeof vi.fn>
5775

5876
beforeEach(() => {
@@ -111,6 +129,23 @@ describe.each(ROUTES)(
111129
expect(url.pathname.split('/')).toEqual(['', 'crm', 'v3', collection, objectType])
112130
})
113131

132+
it.each(PROTOTYPE_KEYS)(
133+
'treats the inherited key %j as a plain object-type string, never a 500',
134+
async (objectType) => {
135+
const response = await handler(request(name, objectType))
136+
137+
expect(response.status).not.toBe(500)
138+
expect(response.status).toBe(200)
139+
expect(outgoingUrl().pathname.split('/')).toEqual([
140+
'',
141+
'crm',
142+
'v3',
143+
collection,
144+
encodeURIComponent(objectType),
145+
])
146+
}
147+
)
148+
114149
it.each(REJECTED)(
115150
'rejects objectType=%j with a 400 and never calls HubSpot',
116151
async (objectType) => {

apps/sim/app/api/tools/hubspot/pipelines/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ const logger = createLogger('HubSpotPipelinesAPI')
1818
* only to a non-empty string. That value lands in a path segment, so it is
1919
* validated before use — `encodeURIComponent` alone leaves a `.`/`..` segment
2020
* intact and the WHATWG parser removes it, re-aiming the authenticated request.
21+
*
22+
* The lookup goes through `Object.hasOwn` rather than plain indexing: on a
23+
* plain object literal, `objectType = 'constructor'` (or `'__proto__'`,
24+
* `'toString'`, …) resolves through the prototype chain to a function, the
25+
* `?? objectType` fallback never fires, and `validatePathSegment` then calls a
26+
* string method on a non-string and throws — turning caller-controlled input
27+
* into a 500. Those names are legal HubSpot custom-object spellings, so they
28+
* must reach the validator as the strings they are.
2129
*/
2230
const BUILT_IN_PATH: Record<string, string> = {
2331
contact: 'contacts',
@@ -64,7 +72,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6472
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
6573
}
6674

67-
const pathSegment = BUILT_IN_PATH[objectType] ?? objectType
75+
const pathSegment = Object.hasOwn(BUILT_IN_PATH, objectType)
76+
? BUILT_IN_PATH[objectType]
77+
: objectType
6878
const pathSegmentValidation = validatePathSegment(pathSegment, { paramName: 'objectType' })
6979
if (!pathSegmentValidation.isValid) {
7080
logger.warn(`[${requestId}] Invalid objectType: ${pathSegmentValidation.error}`)

apps/sim/app/api/tools/hubspot/properties/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ const logger = createLogger('HubSpotPropertiesAPI')
1818
* only to a non-empty string. That value lands in a path segment, so it is
1919
* validated before use — `encodeURIComponent` alone leaves a `.`/`..` segment
2020
* intact and the WHATWG parser removes it, re-aiming the authenticated request.
21+
*
22+
* The lookup goes through `Object.hasOwn` rather than plain indexing: on a
23+
* plain object literal, `objectType = 'constructor'` (or `'__proto__'`,
24+
* `'toString'`, …) resolves through the prototype chain to a function, the
25+
* `?? objectType` fallback never fires, and `validatePathSegment` then calls a
26+
* string method on a non-string and throws — turning caller-controlled input
27+
* into a 500. Those names are legal HubSpot custom-object spellings, so they
28+
* must reach the validator as the strings they are.
2129
*/
2230
const BUILT_IN_PATH: Record<string, string> = {
2331
contact: 'contacts',
@@ -67,7 +75,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6775
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
6876
}
6977

70-
const pathSegment = BUILT_IN_PATH[objectType] ?? objectType
78+
const pathSegment = Object.hasOwn(BUILT_IN_PATH, objectType)
79+
? BUILT_IN_PATH[objectType]
80+
: objectType
7181
const pathSegmentValidation = validatePathSegment(pathSegment, { paramName: 'objectType' })
7282
if (!pathSegmentValidation.isValid) {
7383
logger.warn(`[${requestId}] Invalid objectType: ${pathSegmentValidation.error}`)

apps/sim/app/api/tools/twilio/get-recording/route.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
inputValidationMockFns,
2525
} from '@sim/testing'
2626
import { beforeEach, describe, expect, it, vi } from 'vitest'
27+
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
2728

2829
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
2930

@@ -206,3 +207,76 @@ describe('POST /api/tools/twilio/get-recording — media size cap', () => {
206207
})
207208
})
208209
})
210+
211+
describe('POST /api/tools/twilio/get-recording — over-limit media', () => {
212+
/**
213+
* The cap is enforced by `secureFetchWithPinnedIP` while the body streams, so
214+
* it surfaces as a rejected promise *inside* the media `try`. A blanket
215+
* `catch` there logged a warning and fell through to the success response
216+
* with `file` simply absent — indistinguishable, to the caller, from a
217+
* recording that has no media yet. An unavailable recording must never look
218+
* like a retrieved one.
219+
*/
220+
it('returns an explicit failure, not success-with-no-file, when the media exceeds the cap', async () => {
221+
mockSecureFetchWithPinnedIP
222+
.mockResolvedValueOnce(jsonResponse(recordingPayload))
223+
.mockResolvedValueOnce(jsonResponse({ transcriptions: [] }))
224+
.mockRejectedValueOnce(
225+
new PayloadSizeLimitError({
226+
label: 'response body',
227+
maxBytes: MAX_TWILIO_RECORDING_BYTES,
228+
observedBytes: MAX_TWILIO_RECORDING_BYTES + 1,
229+
})
230+
)
231+
232+
const response = await POST(createMockRequest('POST', baseBody))
233+
234+
expect(response.status).toBe(413)
235+
const data = (await response.json()) as {
236+
success: boolean
237+
error?: string
238+
output?: { file?: unknown }
239+
}
240+
expect(data.success).toBe(false)
241+
expect(data.error).toBeTruthy()
242+
expect(data.error).toMatch(new RegExp(String(MAX_TWILIO_RECORDING_BYTES)))
243+
expect(data.output?.file).toBeUndefined()
244+
})
245+
246+
it('still degrades to success-without-file for the other media errors that catch exists for', async () => {
247+
mockSecureFetchWithPinnedIP
248+
.mockResolvedValueOnce(jsonResponse(recordingPayload))
249+
.mockResolvedValueOnce(jsonResponse({ transcriptions: [] }))
250+
.mockRejectedValueOnce(new Error('socket hang up'))
251+
252+
const response = await POST(createMockRequest('POST', baseBody))
253+
254+
expect(response.status).toBe(200)
255+
const data = (await response.json()) as {
256+
success: boolean
257+
output: { file?: unknown; mediaUrl?: string }
258+
}
259+
expect(data.success).toBe(true)
260+
expect(data.output.file).toBeUndefined()
261+
expect(data.output.mediaUrl).toBe(
262+
`https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}/Recordings/${RECORDING_SID}`
263+
)
264+
})
265+
266+
it('still degrades to success-without-file when media URL validation fails', async () => {
267+
mockSecureFetchWithPinnedIP
268+
.mockResolvedValueOnce(jsonResponse(recordingPayload))
269+
.mockResolvedValueOnce(jsonResponse({ transcriptions: [] }))
270+
mockValidateUrlWithDNS
271+
.mockResolvedValueOnce({ isValid: true, resolvedIP: PINNED_IP })
272+
.mockResolvedValueOnce({ isValid: true, resolvedIP: PINNED_IP })
273+
.mockResolvedValueOnce({ isValid: false, error: 'blocked host' })
274+
275+
const response = await POST(createMockRequest('POST', baseBody))
276+
277+
expect(response.status).toBe(200)
278+
const data = (await response.json()) as { success: boolean; output: { file?: unknown } }
279+
expect(data.success).toBe(true)
280+
expect(data.output.file).toBeUndefined()
281+
})
282+
})

apps/sim/app/api/tools/twilio/get-recording/route.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
validateUrlWithDNS,
1010
} from '@/lib/core/security/input-validation.server'
1111
import { generateRequestId } from '@/lib/core/utils/request'
12+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
1415

@@ -248,6 +249,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
248249
}
249250
}
250251
} catch (error) {
252+
/**
253+
* The size cap is enforced while the body streams, so it surfaces here
254+
* rather than as a non-ok response. It must not share the fall-through
255+
* below: that path returns `success: true` with `file` absent, which is
256+
* exactly how a recording with no media yet looks. Reporting an
257+
* over-limit recording as a successful retrieval that happened to
258+
* return nothing is worse than reporting nothing at all.
259+
*/
260+
if (isPayloadSizeLimitError(error)) {
261+
logger.warn(`[${requestId}] Twilio recording media exceeds the transportable size cap`, {
262+
recordingSid: data.sid ?? recordingSid,
263+
maxBytes: MAX_TWILIO_RECORDING_BYTES,
264+
})
265+
const message = `Recording media exceeds the maximum transportable size of ${MAX_TWILIO_RECORDING_BYTES} bytes and cannot be returned inline. Download it directly from ${mediaUrl}.`
266+
return NextResponse.json(
267+
{
268+
success: false,
269+
output: { success: false, error: message, mediaUrl },
270+
error: message,
271+
},
272+
{ status: 413 }
273+
)
274+
}
251275
logger.warn(`[${requestId}] Failed to download recording media:`, error)
252276
}
253277
}

0 commit comments

Comments
 (0)