mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
fix(security): require explicit trust for internal tool routes (#7089)
* fix: require explicit trust for internal tool routes * fix(tools): harden internal route validation * fix(tools): close request audit gaps * fix(tools): validate request trust policies
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { auditToolRequestTrust } from './check-tool-request-boundary'
|
||||
|
||||
const PARAM_TEMPLATE_EXPRESSION = '$' + '{params.id}'
|
||||
const INPUT_TEMPLATE_EXPRESSION = '$' + '{input.id}'
|
||||
const DESTRUCTURED_TEMPLATE_EXPRESSION = '$' + '{id}'
|
||||
const ENCODED_TEMPLATE_EXPRESSION = '$' + '{encodeURIComponent(params.id)}'
|
||||
|
||||
function auditRequest(request: string) {
|
||||
return auditToolRequestTrust(`
|
||||
const tool = {
|
||||
id: 'test_tool',
|
||||
request: { ${request} },
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
describe('tool request trust audit', () => {
|
||||
it('allows a literal internal URL because the definition owns the full path', () => {
|
||||
const audit = auditRequest("url: '/api/tools/test', method: 'GET', headers: () => ({})")
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(0)
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an unmarked dynamic internal URL', () => {
|
||||
const audit = auditRequest(`url: (params) => \`/api/tools/\${encodeURIComponent(params.id)}\``)
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(1)
|
||||
expect(audit.violations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolId: 'test_tool',
|
||||
reason: 'missing-internal-policy',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts a marked dynamic internal URL', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: (params) => \`/api/tools/\${encodeURIComponent(params.id)}\``
|
||||
)
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(1)
|
||||
expect(audit.dynamicInternalPolicies).toBe(1)
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an unencoded internal path parameter', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: (params) => \`/api/tools/${PARAM_TEMPLATE_EXPRESSION}\``
|
||||
)
|
||||
|
||||
expect(audit.violations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolId: 'test_tool',
|
||||
reason: 'unsafe-internal-path-interpolation',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('detects a concatenated dynamic internal URL', () => {
|
||||
const audit = auditRequest("url: (params) => '/api/tools/' + encodeURIComponent(params.id)")
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(1)
|
||||
expect(audit.violations[0]?.reason).toBe('missing-internal-policy')
|
||||
})
|
||||
|
||||
it('rejects an unencoded concatenated internal path parameter', () => {
|
||||
const audit = auditRequest("internal: true, url: (params) => '/api/tools/' + params.id")
|
||||
|
||||
expect(audit.violations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolId: 'test_tool',
|
||||
reason: 'unsafe-internal-path-interpolation',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts an encoded concatenated internal path parameter', () => {
|
||||
const audit = auditRequest(
|
||||
"internal: true, url: (params) => '/api/tools/' + encodeURIComponent(params.id)"
|
||||
)
|
||||
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an unencoded path parameter with a renamed callback binding', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: (input) => \`/api/tools/${INPUT_TEMPLATE_EXPRESSION}\``
|
||||
)
|
||||
|
||||
expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation')
|
||||
})
|
||||
|
||||
it('rejects an unencoded destructured callback binding', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: ({ id }) => \`/api/tools/${DESTRUCTURED_TEMPLATE_EXPRESSION}\``
|
||||
)
|
||||
|
||||
expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation')
|
||||
})
|
||||
|
||||
it('rejects an unencoded template nested inside a concatenation', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: (params) => '/api/tools/' + \`${PARAM_TEMPLATE_EXPRESSION}\``
|
||||
)
|
||||
|
||||
expect(audit.violations[0]?.reason).toBe('unsafe-internal-path-interpolation')
|
||||
})
|
||||
|
||||
it('accepts an encoded template nested inside a concatenation', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: (params) => '/api/tools/' + \`${ENCODED_TEMPLATE_EXPRESSION}\``
|
||||
)
|
||||
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('allows a raw query value after a nested encoded path template', () => {
|
||||
const audit = auditRequest(
|
||||
`internal: true, url: (params) => '/api/tools/' + \`${ENCODED_TEMPLATE_EXPRESSION}?query=\` + params.query`
|
||||
)
|
||||
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a definition-owned policy for conditional internal and external branches', () => {
|
||||
const audit = auditRequest(`
|
||||
internal: (params) => params.internal,
|
||||
url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test'
|
||||
`)
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(1)
|
||||
expect(audit.dynamicInternalPolicies).toBe(1)
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a named predicate for conditional internal and external branches', () => {
|
||||
const audit = auditRequest(`
|
||||
internal: usesInternalRoute,
|
||||
url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test'
|
||||
`)
|
||||
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects static trust for a mixed internal and external URL builder', () => {
|
||||
const audit = auditRequest(`
|
||||
internal: true,
|
||||
url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test'
|
||||
`)
|
||||
|
||||
expect(audit.violations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolId: 'test_tool',
|
||||
reason: 'mixed-route-requires-conditional-policy',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('detects an external URL constructor in a mixed URL builder', () => {
|
||||
const audit = auditRequest(`
|
||||
internal: true,
|
||||
url: (params) =>
|
||||
params.internal
|
||||
? '/api/tools/test'
|
||||
: new URL('https://example.com/test').toString()
|
||||
`)
|
||||
|
||||
expect(audit.violations[0]?.reason).toBe('mixed-route-requires-conditional-policy')
|
||||
})
|
||||
|
||||
it('rejects false as an internal route policy', () => {
|
||||
const audit = auditRequest(`
|
||||
internal: false,
|
||||
url: (params) => params.internal ? '/api/tools/test' : 'https://example.com/test'
|
||||
`)
|
||||
|
||||
expect(audit.violations).toEqual([
|
||||
expect.objectContaining({
|
||||
toolId: 'test_tool',
|
||||
reason: 'invalid-internal-policy',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('detects internal paths constructed through URL', () => {
|
||||
const audit = auditRequest(`
|
||||
url: (params) => {
|
||||
const url = new URL('/api/tools/test', 'http://placeholder')
|
||||
url.searchParams.set('id', params.id)
|
||||
return url.pathname + url.search
|
||||
}
|
||||
`)
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(1)
|
||||
expect(audit.violations[0]?.reason).toBe('missing-internal-policy')
|
||||
})
|
||||
|
||||
it('rejects internal trust on an external-only URL builder', () => {
|
||||
const audit = auditRequest("internal: true, url: () => 'https://example.com/test'")
|
||||
|
||||
expect(audit.violations[0]?.reason).toBe('internal-policy-without-internal-route')
|
||||
})
|
||||
|
||||
it('allows an explicit policy when a helper owns the internal route construction', () => {
|
||||
const audit = auditRequest('internal: true, url: (params) => buildInternalRoute(params.id)')
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(0)
|
||||
expect(audit.dynamicInternalPolicies).toBe(1)
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
|
||||
it('does not mistake a provider-relative helper argument for a Sim API route', () => {
|
||||
const audit = auditRequest("url: (params) => providerUrl('/api/messages', params.host)")
|
||||
|
||||
expect(audit.dynamicInternalRoutes).toBe(0)
|
||||
expect(audit.violations).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,24 @@ interface Violation {
|
||||
expression: string
|
||||
}
|
||||
|
||||
export interface RequestTrustViolation {
|
||||
file: string
|
||||
line: number
|
||||
toolId?: string
|
||||
reason:
|
||||
| 'missing-internal-policy'
|
||||
| 'invalid-internal-policy'
|
||||
| 'internal-policy-without-internal-route'
|
||||
| 'mixed-route-requires-conditional-policy'
|
||||
| 'unsafe-internal-path-interpolation'
|
||||
}
|
||||
|
||||
export interface RequestTrustAudit {
|
||||
violations: RequestTrustViolation[]
|
||||
dynamicInternalRoutes: number
|
||||
dynamicInternalPolicies: number
|
||||
}
|
||||
|
||||
interface SyntaxNode extends Record<string, unknown> {
|
||||
type: string
|
||||
start?: number | null
|
||||
@@ -92,6 +110,478 @@ function unwrapExpression(expression: SyntaxNode): SyntaxNode {
|
||||
return current
|
||||
}
|
||||
|
||||
function getStaticPropertyName(property: SyntaxNode): string | undefined {
|
||||
if (!isSyntaxNode(property.key)) return undefined
|
||||
const key = property.key
|
||||
if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
|
||||
if (key.type === 'StringLiteral' && typeof key.value === 'string') return key.value
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getObjectProperty(object: SyntaxNode, name: string): SyntaxNode | undefined {
|
||||
if (object.type !== 'ObjectExpression' || !Array.isArray(object.properties)) return undefined
|
||||
return object.properties.find(
|
||||
(property): property is SyntaxNode =>
|
||||
isSyntaxNode(property) &&
|
||||
property.type === 'ObjectProperty' &&
|
||||
getStaticPropertyName(property) === name
|
||||
)
|
||||
}
|
||||
|
||||
function getStringPrefix(expression: SyntaxNode): string | undefined {
|
||||
const current = unwrapExpression(expression)
|
||||
if (current.type === 'StringLiteral' && typeof current.value === 'string') {
|
||||
return current.value
|
||||
}
|
||||
if (
|
||||
current.type === 'TemplateLiteral' &&
|
||||
Array.isArray(current.quasis) &&
|
||||
current.quasis.length > 0 &&
|
||||
isSyntaxNode(current.quasis[0])
|
||||
) {
|
||||
const value = current.quasis[0].value
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if ('cooked' in value && typeof value.cooked === 'string') return value.cooked
|
||||
if ('raw' in value && typeof value.raw === 'string') return value.raw
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isInternalPathExpression(expression: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(expression)
|
||||
const prefix = getStringPrefix(current)
|
||||
if (prefix?.startsWith('/api/')) return true
|
||||
|
||||
if (current.type === 'ConditionalExpression') {
|
||||
return (
|
||||
(isSyntaxNode(current.consequent) && isInternalPathExpression(current.consequent)) ||
|
||||
(isSyntaxNode(current.alternate) && isInternalPathExpression(current.alternate))
|
||||
)
|
||||
}
|
||||
if (current.type === 'LogicalExpression') {
|
||||
return (
|
||||
(isSyntaxNode(current.left) && isInternalPathExpression(current.left)) ||
|
||||
(isSyntaxNode(current.right) && isInternalPathExpression(current.right))
|
||||
)
|
||||
}
|
||||
if (current.type === 'BinaryExpression' && current.operator === '+') {
|
||||
return (
|
||||
(isSyntaxNode(current.left) && isInternalPathExpression(current.left)) ||
|
||||
(isSyntaxNode(current.right) && isInternalPathExpression(current.right))
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isExternalUrlExpression(expression: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(expression)
|
||||
const prefix = getStringPrefix(current)
|
||||
if (prefix && /^https?:\/\//.test(prefix)) return true
|
||||
|
||||
if (current.type === 'ConditionalExpression') {
|
||||
return (
|
||||
(isSyntaxNode(current.consequent) && isExternalUrlExpression(current.consequent)) ||
|
||||
(isSyntaxNode(current.alternate) && isExternalUrlExpression(current.alternate))
|
||||
)
|
||||
}
|
||||
if (
|
||||
current.type === 'LogicalExpression' ||
|
||||
(current.type === 'BinaryExpression' && current.operator === '+')
|
||||
) {
|
||||
return (
|
||||
(isSyntaxNode(current.left) && isExternalUrlExpression(current.left)) ||
|
||||
(isSyntaxNode(current.right) && isExternalUrlExpression(current.right))
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getUrlConstructionPrefix(node: SyntaxNode): string | undefined {
|
||||
const current = unwrapExpression(node)
|
||||
if (
|
||||
current.type !== 'NewExpression' ||
|
||||
!isSyntaxNode(current.callee) ||
|
||||
current.callee.type !== 'Identifier' ||
|
||||
current.callee.name !== 'URL' ||
|
||||
!Array.isArray(current.arguments) ||
|
||||
current.arguments.length === 0 ||
|
||||
!isSyntaxNode(current.arguments[0])
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return getStringPrefix(current.arguments[0])
|
||||
}
|
||||
|
||||
function isInternalUrlConstruction(node: SyntaxNode): boolean {
|
||||
return getUrlConstructionPrefix(node)?.startsWith('/api/') === true
|
||||
}
|
||||
|
||||
function isExternalUrlConstruction(node: SyntaxNode): boolean {
|
||||
const prefix = getUrlConstructionPrefix(node)
|
||||
return prefix !== undefined && /^https?:\/\//.test(prefix)
|
||||
}
|
||||
|
||||
function functionContainsInternalRoute(fn: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(fn)
|
||||
if (
|
||||
!['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(current.type)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) {
|
||||
const body = unwrapExpression(current.body)
|
||||
if (body.type !== 'BlockStatement' && isInternalPathExpression(body)) return true
|
||||
}
|
||||
|
||||
let found = false
|
||||
const visit = (node: SyntaxNode) => {
|
||||
if (found) return
|
||||
if (
|
||||
node.type === 'ReturnStatement' &&
|
||||
isSyntaxNode(node.argument) &&
|
||||
isInternalPathExpression(node.argument)
|
||||
) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
if (isInternalUrlConstruction(node)) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
for (const child of getChildNodes(node)) visit(child)
|
||||
}
|
||||
visit(current)
|
||||
return found
|
||||
}
|
||||
|
||||
function functionContainsExternalRoute(fn: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(fn)
|
||||
if (
|
||||
!['ArrowFunctionExpression', 'FunctionExpression', 'FunctionDeclaration'].includes(current.type)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) {
|
||||
const body = unwrapExpression(current.body)
|
||||
if (body.type !== 'BlockStatement' && isExternalUrlExpression(body)) return true
|
||||
}
|
||||
|
||||
let found = false
|
||||
const visit = (node: SyntaxNode) => {
|
||||
if (found) return
|
||||
if (
|
||||
node.type === 'ReturnStatement' &&
|
||||
isSyntaxNode(node.argument) &&
|
||||
isExternalUrlExpression(node.argument)
|
||||
) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
if (isExternalUrlConstruction(node)) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
for (const child of getChildNodes(node)) visit(child)
|
||||
}
|
||||
visit(current)
|
||||
return found
|
||||
}
|
||||
|
||||
function collectBindingIdentifiers(pattern: SyntaxNode, bindings: Set<string>): void {
|
||||
const current = unwrapExpression(pattern)
|
||||
if (current.type === 'Identifier' && typeof current.name === 'string') {
|
||||
bindings.add(current.name)
|
||||
return
|
||||
}
|
||||
if (current.type === 'AssignmentPattern' && isSyntaxNode(current.left)) {
|
||||
collectBindingIdentifiers(current.left, bindings)
|
||||
return
|
||||
}
|
||||
if (current.type === 'RestElement' && isSyntaxNode(current.argument)) {
|
||||
collectBindingIdentifiers(current.argument, bindings)
|
||||
return
|
||||
}
|
||||
if (current.type === 'TSParameterProperty' && isSyntaxNode(current.parameter)) {
|
||||
collectBindingIdentifiers(current.parameter, bindings)
|
||||
return
|
||||
}
|
||||
if (current.type === 'ObjectPattern' && Array.isArray(current.properties)) {
|
||||
for (const property of current.properties) {
|
||||
if (!isSyntaxNode(property)) continue
|
||||
if (property.type === 'RestElement' && isSyntaxNode(property.argument)) {
|
||||
collectBindingIdentifiers(property.argument, bindings)
|
||||
} else if (property.type === 'ObjectProperty' && isSyntaxNode(property.value)) {
|
||||
collectBindingIdentifiers(property.value, bindings)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (current.type === 'ArrayPattern' && Array.isArray(current.elements)) {
|
||||
for (const element of current.elements) {
|
||||
if (isSyntaxNode(element)) collectBindingIdentifiers(element, bindings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFunctionParameterBindings(fn: SyntaxNode): Set<string> {
|
||||
const bindings = new Set<string>()
|
||||
const current = unwrapExpression(fn)
|
||||
if (!Array.isArray(current.params)) return bindings
|
||||
for (const param of current.params) {
|
||||
if (isSyntaxNode(param)) collectBindingIdentifiers(param, bindings)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
function containsParameterReference(
|
||||
expression: SyntaxNode,
|
||||
parameterBindings: ReadonlySet<string>
|
||||
): boolean {
|
||||
const current = unwrapExpression(expression)
|
||||
if (
|
||||
current.type === 'Identifier' &&
|
||||
typeof current.name === 'string' &&
|
||||
parameterBindings.has(current.name)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return getChildNodes(current).some((child) =>
|
||||
containsParameterReference(child, parameterBindings)
|
||||
)
|
||||
}
|
||||
|
||||
function isEncodedPathExpression(expression: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(expression)
|
||||
return (
|
||||
current.type === 'CallExpression' &&
|
||||
isSyntaxNode(current.callee) &&
|
||||
current.callee.type === 'Identifier' &&
|
||||
current.callee.name === 'encodeURIComponent'
|
||||
)
|
||||
}
|
||||
|
||||
function getConcatenationParts(expression: SyntaxNode): SyntaxNode[] {
|
||||
const current = unwrapExpression(expression)
|
||||
if (
|
||||
current.type !== 'BinaryExpression' ||
|
||||
current.operator !== '+' ||
|
||||
!isSyntaxNode(current.left) ||
|
||||
!isSyntaxNode(current.right)
|
||||
) {
|
||||
return [current]
|
||||
}
|
||||
return [...getConcatenationParts(current.left), ...getConcatenationParts(current.right)]
|
||||
}
|
||||
|
||||
function templateElementContainsQuery(element: unknown): boolean {
|
||||
if (!isSyntaxNode(element)) return false
|
||||
const value = element.value
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(('cooked' in value && typeof value.cooked === 'string' && value.cooked.includes('?')) ||
|
||||
('raw' in value && typeof value.raw === 'string' && value.raw.includes('?')))
|
||||
)
|
||||
}
|
||||
|
||||
function inspectTemplatePathExpressions(
|
||||
template: SyntaxNode,
|
||||
parameterBindings: ReadonlySet<string>,
|
||||
initialQueryStarted = false
|
||||
): { queryStarted: boolean; unsafe: boolean } {
|
||||
if (!Array.isArray(template.quasis) || !Array.isArray(template.expressions)) {
|
||||
return { queryStarted: initialQueryStarted, unsafe: false }
|
||||
}
|
||||
|
||||
let queryStarted = initialQueryStarted
|
||||
for (let index = 0; index < template.expressions.length; index++) {
|
||||
if (templateElementContainsQuery(template.quasis[index])) queryStarted = true
|
||||
const expression = template.expressions[index]
|
||||
if (
|
||||
!queryStarted &&
|
||||
isSyntaxNode(expression) &&
|
||||
containsParameterReference(expression, parameterBindings) &&
|
||||
!isEncodedPathExpression(expression)
|
||||
) {
|
||||
return { queryStarted, unsafe: true }
|
||||
}
|
||||
}
|
||||
if (templateElementContainsQuery(template.quasis[template.expressions.length])) {
|
||||
queryStarted = true
|
||||
}
|
||||
return { queryStarted, unsafe: false }
|
||||
}
|
||||
|
||||
function functionContainsUnsafeInternalPathInterpolation(fn: SyntaxNode): boolean {
|
||||
const current = unwrapExpression(fn)
|
||||
const parameterBindings = getFunctionParameterBindings(current)
|
||||
let found = false
|
||||
|
||||
const visit = (node: SyntaxNode) => {
|
||||
if (found) return
|
||||
if (
|
||||
node.type === 'BinaryExpression' &&
|
||||
node.operator === '+' &&
|
||||
isInternalPathExpression(node)
|
||||
) {
|
||||
let queryStarted = false
|
||||
for (const part of getConcatenationParts(node)) {
|
||||
const currentPart = unwrapExpression(part)
|
||||
if (currentPart.type === 'TemplateLiteral') {
|
||||
const inspected = inspectTemplatePathExpressions(
|
||||
currentPart,
|
||||
parameterBindings,
|
||||
queryStarted
|
||||
)
|
||||
if (inspected.unsafe) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
queryStarted = inspected.queryStarted
|
||||
continue
|
||||
}
|
||||
const prefix = getStringPrefix(part)
|
||||
if (prefix?.includes('?')) queryStarted = true
|
||||
if (
|
||||
!queryStarted &&
|
||||
containsParameterReference(part, parameterBindings) &&
|
||||
!isEncodedPathExpression(part)
|
||||
) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
node.type === 'TemplateLiteral' &&
|
||||
Array.isArray(node.quasis) &&
|
||||
Array.isArray(node.expressions) &&
|
||||
node.quasis.length > 0 &&
|
||||
isSyntaxNode(node.quasis[0]) &&
|
||||
getStringPrefix(node)?.startsWith('/api/')
|
||||
) {
|
||||
if (inspectTemplatePathExpressions(node, parameterBindings).unsafe) {
|
||||
found = true
|
||||
return
|
||||
}
|
||||
}
|
||||
for (const child of getChildNodes(node)) visit(child)
|
||||
}
|
||||
|
||||
visit(current)
|
||||
return found
|
||||
}
|
||||
|
||||
function getToolId(object: SyntaxNode): string | undefined {
|
||||
const idProperty = getObjectProperty(object, 'id')
|
||||
if (!idProperty || !isSyntaxNode(idProperty.value)) return undefined
|
||||
const value = unwrapExpression(idProperty.value)
|
||||
return value.type === 'StringLiteral' && typeof value.value === 'string' ? value.value : undefined
|
||||
}
|
||||
|
||||
/** Audits dynamic tool URL builders for an explicit, definition-owned internal-route policy. */
|
||||
export function auditToolRequestTrust(source: string, file = 'source.ts'): RequestTrustAudit {
|
||||
const extension = extname(file)
|
||||
const syntaxTree = parse(source, {
|
||||
sourceFilename: file,
|
||||
sourceType: 'unambiguous',
|
||||
errorRecovery: true,
|
||||
plugins: [
|
||||
...(extension === '.jsx' || extension === '.tsx' ? (['jsx'] as const) : []),
|
||||
...(!['.js', '.jsx', '.mjs', '.cjs'].includes(extension) ? (['typescript'] as const) : []),
|
||||
],
|
||||
})
|
||||
const violations: RequestTrustViolation[] = []
|
||||
let dynamicInternalRoutes = 0
|
||||
let dynamicInternalPolicies = 0
|
||||
|
||||
const visit = (node: SyntaxNode) => {
|
||||
if (node.type === 'ObjectExpression') {
|
||||
const requestProperty = getObjectProperty(node, 'request')
|
||||
if (requestProperty && isSyntaxNode(requestProperty.value)) {
|
||||
const request = unwrapExpression(requestProperty.value)
|
||||
const urlProperty = getObjectProperty(request, 'url')
|
||||
const internalProperty = getObjectProperty(request, 'internal')
|
||||
const url = urlProperty?.value
|
||||
const isDynamic =
|
||||
isSyntaxNode(url) &&
|
||||
['ArrowFunctionExpression', 'FunctionExpression'].includes(unwrapExpression(url).type)
|
||||
if (isDynamic && isSyntaxNode(url)) {
|
||||
const hasInternalRoute = functionContainsInternalRoute(url)
|
||||
const hasExternalRoute = functionContainsExternalRoute(url)
|
||||
const hasInternalPolicy = internalProperty !== undefined
|
||||
const internalPolicyValue = internalProperty?.value
|
||||
const internalPolicyType = isSyntaxNode(internalPolicyValue)
|
||||
? unwrapExpression(internalPolicyValue).type
|
||||
: undefined
|
||||
const hasStaticInternalPolicy =
|
||||
internalPolicyType === 'BooleanLiteral' &&
|
||||
isSyntaxNode(internalPolicyValue) &&
|
||||
unwrapExpression(internalPolicyValue).value === true
|
||||
const hasConditionalInternalPolicy =
|
||||
internalPolicyType === 'ArrowFunctionExpression' ||
|
||||
internalPolicyType === 'FunctionExpression' ||
|
||||
internalPolicyType === 'Identifier'
|
||||
const hasValidInternalPolicy = hasStaticInternalPolicy || hasConditionalInternalPolicy
|
||||
const hasUnsafeInternalPathInterpolation =
|
||||
functionContainsUnsafeInternalPathInterpolation(url)
|
||||
if (hasInternalRoute) dynamicInternalRoutes += 1
|
||||
if (hasInternalPolicy) dynamicInternalPolicies += 1
|
||||
if (hasInternalPolicy && !hasValidInternalPolicy) {
|
||||
violations.push({
|
||||
file,
|
||||
line: internalProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
|
||||
toolId: getToolId(node),
|
||||
reason: 'invalid-internal-policy',
|
||||
})
|
||||
} else if (hasInternalRoute && !hasInternalPolicy) {
|
||||
violations.push({
|
||||
file,
|
||||
line: urlProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
|
||||
toolId: getToolId(node),
|
||||
reason: 'missing-internal-policy',
|
||||
})
|
||||
} else if (hasStaticInternalPolicy && hasExternalRoute && !hasInternalRoute) {
|
||||
const location = (hasInternalPolicy ? internalProperty : urlProperty)?.loc?.start.line
|
||||
violations.push({
|
||||
file,
|
||||
line: location ?? requestProperty.loc?.start.line ?? 1,
|
||||
toolId: getToolId(node),
|
||||
reason: 'internal-policy-without-internal-route',
|
||||
})
|
||||
}
|
||||
if (
|
||||
hasValidInternalPolicy &&
|
||||
hasInternalRoute &&
|
||||
hasExternalRoute &&
|
||||
!hasConditionalInternalPolicy
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
line: internalProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
|
||||
toolId: getToolId(node),
|
||||
reason: 'mixed-route-requires-conditional-policy',
|
||||
})
|
||||
}
|
||||
if (hasInternalRoute && hasUnsafeInternalPathInterpolation) {
|
||||
violations.push({
|
||||
file,
|
||||
line: urlProperty?.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
|
||||
toolId: getToolId(node),
|
||||
reason: 'unsafe-internal-path-interpolation',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of getChildNodes(node)) visit(child)
|
||||
}
|
||||
visit(syntaxTree.program)
|
||||
|
||||
return { violations, dynamicInternalRoutes, dynamicInternalPolicies }
|
||||
}
|
||||
|
||||
function getStaticMemberAccess(
|
||||
expression: SyntaxNode
|
||||
): { target: SyntaxNode; member: string } | undefined {
|
||||
@@ -250,9 +740,14 @@ function findToolRequestBoundaryViolations(source: string, file = 'source.ts'):
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const violations = collectProductionSources(APP)
|
||||
const productionSources = collectProductionSources(APP)
|
||||
const violations = productionSources
|
||||
.filter((file) => file !== CANONICAL_TRANSPORT)
|
||||
.flatMap((file) => findToolRequestBoundaryViolations(readFileSync(file, 'utf8'), file))
|
||||
const requestTrustAudits = productionSources
|
||||
.filter((file) => file.startsWith(join(APP, 'tools')))
|
||||
.map((file) => auditToolRequestTrust(readFileSync(file, 'utf8'), file))
|
||||
const requestTrustViolations = requestTrustAudits.flatMap((audit) => audit.violations)
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Direct ToolConfig request execution is forbidden outside the shared transport:')
|
||||
@@ -265,7 +760,38 @@ function main(): void {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (requestTrustViolations.length > 0) {
|
||||
console.error('Dynamic tool routes have an invalid internal trust declaration:')
|
||||
for (const violation of requestTrustViolations) {
|
||||
const description =
|
||||
violation.reason === 'missing-internal-policy'
|
||||
? 'dynamic /api route is missing request.internal'
|
||||
: violation.reason === 'invalid-internal-policy'
|
||||
? 'request.internal must be true or a predicate function'
|
||||
: violation.reason === 'internal-policy-without-internal-route'
|
||||
? 'request.internal is declared but the URL builder has no /api route'
|
||||
: violation.reason === 'mixed-route-requires-conditional-policy'
|
||||
? 'mixed internal/external URL builder requires a predicate request.internal policy'
|
||||
: 'dynamic /api path parameter must use encodeURIComponent'
|
||||
console.error(
|
||||
` ${relative(ROOT, violation.file)}:${violation.line} ${violation.toolId ?? 'unknown tool'}: ${description}`
|
||||
)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('✓ production tool requests are materialized only by the shared transport')
|
||||
const dynamicInternalRoutes = requestTrustAudits.reduce(
|
||||
(total, audit) => total + audit.dynamicInternalRoutes,
|
||||
0
|
||||
)
|
||||
const dynamicInternalPolicies = requestTrustAudits.reduce(
|
||||
(total, audit) => total + audit.dynamicInternalPolicies,
|
||||
0
|
||||
)
|
||||
console.log(
|
||||
`✓ ${dynamicInternalRoutes} directly detectable dynamic internal routes declare trust (${dynamicInternalPolicies} explicit dynamic policies)`
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) main()
|
||||
|
||||
Reference in New Issue
Block a user