Skip to content

Commit 5d986b2

Browse files
committed
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.
1 parent df416cc commit 5d986b2

File tree

2 files changed

+31
-78
lines changed

2 files changed

+31
-78
lines changed

apps/sim/app/api/tools/agiloft/attach/route.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,12 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { z } from 'zod'
44
import { checkInternalAuth } from '@/lib/auth/hybrid'
5-
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
5+
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
66
import { generateRequestId } from '@/lib/core/utils/request'
77
import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas'
88
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
99
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
10-
import {
11-
agiloftLogin,
12-
agiloftLogout,
13-
buildAttachFileUrl,
14-
validateInstanceUrl,
15-
} from '@/tools/agiloft/utils'
10+
import { agiloftLogin, agiloftLogout, buildAttachFileUrl } from '@/tools/agiloft/utils'
1611

1712
export const dynamic = 'force-dynamic'
1813

@@ -65,28 +60,26 @@ export async function POST(request: NextRequest) {
6560
const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
6661
const resolvedFileName = data.fileName || userFile.name || 'attachment'
6762

68-
let resolvedIP: string
69-
try {
70-
resolvedIP = await validateInstanceUrl(data.instanceUrl)
71-
} catch (error) {
63+
const urlValidation = await validateUrlWithDNS(data.instanceUrl, 'instanceUrl')
64+
if (!urlValidation.isValid) {
7265
logger.warn(`[${requestId}] SSRF attempt blocked for Agiloft instance URL`, {
7366
instanceUrl: data.instanceUrl,
7467
})
7568
return NextResponse.json(
76-
{ success: false, error: error instanceof Error ? error.message : 'Invalid instance URL' },
69+
{ success: false, error: urlValidation.error || 'Invalid instance URL' },
7770
{ status: 400 }
7871
)
7972
}
8073

81-
const token = await agiloftLogin(data, resolvedIP)
74+
const token = await agiloftLogin(data)
8275
const base = data.instanceUrl.replace(/\/$/, '')
8376

8477
try {
8578
const url = buildAttachFileUrl(base, data, resolvedFileName)
8679

8780
logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`)
8881

89-
const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, {
82+
const agiloftResponse = await fetch(url, {
9083
method: 'PUT',
9184
headers: {
9285
'Content-Type': userFile.type || 'application/octet-stream',
@@ -130,7 +123,7 @@ export async function POST(request: NextRequest) {
130123
},
131124
})
132125
} finally {
133-
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
126+
await agiloftLogout(data.instanceUrl, data.knowledgeBase, token)
134127
}
135128
} catch (error) {
136129
if (error instanceof z.ZodError) {

apps/sim/tools/agiloft/utils.ts

Lines changed: 23 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { validateExternalUrl } from '@/lib/core/security/input-validation'
23
import type {
34
AgiloftAttachmentInfoParams,
45
AgiloftBaseParams,
@@ -13,76 +14,39 @@ import type {
1314
} from '@/tools/agiloft/types'
1415
import type { HttpMethod, ToolResponse } from '@/tools/types'
1516

16-
/**
17-
* Mirrors the shape of SecureFetchResponse from input-validation.server.ts.
18-
* Defined locally to avoid importing the .server module into client bundles
19-
* (it pulls in dns/promises which is Node-only).
20-
*/
21-
interface SecureFetchResponse {
22-
ok: boolean
23-
status: number
24-
statusText: string
25-
headers: { get(name: string): string | null }
26-
text: () => Promise<string>
27-
json: () => Promise<unknown>
28-
arrayBuffer: () => Promise<ArrayBuffer>
29-
}
30-
3117
const logger = createLogger('AgiloftAuth')
3218

33-
/**
34-
* Lazily imports server-only security functions to avoid pulling `dns/promises`
35-
* into client bundles (this file is reachable from tools/registry.ts).
36-
*/
37-
async function getServerSecurity() {
38-
const mod = await import('@/lib/core/security/input-validation.server')
39-
return {
40-
secureFetchWithPinnedIP: mod.secureFetchWithPinnedIP,
41-
validateUrlWithDNS: mod.validateUrlWithDNS,
42-
}
43-
}
44-
4519
interface AgiloftRequestConfig {
4620
url: string
4721
method: HttpMethod
4822
headers?: Record<string, string>
4923
body?: BodyInit
5024
}
5125

52-
/**
53-
* Validates the instance URL via DNS resolution and returns the resolved IP
54-
* for use with pinned fetches to prevent SSRF via DNS rebinding.
55-
*/
56-
async function validateInstanceUrl(instanceUrl: string): Promise<string> {
57-
const { validateUrlWithDNS } = await getServerSecurity()
58-
const validation = await validateUrlWithDNS(instanceUrl, 'instanceUrl')
59-
if (!validation.isValid) {
60-
throw new Error(`Invalid Agiloft instance URL: ${validation.error}`)
61-
}
62-
return validation.resolvedIP!
63-
}
64-
6526
/**
6627
* Exchanges login/password for a short-lived Bearer token via EWLogin.
67-
* Uses DNS-pinned fetch to prevent SSRF via DNS rebinding.
6828
*/
69-
async function agiloftLogin(params: AgiloftBaseParams, resolvedIP: string): Promise<string> {
29+
async function agiloftLogin(params: AgiloftBaseParams): Promise<string> {
7030
const base = params.instanceUrl.replace(/\/$/, '')
7131

32+
const urlValidation = validateExternalUrl(params.instanceUrl, 'instanceUrl')
33+
if (!urlValidation.isValid) {
34+
throw new Error(`Invalid Agiloft instance URL: ${urlValidation.error}`)
35+
}
36+
7237
const kb = encodeURIComponent(params.knowledgeBase)
7338
const login = encodeURIComponent(params.login)
7439
const password = encodeURIComponent(params.password)
7540

7641
const url = `${base}/ewws/EWLogin?$KB=${kb}&$login=${login}&$password=${password}`
77-
const { secureFetchWithPinnedIP } = await getServerSecurity()
78-
const response = await secureFetchWithPinnedIP(url, resolvedIP, { method: 'POST' })
42+
const response = await fetch(url, { method: 'POST' })
7943

8044
if (!response.ok) {
8145
const errorText = await response.text()
8246
throw new Error(`Agiloft login failed: ${response.status} - ${errorText}`)
8347
}
8448

85-
const data = (await response.json()) as { access_token?: string }
49+
const data = await response.json()
8650
const token = data.access_token
8751

8852
if (!token) {
@@ -94,19 +58,16 @@ async function agiloftLogin(params: AgiloftBaseParams, resolvedIP: string): Prom
9458

9559
/**
9660
* Cleans up the server session. Best-effort — failures are logged but not thrown.
97-
* Uses DNS-pinned fetch to prevent SSRF via DNS rebinding.
9861
*/
9962
async function agiloftLogout(
10063
instanceUrl: string,
10164
knowledgeBase: string,
102-
token: string,
103-
resolvedIP: string
65+
token: string
10466
): Promise<void> {
10567
try {
10668
const base = instanceUrl.replace(/\/$/, '')
10769
const kb = encodeURIComponent(knowledgeBase)
108-
const { secureFetchWithPinnedIP } = await getServerSecurity()
109-
await secureFetchWithPinnedIP(`${base}/ewws/EWLogout?$KB=${kb}`, resolvedIP, {
70+
await fetch(`${base}/ewws/EWLogout?$KB=${kb}`, {
11071
method: 'POST',
11172
headers: { Authorization: `Bearer ${token}` },
11273
})
@@ -117,43 +78,42 @@ async function agiloftLogout(
11778

11879
/**
11980
* Shared wrapper that handles the full auth lifecycle:
120-
* 1. Validate instance URL via DNS resolution
121-
* 2. Login to get Bearer token (using pinned IP)
122-
* 3. Execute the request with the token (using pinned IP)
123-
* 4. Logout to clean up the session (using pinned IP)
81+
* 1. Login to get Bearer token
82+
* 2. Execute the request with the token
83+
* 3. Logout to clean up the session
12484
*
125-
* All HTTP requests use the resolved IP to prevent SSRF via DNS rebinding.
85+
* The `buildRequest` callback receives the token and base URL, and returns
86+
* the request config. The `transformResponse` callback converts the raw
87+
* Response into the tool's output format.
12688
*/
12789
export async function executeAgiloftRequest<R extends ToolResponse>(
12890
params: AgiloftBaseParams,
12991
buildRequest: (base: string) => AgiloftRequestConfig,
130-
transformResponse: (response: SecureFetchResponse) => Promise<R>
92+
transformResponse: (response: Response) => Promise<R>
13193
): Promise<R> {
132-
const resolvedIP = await validateInstanceUrl(params.instanceUrl)
133-
const token = await agiloftLogin(params, resolvedIP)
94+
const token = await agiloftLogin(params)
13495
const base = params.instanceUrl.replace(/\/$/, '')
13596

13697
try {
13798
const req = buildRequest(base)
138-
const { secureFetchWithPinnedIP } = await getServerSecurity()
139-
const response = await secureFetchWithPinnedIP(req.url, resolvedIP, {
99+
const response = await fetch(req.url, {
140100
method: req.method,
141101
headers: {
142102
...req.headers,
143103
Authorization: `Bearer ${token}`,
144104
},
145-
body: req.body as string | Buffer | Uint8Array | undefined,
105+
body: req.body,
146106
})
147107
return await transformResponse(response)
148108
} finally {
149-
await agiloftLogout(params.instanceUrl, params.knowledgeBase, token, resolvedIP)
109+
await agiloftLogout(params.instanceUrl, params.knowledgeBase, token)
150110
}
151111
}
152112

153113
/**
154114
* Login helper exported for use in the attach file API route.
155115
*/
156-
export { agiloftLogin, agiloftLogout, validateInstanceUrl }
116+
export { agiloftLogin, agiloftLogout }
157117

158118
/** URL builders (credential-free -- auth is via Bearer token header) */
159119

0 commit comments

Comments
 (0)