Skip to content

Commit 3326eb2

Browse files
committed
fix(tools): close request audit gaps
1 parent 80abba3 commit 3326eb2

3 files changed

Lines changed: 163 additions & 33 deletions

File tree

apps/sim/tools/request-transport.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const EXCEL_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadshe
3535
function createSchemaProbeParams(
3636
tool: ToolConfig,
3737
includeOptional: boolean,
38-
adversarialStrings = false
38+
adversarialPathStrings = false
3939
) {
4040
const params: Record<string, unknown> = {
4141
_context: PROBE_CONTEXT,
@@ -59,7 +59,7 @@ function createSchemaProbeParams(
5959
else if (schema.type === 'json') value = includeOptional ? [{ id: 'item-probe' }] : {}
6060
else if (schema.type === 'number') value = 1
6161
else if (schema.type === 'boolean') value = includeOptional
62-
else if (adversarialStrings) value = '../probe?next=/api'
62+
else if (adversarialPathStrings) value = '../probe?next=/api'
6363
else value = name.toLowerCase().includes('id') ? 'id-probe' : 'probe'
6464

6565
if (value !== undefined) params[name] = value

scripts/check-tool-request-boundary.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'
22
import { auditToolRequestTrust } from './check-tool-request-boundary'
33

44
const PARAM_TEMPLATE_EXPRESSION = '$' + '{params.id}'
5+
const INPUT_TEMPLATE_EXPRESSION = '$' + '{input.id}'
6+
const DESTRUCTURED_TEMPLATE_EXPRESSION = '$' + '{id}'
7+
const ENCODED_TEMPLATE_EXPRESSION = '$' + '{encodeURIComponent(params.id)}'
58

69
function auditRequest(request: string) {
710
return auditToolRequestTrust(`
@@ -81,6 +84,46 @@ describe('tool request trust audit', () => {
8184
expect(audit.violations).toEqual([])
8285
})
8386

87+
it('rejects an unencoded path parameter with a renamed callback binding', () => {
88+
const audit = auditRequest(
89+
`internal: true, url: (input) => \`/api/tools/${INPUT_TEMPLATE_EXPRESSION}\``
90+
)
91+
92+
expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation')
93+
})
94+
95+
it('rejects an unencoded destructured callback binding', () => {
96+
const audit = auditRequest(
97+
`internal: true, url: ({ id }) => \`/api/tools/${DESTRUCTURED_TEMPLATE_EXPRESSION}\``
98+
)
99+
100+
expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation')
101+
})
102+
103+
it('rejects an unencoded template nested inside a concatenation', () => {
104+
const audit = auditRequest(
105+
`internal: true, url: (params) => '/api/tools/' + \`${PARAM_TEMPLATE_EXPRESSION}\``
106+
)
107+
108+
expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation')
109+
})
110+
111+
it('accepts an encoded template nested inside a concatenation', () => {
112+
const audit = auditRequest(
113+
`internal: true, url: (params) => '/api/tools/' + \`${ENCODED_TEMPLATE_EXPRESSION}\``
114+
)
115+
116+
expect(audit.violations).toEqual([])
117+
})
118+
119+
it('allows a raw query value after a nested encoded path template', () => {
120+
const audit = auditRequest(
121+
`internal: true, url: (params) => '/api/tools/' + \`${ENCODED_TEMPLATE_EXPRESSION}?query=\` + params.query`
122+
)
123+
124+
expect(audit.violations).toEqual([])
125+
})
126+
84127
it('accepts a definition-owned policy for conditional internal and external branches', () => {
85128
const audit = auditRequest(`
86129
internal: (params) => params.internal,

scripts/check-tool-request-boundary.ts

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,67 @@ function functionContainsExternalRoute(fn: SyntaxNode): boolean {
274274
return found
275275
}
276276

277-
function containsParamsReference(expression: SyntaxNode): boolean {
277+
function collectBindingIdentifiers(pattern: SyntaxNode, bindings: Set<string>): void {
278+
const current = unwrapExpression(pattern)
279+
if (current.type === 'Identifier' && typeof current.name === 'string') {
280+
bindings.add(current.name)
281+
return
282+
}
283+
if (current.type === 'AssignmentPattern' && isSyntaxNode(current.left)) {
284+
collectBindingIdentifiers(current.left, bindings)
285+
return
286+
}
287+
if (current.type === 'RestElement' && isSyntaxNode(current.argument)) {
288+
collectBindingIdentifiers(current.argument, bindings)
289+
return
290+
}
291+
if (current.type === 'TSParameterProperty' && isSyntaxNode(current.parameter)) {
292+
collectBindingIdentifiers(current.parameter, bindings)
293+
return
294+
}
295+
if (current.type === 'ObjectPattern' && Array.isArray(current.properties)) {
296+
for (const property of current.properties) {
297+
if (!isSyntaxNode(property)) continue
298+
if (property.type === 'RestElement' && isSyntaxNode(property.argument)) {
299+
collectBindingIdentifiers(property.argument, bindings)
300+
} else if (property.type === 'ObjectProperty' && isSyntaxNode(property.value)) {
301+
collectBindingIdentifiers(property.value, bindings)
302+
}
303+
}
304+
return
305+
}
306+
if (current.type === 'ArrayPattern' && Array.isArray(current.elements)) {
307+
for (const element of current.elements) {
308+
if (isSyntaxNode(element)) collectBindingIdentifiers(element, bindings)
309+
}
310+
}
311+
}
312+
313+
function getFunctionParameterBindings(fn: SyntaxNode): Set<string> {
314+
const bindings = new Set<string>()
315+
const current = unwrapExpression(fn)
316+
if (!Array.isArray(current.params)) return bindings
317+
for (const param of current.params) {
318+
if (isSyntaxNode(param)) collectBindingIdentifiers(param, bindings)
319+
}
320+
return bindings
321+
}
322+
323+
function containsParameterReference(
324+
expression: SyntaxNode,
325+
parameterBindings: ReadonlySet<string>
326+
): boolean {
278327
const current = unwrapExpression(expression)
279-
if (current.type === 'Identifier' && current.name === 'params') return true
280-
return getChildNodes(current).some((child) => containsParamsReference(child))
328+
if (
329+
current.type === 'Identifier' &&
330+
typeof current.name === 'string' &&
331+
parameterBindings.has(current.name)
332+
) {
333+
return true
334+
}
335+
return getChildNodes(current).some((child) =>
336+
containsParameterReference(child, parameterBindings)
337+
)
281338
}
282339

283340
function isEncodedPathExpression(expression: SyntaxNode): boolean {
@@ -303,8 +360,48 @@ function getConcatenationParts(expression: SyntaxNode): SyntaxNode[] {
303360
return [...getConcatenationParts(current.left), ...getConcatenationParts(current.right)]
304361
}
305362

363+
function templateElementContainsQuery(element: unknown): boolean {
364+
if (!isSyntaxNode(element)) return false
365+
const value = element.value
366+
return (
367+
typeof value === 'object' &&
368+
value !== null &&
369+
(('cooked' in value && typeof value.cooked === 'string' && value.cooked.includes('?')) ||
370+
('raw' in value && typeof value.raw === 'string' && value.raw.includes('?')))
371+
)
372+
}
373+
374+
function inspectTemplatePathExpressions(
375+
template: SyntaxNode,
376+
parameterBindings: ReadonlySet<string>,
377+
initialQueryStarted = false
378+
): { queryStarted: boolean; unsafe: boolean } {
379+
if (!Array.isArray(template.quasis) || !Array.isArray(template.expressions)) {
380+
return { queryStarted: initialQueryStarted, unsafe: false }
381+
}
382+
383+
let queryStarted = initialQueryStarted
384+
for (let index = 0; index < template.expressions.length; index++) {
385+
if (templateElementContainsQuery(template.quasis[index])) queryStarted = true
386+
const expression = template.expressions[index]
387+
if (
388+
!queryStarted &&
389+
isSyntaxNode(expression) &&
390+
containsParameterReference(expression, parameterBindings) &&
391+
!isEncodedPathExpression(expression)
392+
) {
393+
return { queryStarted, unsafe: true }
394+
}
395+
}
396+
if (templateElementContainsQuery(template.quasis[template.expressions.length])) {
397+
queryStarted = true
398+
}
399+
return { queryStarted, unsafe: false }
400+
}
401+
306402
function functionContainsUnsafeInternalPathInterpolation(fn: SyntaxNode): boolean {
307403
const current = unwrapExpression(fn)
404+
const parameterBindings = getFunctionParameterBindings(current)
308405
let found = false
309406

310407
const visit = (node: SyntaxNode) => {
@@ -316,12 +413,25 @@ function functionContainsUnsafeInternalPathInterpolation(fn: SyntaxNode): boolea
316413
) {
317414
let queryStarted = false
318415
for (const part of getConcatenationParts(node)) {
416+
const currentPart = unwrapExpression(part)
417+
if (currentPart.type === 'TemplateLiteral') {
418+
const inspected = inspectTemplatePathExpressions(
419+
currentPart,
420+
parameterBindings,
421+
queryStarted
422+
)
423+
if (inspected.unsafe) {
424+
found = true
425+
return
426+
}
427+
queryStarted = inspected.queryStarted
428+
continue
429+
}
319430
const prefix = getStringPrefix(part)
320431
if (prefix?.includes('?')) queryStarted = true
321432
if (
322433
!queryStarted &&
323-
unwrapExpression(part).type !== 'TemplateLiteral' &&
324-
containsParamsReference(part) &&
434+
containsParameterReference(part, parameterBindings) &&
325435
!isEncodedPathExpression(part)
326436
) {
327437
found = true
@@ -337,32 +447,9 @@ function functionContainsUnsafeInternalPathInterpolation(fn: SyntaxNode): boolea
337447
isSyntaxNode(node.quasis[0]) &&
338448
getStringPrefix(node)?.startsWith('/api/')
339449
) {
340-
let queryStarted = false
341-
for (let index = 0; index < node.expressions.length; index++) {
342-
const quasi = node.quasis[index]
343-
if (isSyntaxNode(quasi)) {
344-
const value = quasi.value
345-
if (
346-
typeof value === 'object' &&
347-
value !== null &&
348-
(('cooked' in value &&
349-
typeof value.cooked === 'string' &&
350-
value.cooked.includes('?')) ||
351-
('raw' in value && typeof value.raw === 'string' && value.raw.includes('?')))
352-
) {
353-
queryStarted = true
354-
}
355-
}
356-
const expression = node.expressions[index]
357-
if (
358-
!queryStarted &&
359-
isSyntaxNode(expression) &&
360-
containsParamsReference(expression) &&
361-
!isEncodedPathExpression(expression)
362-
) {
363-
found = true
364-
return
365-
}
450+
if (inspectTemplatePathExpressions(node, parameterBindings).unsafe) {
451+
found = true
452+
return
366453
}
367454
}
368455
for (const child of getChildNodes(node)) visit(child)

0 commit comments

Comments
 (0)