Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/sim/executor/handlers/agent/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,13 @@ vi.mock('@/providers', () => ({

vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
buildAPIUrl: vi.fn((path: string, params?: Record<string, string>) => {
const url = new URL(path, 'http://localhost:3000')
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value)
}
}
}
return url
}),
internalApiUrl: vi.fn(
(segments: TemplateStringsArray, ...values: unknown[]) =>
new URL(
String.raw({ raw: segments }, ...values.map((v) => encodeURIComponent(String(v)))),
'http://localhost:3000'
)
),
extractAPIErrorMessage: vi.fn(async (response: Response) => {
const defaultMessage = `API request failed with status ${response.status}`
try {
Expand Down
13 changes: 6 additions & 7 deletions apps/sim/executor/handlers/agent/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import type {
import { parseResponseFormat } from '@/executor/handlers/shared/response-format'
import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { buildAuthHeaders, internalApiUrl } from '@/executor/utils/http'
import { stringifyJSON } from '@/executor/utils/json'
import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection'
import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection'
Expand Down Expand Up @@ -1212,12 +1212,11 @@ export class AgentBlockHandler implements BlockHandler {
}

const headers = await buildAuthHeaders(ctx.userId)
const url = buildAPIUrl('/api/mcp/tools/discover', {
serverId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
...(ctx.userId ? { userId: ctx.userId } : {}),
})
const url = internalApiUrl`/api/mcp/tools/discover`
url.searchParams.set('serverId', serverId)
url.searchParams.set('workspaceId', ctx.workspaceId)
url.searchParams.set('workflowId', ctx.workflowId)
if (ctx.userId) url.searchParams.set('userId', ctx.userId)

const maxAttempts = 2
for (let attempt = 0; attempt < maxAttempts; attempt++) {
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/executor/handlers/evaluator/evaluator-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { BlockOutput } from '@/blocks/types'
import { validateModelProvider } from '@/ee/access-control/utils/permission-check'
import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { buildAuthHeaders, extractAPIErrorMessage, internalApiUrl } from '@/executor/utils/http'
import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json'
import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection'
import type {
Expand Down Expand Up @@ -186,7 +186,8 @@ export class EvaluatorBlockHandler implements BlockHandler {
}

try {
const url = buildAPIUrl('/api/providers', ctx.userId ? { userId: ctx.userId } : {})
const url = internalApiUrl`/api/providers`
if (ctx.userId) url.searchParams.set('userId', ctx.userId)

const providerRequest: ProviderRequest = {
model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const PRIVATE_PROVENANCE = {
const {
mockAreModelSafeWorkspaceFileKeys,
mockBuildAuthHeaders,
mockBuildAPIUrl,
mockInternalApiUrl,
mockExtractAPIErrorMessage,
mockGenerateId,
mockIsExecutionCancelled,
Expand All @@ -41,7 +41,7 @@ const {
} = vi.hoisted(() => ({
mockAreModelSafeWorkspaceFileKeys: vi.fn(),
mockBuildAuthHeaders: vi.fn(),
mockBuildAPIUrl: vi.fn(),
mockInternalApiUrl: vi.fn(),
mockExtractAPIErrorMessage: vi.fn(),
mockGenerateId: vi.fn(),
mockIsExecutionCancelled: vi.fn(),
Expand All @@ -57,7 +57,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', ()

vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: mockBuildAuthHeaders,
buildAPIUrl: mockBuildAPIUrl,
internalApiUrl: mockInternalApiUrl,
extractAPIErrorMessage: mockExtractAPIErrorMessage,
}))

Expand Down Expand Up @@ -155,7 +155,7 @@ describe('MothershipBlockHandler', () => {
vi.stubGlobal('fetch', fetchMock)

mockBuildAuthHeaders.mockResolvedValue({ Authorization: 'Bearer internal' })
mockBuildAPIUrl.mockReturnValue(new URL('/api/mothership/execute', 'http://localhost:3000'))
mockInternalApiUrl.mockReturnValue(new URL('/api/mothership/execute', 'http://localhost:3000'))
mockExtractAPIErrorMessage.mockResolvedValue('boom')
mockGenerateId.mockReset()
mockIsExecutionCancelled.mockReset()
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/executor/handlers/mothership/mothership-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import type {
NormalizedBlockOutput,
StreamingExecution,
} from '@/executor/types'
import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http'
import { buildAuthHeaders, extractAPIErrorMessage, internalApiUrl } from '@/executor/utils/http'
import type {
ResolvedSecretInputPath,
ResolvedSecretTraceRegistry,
Expand Down Expand Up @@ -796,7 +796,7 @@ export class MothershipBlockHandler implements BlockHandler {
requestId
)

const url = buildAPIUrl('/api/mothership/execute')
const url = internalApiUrl`/api/mothership/execute`
const headers = await buildAuthHeaders(ctx.userId)
headers.Accept = 'application/x-ndjson'
headers[MOTHERSHIP_EXECUTE_STREAM_HEADER] = MOTHERSHIP_EXECUTE_STREAM_VALUE
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/executor/handlers/workflow/workflow-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,13 @@ vi.mock('@/lib/auth/internal', () => ({

vi.mock('@/executor/utils/http', () => ({
buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }),
buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')),
internalApiUrl: vi.fn(
(segments: TemplateStringsArray, ...values: unknown[]) =>
new URL(
String.raw({ raw: segments }, ...values.map((v) => encodeURIComponent(String(v)))),
'http://localhost:3000'
)
),
extractAPIErrorMessage: vi.fn(async (response: Response) => {
const defaultMessage = `API request failed with status ${response.status}`
try {
Expand Down
10 changes: 5 additions & 5 deletions apps/sim/executor/handlers/workflow/workflow-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
type StreamingExecution,
} from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http'
import { buildAuthHeaders, internalApiUrl } from '@/executor/utils/http'
import { getIterationContext } from '@/executor/utils/iteration-context'
import { parseJSON } from '@/executor/utils/json'
import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup'
Expand Down Expand Up @@ -952,7 +952,7 @@ export class WorkflowBlockHandler implements BlockHandler {

private async loadChildWorkflow(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
const url = internalApiUrl`/api/workflows/${workflowId}`

const response = await fetch(url.toString(), { headers })

Expand Down Expand Up @@ -1015,7 +1015,7 @@ export class WorkflowBlockHandler implements BlockHandler {
private async checkChildDeployment(workflowId: string, userId?: string): Promise<boolean> {
try {
const headers = await buildAuthHeaders(userId)
const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const url = internalApiUrl`/api/workflows/${workflowId}/deployed`

const response = await fetch(url.toString(), {
headers,
Expand All @@ -1037,7 +1037,7 @@ export class WorkflowBlockHandler implements BlockHandler {

private async loadChildWorkflowDeployed(workflowId: string, userId?: string) {
const headers = await buildAuthHeaders(userId)
const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`)
const deployedUrl = internalApiUrl`/api/workflows/${workflowId}/deployed`

const deployedRes = await fetch(deployedUrl.toString(), {
headers,
Expand All @@ -1058,7 +1058,7 @@ export class WorkflowBlockHandler implements BlockHandler {
throw new Error(`Deployed state missing or invalid for child workflow ${workflowId}`)
}

const metaUrl = buildAPIUrl(`/api/workflows/${workflowId}`)
const metaUrl = internalApiUrl`/api/workflows/${workflowId}`
const metaRes = await fetch(metaUrl.toString(), {
headers,
cache: 'no-store',
Expand Down
54 changes: 54 additions & 0 deletions apps/sim/executor/utils/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetInternalApiBaseUrl } = vi.hoisted(() => ({
mockGetInternalApiBaseUrl: vi.fn(),
}))

vi.mock('@/lib/core/utils/urls', () => ({
getInternalApiBaseUrl: mockGetInternalApiBaseUrl,
}))

vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: vi.fn().mockResolvedValue('token'),
}))

import { internalApiUrl } from '@/executor/utils/http'

describe('internalApiUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetInternalApiBaseUrl.mockReturnValue('http://internal.sim.local')
})

it('resolves an internal route against the internal base URL', () => {
const url = internalApiUrl`/api/workflows/${'wf-1'}`

expect(url.toString()).toBe('http://internal.sim.local/api/workflows/wf-1')
})

it('encodes an interpolated id so it cannot widen the path', () => {
const url = internalApiUrl`/api/table/${'../../admin/secrets'}/rows`

expect(url.pathname).toBe('/api/table/..%2F..%2Fadmin%2Fsecrets/rows')
})

it('encodes a query-shaped id rather than letting it add params', () => {
const url = internalApiUrl`/api/table/${'t-1?workspaceId=other'}`

expect(url.searchParams.get('workspaceId')).toBeNull()
expect(url.pathname).toBe('/api/table/t-1%3FworkspaceId%3Dother')
})

it('rejects a route that is not an internal API path', () => {
expect(() => internalApiUrl`/health`).toThrow(/must start with \/api\//)
})

it('rejects an interpolated absolute URL, which would escape the internal base', () => {
expect(() => internalApiUrl`${'https://attacker.example/api/x'}`).toThrow(
/must start with \/api\//
)
})
})
36 changes: 25 additions & 11 deletions apps/sim/executor/utils/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { HTTP } from '@/executor/constants'

export async function buildAuthHeaders(userId?: string): Promise<Record<string, string>> {
Expand All @@ -15,19 +15,33 @@ export async function buildAuthHeaders(userId?: string): Promise<Record<string,
return headers
}

export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl()
const url = new URL(path, baseUrl)
/**
* Builds a URL for one of Sim's own API routes, as a tagged template:
*
* ```ts
* const url = internalApiUrl`/api/workflows/${workflowId}/deployed`
* ```
*
* Callers pair this with {@link buildAuthHeaders}, so the request carries an internal token for
* the executing user — which makes it critical that the *route* comes from this module's source
* and only resource ids come from data. The template's literal segments provide that: they are
* fixed at author time, and every interpolated value is percent-encoded, so an id can never widen
* the path into a different route.
*
* @throws when the resolved path is not a relative `/api/` path, which would otherwise send an
* internally-signed request somewhere the caller did not intend.
*/
export function internalApiUrl(segments: TemplateStringsArray, ...values: unknown[]): URL {
let path = segments[0]
for (const [index, value] of values.entries()) {
path += encodeURIComponent(String(value)) + segments[index + 1]
}

if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value)
}
}
if (!path.startsWith('/api/')) {
throw new Error(`Internal API path must start with /api/: ${path}`)
}

return url
return new URL(path, getInternalApiBaseUrl())
}

export async function extractAPIErrorMessage(response: Response): Promise<string> {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/providers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ async function fetchWorkflowMetadata(
workflowId: string
): Promise<{ name: string; description: string | null } | null> {
try {
const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http')
const { buildAuthHeaders, internalApiUrl } = await import('@/executor/utils/http')

const headers = await buildAuthHeaders()
const url = buildAPIUrl(`/api/workflows/${workflowId}`)
const url = internalApiUrl`/api/workflows/${workflowId}`

const response = await fetch(url.toString(), { headers })
if (!response.ok) {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/attachment_info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const agiloftAttachmentInfoTool: ToolConfig<
},

request: {
url: () => '/api/tools/agiloft/attachment_info',
url: '/api/tools/agiloft/attachment_info',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/create_record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const agiloftCreateRecordTool: ToolConfig<AgiloftCreateRecordParams, Agil
},

request: {
url: () => '/api/tools/agiloft/create_record',
url: '/api/tools/agiloft/create_record',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/delete_record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const agiloftDeleteRecordTool: ToolConfig<AgiloftDeleteRecordParams, Agil
},

request: {
url: () => '/api/tools/agiloft/delete_record',
url: '/api/tools/agiloft/delete_record',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/get_choice_line_id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const agiloftGetChoiceLineIdTool: ToolConfig<
},

request: {
url: () => '/api/tools/agiloft/get_choice_line_id',
url: '/api/tools/agiloft/get_choice_line_id',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/lock_record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const agiloftLockRecordTool: ToolConfig<AgiloftLockRecordParams, AgiloftL
},

request: {
url: () => '/api/tools/agiloft/lock_record',
url: '/api/tools/agiloft/lock_record',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/read_record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const agiloftReadRecordTool: ToolConfig<AgiloftReadRecordParams, AgiloftR
},

request: {
url: () => '/api/tools/agiloft/read_record',
url: '/api/tools/agiloft/read_record',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/remove_attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const agiloftRemoveAttachmentTool: ToolConfig<
},

request: {
url: () => '/api/tools/agiloft/remove_attachment',
url: '/api/tools/agiloft/remove_attachment',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/saved_search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const agiloftSavedSearchTool: ToolConfig<
},

request: {
url: () => '/api/tools/agiloft/saved_search',
url: '/api/tools/agiloft/saved_search',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/search_records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const agiloftSearchRecordsTool: ToolConfig<
},

request: {
url: () => '/api/tools/agiloft/search_records',
url: '/api/tools/agiloft/search_records',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/agiloft/select_records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const agiloftSelectRecordsTool: ToolConfig<
},

request: {
url: () => '/api/tools/agiloft/select_records',
url: '/api/tools/agiloft/select_records',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params) => ({
Expand Down
Loading
Loading