Skip to content

Commit d27ed63

Browse files
committed
fix(tools): close remaining self-hop bypasses
1 parent 105cab9 commit d27ed63

6 files changed

Lines changed: 182 additions & 10 deletions

File tree

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,8 @@ export interface SecureFetchOptions {
372372
stripAuthOnRedirect?: boolean
373373
/** Omit for the historical behavior used by existing workflows. */
374374
redirectPolicy?: HttpRedirectPolicy
375+
/** Rejects a redirect target before DNS resolution or a follow-up request is attempted. */
376+
assertRedirectTarget?: (url: string) => void
375377
/**
376378
* Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}).
377379
* When set, the connection routes through this proxy and target-IP pinning is
@@ -1059,6 +1061,12 @@ export async function secureFetchWithPinnedIP(
10591061
res.resume()
10601062
const redirectUrl = resolveRedirectUrl(url, location)
10611063

1064+
try {
1065+
options.assertRedirectTarget?.(redirectUrl)
1066+
} catch (error) {
1067+
settledReject(error)
1068+
return
1069+
}
10621070
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
10631071
.then((validation) => {
10641072
if (!validation.isValid) {

apps/sim/lib/core/security/pinned-redirect-replay.server.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ async function startRecordingServer(hops: RecordedHop[]): Promise<string> {
5656
}
5757

5858
describe('secureFetchWithPinnedIP redirect replay', () => {
59+
it('rejects a redirect target before following it', async () => {
60+
const hops: RecordedHop[] = []
61+
const target = await startRecordingServer(hops)
62+
const origin = await startServer((req, res) => {
63+
req.resume()
64+
res.writeHead(302, { location: `${target}/after` })
65+
res.end()
66+
})
67+
const assertRedirectTarget = vi.fn((url: string) => {
68+
if (url === `${target}/after`) throw new Error('redirect target rejected')
69+
})
70+
71+
await expect(
72+
secureFetchWithPinnedIP(origin, '127.0.0.1', {
73+
allowHttp: true,
74+
assertRedirectTarget,
75+
})
76+
).rejects.toThrow('redirect target rejected')
77+
78+
expect(assertRedirectTarget).toHaveBeenCalledWith(`${target}/after`)
79+
expect(hops).toEqual([])
80+
})
81+
5982
it('preserves historical replay when no redirect policy is present', async () => {
6083
const hops: RecordedHop[] = []
6184
const target = await startRecordingServer(hops)

apps/sim/tools/index.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2674,6 +2674,11 @@ describe('Internal Route Trust', () => {
26742674
'http://localhost:3000/api/v1/workflows/test',
26752675
'toolUrl'
26762676
)
2677+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
2678+
'http://localhost:3000/api/v1/workflows/test',
2679+
'93.184.216.34',
2680+
expect.objectContaining({ assertRedirectTarget: undefined })
2681+
)
26772682
})
26782683

26792684
it('rejects an integration request that resolves back to this Sim instance', async () => {
@@ -2705,6 +2710,40 @@ describe('Internal Route Trust', () => {
27052710
}
27062711
})
27072712

2713+
it('rejects an integration redirect that resolves back to this Sim instance', async () => {
2714+
const mockTool = {
2715+
id: 'test_same_origin_redirect',
2716+
name: 'Same Origin Redirect Integration',
2717+
description: 'Regression fixture',
2718+
version: '1.0.0',
2719+
params: {},
2720+
request: {
2721+
url: () => 'https://api.example.com/download',
2722+
method: 'GET' as const,
2723+
headers: () => ({}),
2724+
},
2725+
}
2726+
;(tools as Record<string, unknown>).test_same_origin_redirect = mockTool
2727+
2728+
try {
2729+
const result = await executeTool('test_same_origin_redirect', {})
2730+
2731+
expect(result.success).toBe(true)
2732+
const secureFetchOptions = mockSecureFetchWithPinnedIP.mock.calls.at(-1)?.[2]
2733+
expect(secureFetchOptions?.assertRedirectTarget).toBeTypeOf('function')
2734+
expect(() =>
2735+
secureFetchOptions?.assertRedirectTarget?.('http://localhost:3000/api/tools/test')
2736+
).toThrow(
2737+
'External integration tools cannot target this Sim instance; use an internal operation'
2738+
)
2739+
expect(() =>
2740+
secureFetchOptions?.assertRedirectTarget?.('https://provider.example.com/download')
2741+
).not.toThrow()
2742+
} finally {
2743+
Reflect.deleteProperty(tools, 'test_same_origin_redirect')
2744+
}
2745+
})
2746+
27082747
it('transports only active provenance selected for an internal model input', async () => {
27092748
const registry = new ResolvedSecretTraceRegistry([
27102749
{

apps/sim/tools/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2686,6 +2686,14 @@ async function executeToolRequest(
26862686
proxyUrl: proxyOption,
26872687
stripAuthOnRedirect: requestParams.stripAuthOnRedirect,
26882688
redirectPolicy: requestParams.redirectPolicy,
2689+
assertRedirectTarget:
2690+
tool.request.allowSameOrigin === true
2691+
? undefined
2692+
: (redirectUrl) => {
2693+
if (isSelfOriginUrl(redirectUrl)) {
2694+
throw new Error(SAME_ORIGIN_EXTERNAL_TOOL_ERROR_MESSAGE)
2695+
}
2696+
},
26892697
})
26902698

26912699
const responseHeaders = new Headers(secureResponse.headers.toRecord())

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,76 @@ describe('tool self-hop audit', () => {
632632
}
633633
)
634634

635+
it('resolves a constant tool ID before applying the same-origin allowlist', () => {
636+
const audit = auditToolSelfHops(`
637+
const TOOL_ID = 'http_request'
638+
const tool = {
639+
id: TOOL_ID,
640+
request: {
641+
allowSameOrigin: true,
642+
url: (params) => params.url,
643+
method: 'POST',
644+
headers: () => ({}),
645+
},
646+
}
647+
`)
648+
649+
expect(audit.violations).toEqual([])
650+
})
651+
652+
it('audits a same-origin request when the tool ID is an expression', () => {
653+
const audit = auditToolSelfHops(`
654+
const tool = {
655+
id: flag ? 'first_tool' : 'second_tool',
656+
request: { url: '/api/tools/test', method: 'POST', headers: () => ({}) },
657+
}
658+
`)
659+
660+
expect(audit.violations).toEqual([
661+
expect.objectContaining({ reason: 'same-origin-tool-request' }),
662+
])
663+
})
664+
665+
it('fails closed on a computed request property key', () => {
666+
const audit = auditToolSelfHops(`
667+
const tool = {
668+
id: 'test_tool',
669+
[runtimeRequestKey]: {
670+
url: 'https://provider.example.com',
671+
method: 'POST',
672+
headers: () => ({}),
673+
},
674+
}
675+
`)
676+
677+
expect(audit.violations).toEqual([
678+
expect.objectContaining({
679+
toolId: 'test_tool',
680+
reason: 'unresolved-request-policy',
681+
}),
682+
])
683+
})
684+
685+
it('fails closed on a computed request URL key', () => {
686+
const audit = auditToolSelfHops(`
687+
const tool = {
688+
id: 'test_tool',
689+
request: {
690+
[runtimeUrlKey]: '/api/tools/test',
691+
method: 'POST',
692+
headers: () => ({}),
693+
},
694+
}
695+
`)
696+
697+
expect(audit.violations).toEqual([
698+
expect.objectContaining({
699+
toolId: 'test_tool',
700+
reason: 'unresolved-request-policy',
701+
}),
702+
])
703+
})
704+
635705
it('rejects request.internal even when the URL comes only from a spread', () => {
636706
const audit = auditToolSelfHops(`
637707
const externalRequest = { url: 'https://api.example.com/v1/items' }

scripts/check-tool-request-boundary.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ function unwrapExpression(expression: SyntaxNode): SyntaxNode {
124124
function getStaticPropertyName(property: SyntaxNode): string | undefined {
125125
if (!isSyntaxNode(property.key)) return undefined
126126
const key = property.key
127+
if (property.computed === true) return getStaticString(key)
127128
if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
128129
if (key.type === 'StringLiteral' && typeof key.value === 'string') return key.value
129130
return undefined
@@ -1278,11 +1279,28 @@ function functionContainsInternalRoute(
12781279
return found
12791280
}
12801281

1281-
function getToolId(object: SyntaxNode): string | undefined {
1282+
function resolveStaticStringExpression(
1283+
expression: SyntaxNode,
1284+
resolver: SelfHopResolver,
1285+
seen = new Set<string>()
1286+
): string | undefined {
1287+
const value = unwrapExpression(expression)
1288+
const staticValue = getStaticString(value)
1289+
if (staticValue !== undefined) return staticValue
1290+
if (value.type !== 'Identifier' || typeof value.name !== 'string') return undefined
1291+
const key = `${resolver.file}:static-string:${value.name}`
1292+
if (seen.has(key)) return undefined
1293+
const binding = resolveScopedIdentifier(value.name, resolver)
1294+
if (!binding) return undefined
1295+
const nextSeen = new Set(seen)
1296+
nextSeen.add(key)
1297+
return resolveStaticStringExpression(binding.expression, binding.resolver, nextSeen)
1298+
}
1299+
1300+
function getToolId(object: SyntaxNode, resolver: SelfHopResolver): string | undefined {
12821301
const idProperty = getObjectProperty(object, 'id')
12831302
if (!idProperty || !isSyntaxNode(idProperty.value)) return undefined
1284-
const value = unwrapExpression(idProperty.value)
1285-
return value.type === 'StringLiteral' && typeof value.value === 'string' ? value.value : undefined
1303+
return resolveStaticStringExpression(idProperty.value, resolver)
12861304
}
12871305

12881306
interface ScopedExpression {
@@ -1531,11 +1549,15 @@ function getResolvedObjectProperties(
15311549
const lastIndex = endIndex ?? properties.length - 1
15321550
for (let index = lastIndex; index >= 0; index -= 1) {
15331551
const property = properties[index]
1534-
if (
1535-
(property.type === 'ObjectProperty' || property.type === 'ObjectMethod') &&
1536-
getStaticPropertyName(property) === name
1537-
) {
1538-
return { properties: [{ property, request }], complete: true }
1552+
if (property.type === 'ObjectProperty' || property.type === 'ObjectMethod') {
1553+
const propertyName = getStaticPropertyName(property)
1554+
if (propertyName === name) {
1555+
return { properties: [{ property, request }], complete: true }
1556+
}
1557+
if (property.computed === true && propertyName === undefined) {
1558+
const earlier = getResolvedObjectProperties(request, name, seen, index - 1)
1559+
return { properties: earlier.properties, complete: false }
1560+
}
15391561
}
15401562
if (property.type !== 'SpreadElement' || !isSyntaxNode(property.argument)) continue
15411563
const key = `${request.resolver.file}:spread:${property.start ?? index}:${name}`
@@ -1582,8 +1604,10 @@ export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfH
15821604

15831605
const visit = (node: SyntaxNode) => {
15841606
if (node.type === 'ObjectExpression') {
1585-
const toolId = getToolId(node)
1586-
if (toolId) {
1607+
const idProperty = getObjectProperty(node, 'id')
1608+
const toolId = idProperty ? getToolId(node, resolver) : undefined
1609+
const directRequestProperty = getObjectProperty(node, 'request')
1610+
if (idProperty && (toolId !== undefined || directRequestProperty)) {
15871611
const toolObject: ResolvedRequestObject = {
15881612
expression: node,
15891613
resolver,

0 commit comments

Comments
 (0)