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.
This commit is contained in:
Waleed Latif
2026-04-17 13:02:43 -07:00
parent df416cc098
commit 5d986b22ba
2 changed files with 31 additions and 78 deletions

View File

@@ -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) {

View File

@@ -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<string>
json: () => Promise<unknown>
arrayBuffer: () => Promise<ArrayBuffer>
}
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<string> {
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<string> {
async function agiloftLogin(params: AgiloftBaseParams): Promise<string> {
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<void> {
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<R extends ToolResponse>(
params: AgiloftBaseParams,
buildRequest: (base: string) => AgiloftRequestConfig,
transformResponse: (response: SecureFetchResponse) => Promise<R>
transformResponse: (response: Response) => Promise<R>
): Promise<R> {
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) */