Skip to content

Commit c4bcd23

Browse files
committed
fix(agiloft): stop sending a bearer token to EWAttach
EWAttach lives on the legacy surface, which authenticates from inline $login/$password and rejects a bearer token — the same pairing that made the record operations fail. The attach route was still logging in and sending Authorization, while its URL builder was the one EW* builder that never included credentials, so the call had no usable auth at all. It now builds the URL through the shared EW* query prefix and issues a single request with no login/logout pair, matching every other legacy operation.
1 parent e187f86 commit c4bcd23

3 files changed

Lines changed: 25 additions & 34 deletions

File tree

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

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,10 @@ describe('POST /api/tools/agiloft/attach', () => {
109109
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
110110
})
111111

112-
it('pins the resolved IP for login, attach, and logout (TOCTOU fix)', async () => {
113-
inputValidationMockFns.mockSecureFetchWithPinnedIP
114-
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-att' } }))
115-
.mockResolvedValueOnce(mockSecureFetchResponse({ text: '1' }))
116-
.mockResolvedValueOnce(mockSecureFetchResponse({}))
112+
it('attaches with inline credentials on the pinned IP, with no login round trip', async () => {
113+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
114+
mockSecureFetchResponse({ text: '1' })
115+
)
117116

118117
const response = await POST(createMockRequest('POST', baseBody))
119118
expect(response.status).toBe(200)
@@ -125,21 +124,17 @@ describe('POST /api/tools/agiloft/attach', () => {
125124
expect(data.output.fileName).toBe('file.txt')
126125

127126
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
128-
expect(calls).toHaveLength(3)
129-
for (const call of calls) {
130-
expect(call[1]).toBe(PINNED_IP)
131-
}
127+
expect(calls).toHaveLength(1)
128+
expect(calls[0][1]).toBe(PINNED_IP)
132129

133-
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin')
134-
expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWAttach')
135-
expect(calls[1][2]).toMatchObject({
130+
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWAttach')
131+
expect(calls[0][0]).toContain('&$login=admin')
132+
expect(calls[0][2]).toMatchObject({
136133
method: 'PUT',
137-
headers: {
138-
Authorization: 'Bearer tok-att',
139-
'Content-Type': 'application/octet-stream',
140-
},
134+
headers: { 'Content-Type': 'application/octet-stream' },
141135
})
142-
expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout')
136+
// A bearer token on this surface is rejected; it must not be sent.
137+
expect(calls[0][2].headers.Authorization).toBeUndefined()
143138

144139
// DNS only resolved once.
145140
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1)

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

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.
1313
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
1414
import { assertToolFileAccess } from '@/app/api/files/authorization'
1515
import { buildAttachFileUrl } from '@/tools/agiloft/utils'
16-
import {
17-
agiloftLoginPinned,
18-
agiloftLogoutPinned,
19-
resolveAgiloftInstance,
20-
} from '@/tools/agiloft/utils.server'
16+
import { resolveAgiloftInstance } from '@/tools/agiloft/utils.server'
2117

2218
export const dynamic = 'force-dynamic'
2319

@@ -99,10 +95,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
9995
return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 })
10096
}
10197

102-
const session = await agiloftLoginPinned(data, resolvedIP)
10398
const base = data.instanceUrl.replace(/\/$/, '')
10499

105-
try {
100+
{
101+
/**
102+
* EWAttach lives on the legacy surface, which authenticates from the
103+
* inline credentials in the URL and rejects a bearer token — so there is
104+
* no login/logout pair here.
105+
*/
106106
const url = buildAttachFileUrl(base, data, resolvedFileName)
107107

108108
logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`)
@@ -111,7 +111,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
111111
method: 'PUT',
112112
headers: {
113113
'Content-Type': 'application/octet-stream',
114-
Authorization: session.authorization,
115114
},
116115
body: new Uint8Array(fileBuffer),
117116
})
@@ -150,13 +149,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
150149
totalAttachments,
151150
},
152151
})
153-
} finally {
154-
await agiloftLogoutPinned(
155-
data.instanceUrl,
156-
data.knowledgeBase,
157-
session.authorization,
158-
resolvedIP
159-
)
160152
}
161153
} catch (error) {
162154
logger.error(`[${requestId}] Error attaching file to Agiloft:`, error)

apps/sim/tools/agiloft/utils.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,16 +138,20 @@ export function buildLockRecordUrl(base: string, params: AgiloftLockRecordParams
138138
return url
139139
}
140140

141+
/**
142+
* EWAttach carries the file as the raw request body, so its credentials have to
143+
* travel in the query string like the rest of the EW* surface — there is no
144+
* room for a form-encoded credential body here.
145+
*/
141146
export function buildAttachFileUrl(
142147
base: string,
143148
params: AgiloftBaseParams & { recordId: string; fieldName: string },
144149
fileName: string
145150
): string {
146-
const { kb, table } = encodeTable(params)
147151
const recordId = encodeURIComponent(params.recordId.trim())
148152
const fieldName = encodeURIComponent(params.fieldName.trim())
149153
const encodedFileName = encodeURIComponent(fileName)
150-
return `${base}/ewws/EWAttach?$KB=${kb}&$table=${table}&$lang=${AGILOFT_LANG}&id=${recordId}&field=${fieldName}&fileName=${encodedFileName}`
154+
return `${base}/ewws/EWAttach?${buildEwBaseQuery(params)}&id=${recordId}&field=${fieldName}&fileName=${encodedFileName}`
151155
}
152156

153157
export function buildGetChoiceLineIdUrl(

0 commit comments

Comments
 (0)