mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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) {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user