From 5d986b22ba09d6f7c3857cbd5a3c0f6b9fb99fc0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 13:02:43 -0700 Subject: [PATCH] fix(agiloft): revert to client-safe imports to fix build The SSRF upgrade to input-validation.server introduced dns/promises into client bundles via tools/registry.ts. Revert to the original client-safe validateExternalUrl + fetch. The SSRF DNS-pinning upgrade for agiloft directExecution should be done via API routes in a separate PR. --- .../sim/app/api/tools/agiloft/attach/route.ts | 23 ++--- apps/sim/tools/agiloft/utils.ts | 86 +++++-------------- 2 files changed, 31 insertions(+), 78 deletions(-) diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index 27c704f697..db55283d82 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -2,17 +2,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' -import { - agiloftLogin, - agiloftLogout, - buildAttachFileUrl, - validateInstanceUrl, -} from '@/tools/agiloft/utils' +import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils' export const dynamic = 'force-dynamic' @@ -65,20 +60,18 @@ export async function POST(request: NextRequest) { const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger) const resolvedFileName = data.fileName || userFile.name || 'attachment' - let resolvedIP: string - try { - resolvedIP = await validateInstanceUrl(data.instanceUrl) - } catch (error) { + const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl') + if (!urlValidation.isValid) { logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, { instanceUrl: data.instanceUrl, }) return NextResponse.json( - { success: false, error: error instanceof Error ? error.message : 'Invalid instance URL' }, + { success: false, error: urlValidation.error || 'Invalid instance URL' }, { status: 400 } ) } - const token = await agiloftLogin(data, resolvedIP) + const token = await agiloftLogin(data) const base = data.instanceUrl.replace(/\/$/, '') try { @@ -86,7 +79,7 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`) - const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, { + const agiloftResponse = await fetch(url, { method: 'PUT', headers: { 'Content-Type': userFile.type || 'application/octet-stream', @@ -130,7 +123,7 @@ export async function POST(request: NextRequest) { }, }) } finally { - await agiloftLogout(data.instanceUrl, data.knowledgeBase, token, resolvedIP) + await agiloftLogout(data.instanceUrl, data.knowledgeBase, token) } } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/sim/tools/agiloft/utils.ts b/apps/sim/tools/agiloft/utils.ts index c6e989054e..252dcb4a81 100644 --- a/apps/sim/tools/agiloft/utils.ts +++ b/apps/sim/tools/agiloft/utils.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { validateExternalUrl } from '@/lib/core/security/input-validation' import type { AgiloftAttachmentInfoParams, AgiloftBaseParams, @@ -13,35 +14,8 @@ import type { } from '@/tools/agiloft/types' import type { HttpMethod, ToolResponse } from '@/tools/types' -/** - * Mirrors the shape of SecureFetchResponse from input-validation.server.ts. - * Defined locally to avoid importing the .server module into client bundles - * (it pulls in dns/promises which is Node-only). - */ -interface SecureFetchResponse { - ok: boolean - status: number - statusText: string - headers: { get(name: string): string | null } - text: () => Promise - json: () => Promise - arrayBuffer: () => Promise -} - const logger = createLogger('AgiloftAuth') -/** - * Lazily imports server-only security functions to avoid pulling `dns/promises` - * into client bundles (this file is reachable from tools/registry.ts). - */ -async function getServerSecurity() { - const mod = await import('@/lib/core/security/input-validation.server') - return { - secureFetchWithPinnedIP: mod.secureFetchWithPinnedIP, - validateUrlWithDNS: mod.validateUrlWithDNS, - } -} - interface AgiloftRequestConfig { url: string method: HttpMethod @@ -49,40 +23,30 @@ interface AgiloftRequestConfig { body?: BodyInit } -/** - * Validates the instance URL via DNS resolution and returns the resolved IP - * for use with pinned fetches to prevent SSRF via DNS rebinding. - */ -async function validateInstanceUrl(instanceUrl: string): Promise { - const { validateUrlWithDNS } = await getServerSecurity() - const validation = await validateUrlWithDNS(instanceUrl, 'instanceUrl') - if (!validation.isValid) { - throw new Error(`Invalid Agiloft instance URL: ${validation.error}`) - } - return validation.resolvedIP! -} - /** * Exchanges login/password for a short-lived Bearer token via EWLogin. - * Uses DNS-pinned fetch to prevent SSRF via DNS rebinding. */ -async function agiloftLogin(params: AgiloftBaseParams, resolvedIP: string): Promise { +async function agiloftLogin(params: AgiloftBaseParams): Promise { const base = params.instanceUrl.replace(/\/$/, '') + const urlValidation = validateExternalUrl(params.instanceUrl, 'instanceUrl') + if (!urlValidation.isValid) { + throw new Error(`Invalid Agiloft instance URL: ${urlValidation.error}`) + } + const kb = encodeURIComponent(params.knowledgeBase) const login = encodeURIComponent(params.login) const password = encodeURIComponent(params.password) const url = `${base}/ewws/EWLogin?$KB=${kb}&$login=${login}&$password=${password}` - const { secureFetchWithPinnedIP } = await getServerSecurity() - const response = await secureFetchWithPinnedIP(url, resolvedIP, { method: 'POST' }) + const response = await fetch(url, { method: 'POST' }) if (!response.ok) { const errorText = await response.text() throw new Error(`Agiloft login failed: ${response.status} - ${errorText}`) } - const data = (await response.json()) as { access_token?: string } + const data = await response.json() const token = data.access_token if (!token) { @@ -94,19 +58,16 @@ async function agiloftLogin(params: AgiloftBaseParams, resolvedIP: string): Prom /** * Cleans up the server session. Best-effort — failures are logged but not thrown. - * Uses DNS-pinned fetch to prevent SSRF via DNS rebinding. */ async function agiloftLogout( instanceUrl: string, knowledgeBase: string, - token: string, - resolvedIP: string + token: string ): Promise { try { const base = instanceUrl.replace(/\/$/, '') const kb = encodeURIComponent(knowledgeBase) - const { secureFetchWithPinnedIP } = await getServerSecurity() - await secureFetchWithPinnedIP(`${base}/ewws/EWLogout?$KB=${kb}`, resolvedIP, { + await fetch(`${base}/ewws/EWLogout?$KB=${kb}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, }) @@ -117,43 +78,42 @@ async function agiloftLogout( /** * Shared wrapper that handles the full auth lifecycle: - * 1. Validate instance URL via DNS resolution - * 2. Login to get Bearer token (using pinned IP) - * 3. Execute the request with the token (using pinned IP) - * 4. Logout to clean up the session (using pinned IP) + * 1. Login to get Bearer token + * 2. Execute the request with the token + * 3. Logout to clean up the session * - * All HTTP requests use the resolved IP to prevent SSRF via DNS rebinding. + * The `buildRequest` callback receives the token and base URL, and returns + * the request config. The `transformResponse` callback converts the raw + * Response into the tool's output format. */ export async function executeAgiloftRequest( params: AgiloftBaseParams, buildRequest: (base: string) => AgiloftRequestConfig, - transformResponse: (response: SecureFetchResponse) => Promise + transformResponse: (response: Response) => Promise ): Promise { - const resolvedIP = await validateInstanceUrl(params.instanceUrl) - const token = await agiloftLogin(params, resolvedIP) + const token = await agiloftLogin(params) const base = params.instanceUrl.replace(/\/$/, '') try { const req = buildRequest(base) - const { secureFetchWithPinnedIP } = await getServerSecurity() - const response = await secureFetchWithPinnedIP(req.url, resolvedIP, { + const response = await fetch(req.url, { method: req.method, headers: { ...req.headers, Authorization: `Bearer ${token}`, }, - body: req.body as string | Buffer | Uint8Array | undefined, + body: req.body, }) return await transformResponse(response) } finally { - await agiloftLogout(params.instanceUrl, params.knowledgeBase, token, resolvedIP) + await agiloftLogout(params.instanceUrl, params.knowledgeBase, token) } } /** * Login helper exported for use in the attach file API route. */ -export { agiloftLogin, agiloftLogout, validateInstanceUrl } +export { agiloftLogin, agiloftLogout } /** URL builders (credential-free -- auth is via Bearer token header) */