Skip to content

Commit 674ac98

Browse files
committed
fix(tools): enforce external request origin
1 parent 7da1283 commit 674ac98

7 files changed

Lines changed: 137 additions & 4 deletions

File tree

apps/sim/tools/http/request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
9797
},
9898

9999
request: {
100+
allowSameOrigin: true,
100101
url: (params: RequestParams) => {
101102
return processUrl(params.url, params.pathParams, params.params)
102103
},

apps/sim/tools/http/webhook_request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const webhookRequestTool: ToolConfig<WebhookRequestParams, RequestRespons
4242
},
4343

4444
request: {
45+
allowSameOrigin: true,
4546
url: (params: WebhookRequestParams) => params.url,
4647

4748
method: () => 'POST',

apps/sim/tools/index.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ const mockRegistryTools: Record<string, any> = {
193193
retryNonIdempotent: { type: 'boolean' },
194194
},
195195
request: {
196+
allowSameOrigin: true,
196197
url: (p: any) => p.url || '/api/test',
197198
method: (p: any) => p.method || 'GET',
198199
headers: (p: any) => p.headers || { 'Content-Type': 'application/json' },
@@ -2662,6 +2663,48 @@ describe('Internal Route Trust', () => {
26622663
expect(global.fetch).not.toHaveBeenCalled()
26632664
})
26642665

2666+
it('allows the generic HTTP tool to target this Sim instance', async () => {
2667+
const result = await executeTool('http_request', {
2668+
url: 'http://localhost:3000/api/v1/workflows/test',
2669+
method: 'GET',
2670+
})
2671+
2672+
expect(result.success).toBe(true)
2673+
expect(mockValidateUrlWithDNS).toHaveBeenCalledWith(
2674+
'http://localhost:3000/api/v1/workflows/test',
2675+
'toolUrl'
2676+
)
2677+
})
2678+
2679+
it('rejects an integration request that resolves back to this Sim instance', async () => {
2680+
const mockTool = {
2681+
id: 'test_same_origin_integration',
2682+
name: 'Same Origin Integration',
2683+
description: 'Regression fixture',
2684+
version: '1.0.0',
2685+
params: {},
2686+
request: {
2687+
url: () => 'http://localhost:3000/api/tools/test',
2688+
method: 'GET' as const,
2689+
headers: () => ({}),
2690+
},
2691+
}
2692+
;(tools as Record<string, unknown>).test_same_origin_integration = mockTool
2693+
2694+
try {
2695+
const result = await executeTool('test_same_origin_integration', {})
2696+
2697+
expect(result.success).toBe(false)
2698+
expect(result.error).toContain(
2699+
'External integration tools cannot target this Sim instance; use an internal operation'
2700+
)
2701+
expect(mockValidateUrlWithDNS).not.toHaveBeenCalled()
2702+
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
2703+
} finally {
2704+
Reflect.deleteProperty(tools, 'test_same_origin_integration')
2705+
}
2706+
})
2707+
26652708
it('transports only active provenance selected for an internal model input', async () => {
26662709
const registry = new ResolvedSecretTraceRegistry([
26672710
{

apps/sim/tools/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,8 @@ const BODY_SIZE_LIMIT_ERROR_MESSAGE =
985985

986986
const RESPONSE_SIZE_LIMIT_ERROR_MESSAGE =
987987
'Tool response size limit exceeded (10MB). The response is too large to keep in workflow data. Reduce the response size or return a file reference instead.'
988+
const SAME_ORIGIN_EXTERNAL_TOOL_ERROR_MESSAGE =
989+
'External integration tools cannot target this Sim instance; use an internal operation'
988990

989991
/**
990992
* Validates request body size and throws a user-friendly error if exceeded
@@ -2627,8 +2629,13 @@ async function executeToolRequest(
26272629
const requestParams = prepareToolRequest(tool, params, resolvedSecretTraceRegistry)
26282630
const { headers } = requestParams
26292631
const fullUrl = new URL(requestParams.url).toString()
2632+
const targetsThisSimInstance = isSelfOriginUrl(fullUrl)
26302633

2631-
if (isSelfOriginUrl(fullUrl)) {
2634+
if (targetsThisSimInstance && tool.request.allowSameOrigin !== true) {
2635+
throw new Error(SAME_ORIGIN_EXTERNAL_TOOL_ERROR_MESSAGE)
2636+
}
2637+
2638+
if (targetsThisSimInstance) {
26322639
const callChain = params._context?.callChain as string[] | undefined
26332640
if (callChain && callChain.length > 0) {
26342641
headers.set(SIM_VIA_HEADER, serializeCallChain(callChain))

apps/sim/tools/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ export interface ToolConfig<P = any, R = any> {
184184
method: HttpMethod | ((params: P) => HttpMethod)
185185
headers: (params: P) => Record<string, string>
186186
body?: (params: P) => Record<string, any> | string | FormData | undefined
187+
/**
188+
* Allows the resolved request URL to target this Sim instance. Reserved for generic,
189+
* user-directed HTTP capabilities; integration tools must use an in-process operation.
190+
*/
191+
allowSameOrigin?: true
187192
/** Defines the exact request fields that may become model-visible. */
188193
modelInput?:
189194
| {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,46 @@ describe('tool self-hop audit', () => {
592592
])
593593
})
594594

595+
it('rejects same-origin opt-in on an integration tool', () => {
596+
const audit = auditToolSelfHops(`
597+
const tool = {
598+
id: 'test_tool',
599+
request: {
600+
allowSameOrigin: true,
601+
url: (params) => params.url,
602+
method: 'POST',
603+
headers: () => ({}),
604+
},
605+
}
606+
`)
607+
608+
expect(audit.violations).toEqual([
609+
expect.objectContaining({
610+
toolId: 'test_tool',
611+
reason: 'unapproved-same-origin-policy',
612+
}),
613+
])
614+
})
615+
616+
it.each(['http_request', 'webhook_request'])(
617+
'allows the intentional same-origin policy on %s',
618+
(toolId) => {
619+
const audit = auditToolSelfHops(`
620+
const tool = {
621+
id: '${toolId}',
622+
request: {
623+
allowSameOrigin: true,
624+
url: (params) => params.url,
625+
method: 'POST',
626+
headers: () => ({}),
627+
},
628+
}
629+
`)
630+
631+
expect(audit.violations).toEqual([])
632+
}
633+
)
634+
595635
it('rejects request.internal even when the URL comes only from a spread', () => {
596636
const audit = auditToolSelfHops(`
597637
const externalRequest = { url: 'https://api.example.com/v1/items' }

scripts/check-tool-request-boundary.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
* Enforces the two tool execution boundaries: external ToolConfig requests are materialized only
44
* by request-transport.ts, while same-process work uses registered InternalToolConfig operations.
55
* Tool definitions may not point back to Sim API routes or revive the retired request.internal
6-
* escape hatch.
6+
* escape hatch. Dynamic provider origins remain supported because the executor rejects their
7+
* resolved URL when it targets Sim; only the two generic user-directed HTTP tools may opt out.
78
*/
89
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
910
import { dirname, extname, join, relative, resolve } from 'node:path'
@@ -28,6 +29,7 @@ const FUNCTION_NODE_TYPES = new Set([
2829
'ObjectMethod',
2930
])
3031
const URL_VALUE_WRAPPER_CALLS = new Set(['String', 'encodeURI', 'encodeURIComponent'])
32+
const APPROVED_SAME_ORIGIN_TOOL_IDS = new Set(['http_request', 'webhook_request'])
3133

3234
interface Violation {
3335
file: string
@@ -39,7 +41,11 @@ export interface ToolSelfHopViolation {
3941
file: string
4042
line: number
4143
toolId?: string
42-
reason: 'same-origin-tool-request' | 'legacy-internal-policy' | 'unresolved-request-policy'
44+
reason:
45+
| 'same-origin-tool-request'
46+
| 'legacy-internal-policy'
47+
| 'unresolved-request-policy'
48+
| 'unapproved-same-origin-policy'
4349
}
4450

4551
export interface ToolSelfHopAudit {
@@ -1619,8 +1625,16 @@ export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfH
16191625
for (const request of requests.requests) {
16201626
const urlProperties = getResolvedObjectProperties(request, 'url')
16211627
const internalProperties = getResolvedObjectProperties(request, 'internal')
1628+
const allowSameOriginProperties = getResolvedObjectProperties(
1629+
request,
1630+
'allowSameOrigin'
1631+
)
16221632
let hasLegacyInternalPolicy = false
1623-
if (!urlProperties.complete || !internalProperties.complete) {
1633+
if (
1634+
!urlProperties.complete ||
1635+
!internalProperties.complete ||
1636+
!allowSameOriginProperties.complete
1637+
) {
16241638
reportUnresolved(requestProperty.loc?.start.line ?? 1)
16251639
}
16261640
for (const resolvedInternal of internalProperties.properties) {
@@ -1635,6 +1649,28 @@ export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfH
16351649
reason: 'legacy-internal-policy',
16361650
})
16371651
}
1652+
for (const resolvedPolicy of allowSameOriginProperties.properties) {
1653+
if (!resolvedPolicy) continue
1654+
const policyProperty = resolvedPolicy.property
1655+
const policyValue =
1656+
policyProperty.type === 'ObjectProperty' && isSyntaxNode(policyProperty.value)
1657+
? unwrapExpression(policyProperty.value)
1658+
: undefined
1659+
if (!policyValue || policyValue.type !== 'BooleanLiteral') {
1660+
reportUnresolved(
1661+
policyProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1
1662+
)
1663+
continue
1664+
}
1665+
if (policyValue.value === true && !APPROVED_SAME_ORIGIN_TOOL_IDS.has(toolId)) {
1666+
violations.push({
1667+
file,
1668+
line: policyProperty.loc?.start.line ?? requestProperty.loc?.start.line ?? 1,
1669+
toolId,
1670+
reason: 'unapproved-same-origin-policy',
1671+
})
1672+
}
1673+
}
16381674
for (const resolvedUrl of urlProperties.properties) {
16391675
if (!resolvedUrl) continue
16401676
const { property: urlProperty, request: urlRequest } = resolvedUrl

0 commit comments

Comments
 (0)