Skip to content

Commit d6654a5

Browse files
committed
fix(tools): validate request trust policies
1 parent 4d3fce1 commit d6654a5

2 files changed

Lines changed: 87 additions & 24 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ describe('tool request trust audit', () => {
135135
expect(audit.violations).toEqual([])
136136
})
137137

138+
it('accepts a named predicate for conditional internal and external branches', () => {
139+
const audit = auditRequest(`
140+
internal: usesInternalRoute,
141+
url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test'
142+
`)
143+
144+
expect(audit.violations).toEqual([])
145+
})
146+
138147
it('rejects static trust for a mixed internal and external URL builder', () => {
139148
const audit = auditRequest(`
140149
internal: true,
@@ -149,6 +158,32 @@ describe('tool request trust audit', () => {
149158
])
150159
})
151160

161+
it('detects an external URL constructor in a mixed URL builder', () => {
162+
const audit = auditRequest(`
163+
internal: true,
164+
url: (params) =>
165+
params.internal
166+
? '/api/tools/test'
167+
: new URL('https://example.com/test').toString()
168+
`)
169+
170+
expect(audit.violations[0]?.reason).toBe('mixed-route-requires-conditional-policy')
171+
})
172+
173+
it('rejects false as an internal route policy', () => {
174+
const audit = auditRequest(`
175+
internal: false,
176+
url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test'
177+
`)
178+
179+
expect(audit.violations).toEqual([
180+
expect.objectContaining({
181+
toolId: 'test_tool',
182+
reason: 'invalid-internal-policy',
183+
}),
184+
])
185+
})
186+
152187
it('detects internal paths constructed through URL', () => {
153188
const audit = auditRequest(`
154189
url: (params) => {

scripts/check-tool-request-boundary.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface RequestTrustViolation {
3131
toolId?: string
3232
reason:
3333
| 'missing-internal-policy'
34+
| 'invalid-internal-policy'
3435
| 'internal-policy-without-internal-route'
3536
| 'mixed-route-requires-conditional-policy'
3637
| 'unsafe-internal-path-interpolation'
@@ -196,7 +197,7 @@ function isExternalUrlExpression(expression: SyntaxNode): boolean {
196197
return false
197198
}
198199

199-
function isInternalUrlConstruction(node: SyntaxNode): boolean {
200+
function getUrlConstructionPrefix(node: SyntaxNode): string | undefined {
200201
const current = unwrapExpression(node)
201202
if (
202203
current.type !== 'NewExpression' ||
@@ -207,9 +208,18 @@ function isInternalUrlConstruction(node: SyntaxNode): boolean {
207208
current.arguments.length === 0 ||
208209
!isSyntaxNode(current.arguments[0])
209210
) {
210-
return false
211+
return undefined
211212
}
212-
return getStringPrefix(current.arguments[0])?.startsWith('/api/') === true
213+
return getStringPrefix(current.arguments[0])
214+
}
215+
216+
function isInternalUrlConstruction(node: SyntaxNode): boolean {
217+
return getUrlConstructionPrefix(node)?.startsWith('/api/') === true
218+
}
219+
220+
function isExternalUrlConstruction(node: SyntaxNode): boolean {
221+
const prefix = getUrlConstructionPrefix(node)
222+
return prefix !== undefined && /^https?:\/\//.test(prefix)
213223
}
214224

215225
function functionContainsInternalRoute(fn: SyntaxNode): boolean {
@@ -268,6 +278,10 @@ function functionContainsExternalRoute(fn: SyntaxNode): boolean {
268278
found = true
269279
return
270280
}
281+
if (isExternalUrlConstruction(node)) {
282+
found = true
283+
return
284+
}
271285
for (const child of getChildNodes(node)) visit(child)
272286
}
273287
visit(current)
@@ -498,35 +512,47 @@ export function auditToolRequestTrust(source: string, file = 'source.ts'): Reque
498512
const hasExternalRoute = functionContainsExternalRoute(url)
499513
const hasInternalPolicy = internalProperty !== undefined
500514
const internalPolicyValue = internalProperty?.value
501-
const hasConditionalInternalPolicy =
515+
const internalPolicyType = isSyntaxNode(internalPolicyValue)
516+
? unwrapExpression(internalPolicyValue).type
517+
: undefined
518+
const hasStaticInternalPolicy =
519+
internalPolicyType === 'BooleanLiteral' &&
502520
isSyntaxNode(internalPolicyValue) &&
503-
!(
504-
unwrapExpression(internalPolicyValue).type === 'BooleanLiteral' &&
505-
unwrapExpression(internalPolicyValue).value === true
506-
)
521+
unwrapExpression(internalPolicyValue).value === true
522+
const hasConditionalInternalPolicy =
523+
internalPolicyType === 'ArrowFunctionExpression' ||
524+
internalPolicyType === 'FunctionExpression' ||
525+
internalPolicyType === 'Identifier'
526+
const hasValidInternalPolicy = hasStaticInternalPolicy || hasConditionalInternalPolicy
507527
const hasUnsafeInternalPathInterpolation =
508528
functionContainsUnsafeInternalPathInterpolation(url)
509529
if (hasInternalRoute) dynamicInternalRoutes += 1
510530
if (hasInternalPolicy) dynamicInternalPolicies += 1
511-
if (
512-
(hasInternalRoute && !hasInternalPolicy) ||
513-
(hasInternalPolicy &&
514-
hasExternalRoute &&
515-
!hasInternalRoute &&
516-
!hasConditionalInternalPolicy)
517-
) {
531+
if (hasInternalPolicy && !hasValidInternalPolicy) {
532+
violations.push({
533+
file,
534+
line: internalProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
535+
toolId: getToolId(node),
536+
reason: 'invalid-internal-policy',
537+
})
538+
} else if (hasInternalRoute && !hasInternalPolicy) {
539+
violations.push({
540+
file,
541+
line: urlProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
542+
toolId: getToolId(node),
543+
reason: 'missing-internal-policy',
544+
})
545+
} else if (hasStaticInternalPolicy && hasExternalRoute && !hasInternalRoute) {
518546
const location = (hasInternalPolicy ? internalProperty : urlProperty)?.loc?.start.line
519547
violations.push({
520548
file,
521549
line: location ?? requestProperty.loc?.start.line ?? 1,
522550
toolId: getToolId(node),
523-
reason: hasInternalRoute
524-
? 'missing-internal-policy'
525-
: 'internal-policy-without-internal-route',
551+
reason: 'internal-policy-without-internal-route',
526552
})
527553
}
528554
if (
529-
hasInternalPolicy &&
555+
hasValidInternalPolicy &&
530556
hasInternalRoute &&
531557
hasExternalRoute &&
532558
!hasConditionalInternalPolicy
@@ -740,11 +766,13 @@ function main(): void {
740766
const description =
741767
violation.reason === 'missing-internal-policy'
742768
? 'dynamic /api route is missing request.internal'
743-
: violation.reason === 'internal-policy-without-internal-route'
744-
? 'request.internal is declared but the URL builder has no /api route'
745-
: violation.reason === 'mixed-route-requires-conditional-policy'
746-
? 'mixed internal/external URL builder requires a predicate request.internal policy'
747-
: 'dynamic /api path parameter must use encodeURIComponent'
769+
: violation.reason === 'invalid-internal-policy'
770+
? 'request.internal must be true or a predicate function'
771+
: violation.reason === 'internal-policy-without-internal-route'
772+
? 'request.internal is declared but the URL builder has no /api route'
773+
: violation.reason === 'mixed-route-requires-conditional-policy'
774+
? 'mixed internal/external URL builder requires a predicate request.internal policy'
775+
: 'dynamic /api path parameter must use encodeURIComponent'
748776
console.error(
749777
` ${relative(ROOT, violation.file)}:${violation.line} ${violation.toolId ?? 'unknown tool'}: ${description}`
750778
)

0 commit comments

Comments
 (0)