Skip to content

Commit f1d95a6

Browse files
committed
fix(tools): block self-hosted loopback aliases
1 parent d27ed63 commit f1d95a6

2 files changed

Lines changed: 86 additions & 6 deletions

File tree

apps/sim/tools/index.test.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2663,19 +2663,19 @@ describe('Internal Route Trust', () => {
26632663
expect(global.fetch).not.toHaveBeenCalled()
26642664
})
26652665

2666-
it('allows the generic HTTP tool to target this Sim instance', async () => {
2666+
it('allows the generic HTTP tool to target this Sim instance through a loopback alias', async () => {
26672667
const result = await executeTool('http_request', {
2668-
url: 'http://localhost:3000/api/v1/workflows/test',
2668+
url: 'http://127.0.0.2:3000/api/v1/workflows/test',
26692669
method: 'GET',
26702670
})
26712671

26722672
expect(result.success).toBe(true)
26732673
expect(mockValidateUrlWithDNS).toHaveBeenCalledWith(
2674-
'http://localhost:3000/api/v1/workflows/test',
2674+
'http://127.0.0.2:3000/api/v1/workflows/test',
26752675
'toolUrl'
26762676
)
26772677
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledWith(
2678-
'http://localhost:3000/api/v1/workflows/test',
2678+
'http://127.0.0.2:3000/api/v1/workflows/test',
26792679
'93.184.216.34',
26802680
expect.objectContaining({ assertRedirectTarget: undefined })
26812681
)
@@ -2710,6 +2710,67 @@ describe('Internal Route Trust', () => {
27102710
}
27112711
})
27122712

2713+
it.each(['127.0.0.1', '127.0.0.2', '[::1]'])(
2714+
'rejects the loopback alias %s for a self-hosted Sim listener',
2715+
async (hostname) => {
2716+
const mockTool = {
2717+
id: 'test_loopback_alias_integration',
2718+
name: 'Loopback Alias Integration',
2719+
description: 'Regression fixture',
2720+
version: '1.0.0',
2721+
params: {},
2722+
request: {
2723+
url: () => `http://${hostname}:3000/api/tools/test`,
2724+
method: 'GET' as const,
2725+
headers: () => ({}),
2726+
},
2727+
}
2728+
;(tools as Record<string, unknown>).test_loopback_alias_integration = mockTool
2729+
2730+
try {
2731+
const result = await executeTool('test_loopback_alias_integration', {})
2732+
2733+
expect(result.success).toBe(false)
2734+
expect(result.error).toContain(
2735+
'External integration tools cannot target this Sim instance; use an internal operation'
2736+
)
2737+
expect(mockValidateUrlWithDNS).not.toHaveBeenCalled()
2738+
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
2739+
} finally {
2740+
Reflect.deleteProperty(tools, 'test_loopback_alias_integration')
2741+
}
2742+
}
2743+
)
2744+
2745+
it('allows a self-hosted provider on a different loopback port', async () => {
2746+
const mockTool = {
2747+
id: 'test_local_provider',
2748+
name: 'Local Provider',
2749+
description: 'Regression fixture',
2750+
version: '1.0.0',
2751+
params: {},
2752+
request: {
2753+
url: () => 'http://127.0.0.1:4000/api/provider',
2754+
method: 'GET' as const,
2755+
headers: () => ({}),
2756+
},
2757+
}
2758+
;(tools as Record<string, unknown>).test_local_provider = mockTool
2759+
2760+
try {
2761+
const result = await executeTool('test_local_provider', {})
2762+
2763+
expect(result.success).toBe(true)
2764+
expect(mockValidateUrlWithDNS).toHaveBeenCalledWith(
2765+
'http://127.0.0.1:4000/api/provider',
2766+
'toolUrl'
2767+
)
2768+
expect(mockSecureFetchWithPinnedIP).toHaveBeenCalled()
2769+
} finally {
2770+
Reflect.deleteProperty(tools, 'test_local_provider')
2771+
}
2772+
})
2773+
27132774
it('rejects an integration redirect that resolves back to this Sim instance', async () => {
27142775
const mockTool = {
27152776
id: 'test_same_origin_redirect',
@@ -2732,7 +2793,7 @@ describe('Internal Route Trust', () => {
27322793
const secureFetchOptions = mockSecureFetchWithPinnedIP.mock.calls.at(-1)?.[2]
27332794
expect(secureFetchOptions?.assertRedirectTarget).toBeTypeOf('function')
27342795
expect(() =>
2735-
secureFetchOptions?.assertRedirectTarget?.('http://localhost:3000/api/tools/test')
2796+
secureFetchOptions?.assertRedirectTarget?.('http://127.0.0.2:3000/api/tools/test')
27362797
).toThrow(
27372798
'External integration tools cannot target this Sim instance; use an internal operation'
27382799
)

apps/sim/tools/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { isLoopbackIp, unwrapIpv6Brackets } from '@sim/security/ssrf'
23
import { describeError, findCause, getErrorMessage, toError } from '@sim/utils/errors'
34
import { sleep } from '@sim/utils/helpers'
45
import { isPlainRecord, isRecordLike } from '@sim/utils/object'
@@ -2358,11 +2359,29 @@ function isErrorResponse(
23582359

23592360
/**
23602361
* Checks whether a fully resolved URL points back to this Sim instance.
2362+
* Loopback aliases are equivalent when protocol and port match because they
2363+
* address the same self-hosted listener even when their origin strings differ.
23612364
* Used to propagate cycle-detection headers on API blocks that target
23622365
* the platform's own workflow execution endpoints via absolute URL.
23632366
*/
23642367
function isSelfOriginUrl(url: string): boolean {
2365-
return isSameOrigin(url, getBaseUrl()) || isSameOrigin(url, getInternalApiBaseUrl())
2368+
return [getBaseUrl(), getInternalApiBaseUrl()].some((baseUrl) => {
2369+
if (isSameOrigin(url, baseUrl)) return true
2370+
2371+
try {
2372+
const target = new URL(url)
2373+
const base = new URL(baseUrl)
2374+
if (target.protocol !== base.protocol || target.port !== base.port) return false
2375+
2376+
const targetHostname = unwrapIpv6Brackets(target.hostname.toLowerCase())
2377+
const baseHostname = unwrapIpv6Brackets(base.hostname.toLowerCase())
2378+
const targetIsLoopback = targetHostname === 'localhost' || isLoopbackIp(targetHostname)
2379+
const baseIsLoopback = baseHostname === 'localhost' || isLoopbackIp(baseHostname)
2380+
return targetIsLoopback && baseIsLoopback
2381+
} catch {
2382+
return false
2383+
}
2384+
})
23662385
}
23672386

23682387
interface ResolvedRetryConfig {

0 commit comments

Comments
 (0)