Skip to content

Commit b324938

Browse files
committed
fix(agiloft): refuse an object nested in a multi-value field
The guard that rejects unencodable field values only ran on the top-level value, so an object inside an array fell through to String() and wrote "[object Object]" into the record while reporting success. Extracting the render step means array entries get the same refusal a bare value does. Pre-existing in the upsert body builder, but it now sits on the create path too, which is the operation this branch is making trustworthy. Also fills the coverage gaps a diff audit turned up: invalid and non-object JSON in the data param, an empty field list for natural language search, a search that matched nothing, separator characters in an encoded value, and the block wiring that makes page and limit reachable for natural language search.
1 parent e2d074b commit b324938

5 files changed

Lines changed: 156 additions & 8 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,39 @@ describe('EWCreate', () => {
238238
expect(data.error).toContain('the record may exist')
239239
expect(data.error).toContain('retrying creates a second record')
240240
})
241+
242+
it('rejects a data parameter that is not JSON without calling Agiloft', async () => {
243+
const response = await POST(createMockRequest('POST', { ...baseBody, data: 'title = X' }))
244+
const data = (await response.json()) as { success: boolean; error?: string }
245+
246+
expect(data.success).toBe(false)
247+
expect(data.error).toContain('must be a JSON object of field names to values')
248+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
249+
})
250+
251+
/**
252+
* A JSON array parses fine but has no field names, so it would post a body
253+
* carrying nothing but credentials and create an empty record.
254+
*/
255+
it('rejects valid JSON that is not an object of field names', async () => {
256+
const response = await POST(createMockRequest('POST', { ...baseBody, data: '["a","b"]' }))
257+
const data = (await response.json()) as { success: boolean; error?: string }
258+
259+
expect(data.success).toBe(false)
260+
expect(data.error).toContain('must be a JSON object of field names to values')
261+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
262+
})
263+
264+
it('refuses an object field value rather than writing [object Object]', async () => {
265+
const response = await POST(
266+
createMockRequest('POST', { ...baseBody, data: '{"nested":{"a":1}}' })
267+
)
268+
const data = (await response.json()) as { success: boolean; error?: string }
269+
270+
expect(data.success).toBe(false)
271+
expect(data.error).toContain('has no encoding for')
272+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
273+
})
241274
})
242275

243276
describe('field projection', () => {

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@ describe('EWNLPSearch response', () => {
161161
expect(data.error).toContain('One has to specify $login, $password')
162162
})
163163

164+
it('reports a search that matched nothing as an empty success, not a failure', async () => {
165+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
166+
res({ json: { success: true, message: '', result: [] } })
167+
)
168+
169+
const response = await POST(createMockRequest('POST', baseBody))
170+
const data = (await response.json()) as {
171+
success: boolean
172+
output: { records: unknown[]; totalCount: number; truncated: boolean }
173+
}
174+
175+
expect(data.success).toBe(true)
176+
expect(data.output).toEqual({ records: [], totalCount: 0, truncated: false })
177+
})
178+
164179
it('caps the records it returns and reports the result as truncated', async () => {
165180
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
166181
res({
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { AgiloftBlock } from '@/blocks/blocks/agiloft'
6+
7+
function conditionFor(subBlockId: string) {
8+
const subBlock = AgiloftBlock.subBlocks.find((entry) => entry.id === subBlockId)
9+
expect(subBlock, `subBlock ${subBlockId} is missing`).toBeDefined()
10+
return subBlock?.condition
11+
}
12+
13+
describe('AgiloftBlock', () => {
14+
/**
15+
* Natural Language Search runs across the whole knowledge base with no table
16+
* to narrow it, so pagination is the caller's only bound on the result size.
17+
* A condition naming only search_records hides both fields from the operation
18+
* that most needs them.
19+
*/
20+
it('offers page and limit to natural language search, not only to search records', () => {
21+
for (const field of ['page', 'limit']) {
22+
expect(conditionFor(field)).toEqual({
23+
field: 'operation',
24+
value: ['search_records', 'nlp_search'],
25+
})
26+
}
27+
})
28+
29+
/**
30+
* The NLP search route returns records, totalCount, and truncated — never a
31+
* limit — so advertising limit for that operation promises an output that
32+
* never arrives.
33+
*/
34+
it('does not advertise a limit output for natural language search', () => {
35+
expect(AgiloftBlock.outputs.limit.condition).toEqual({
36+
field: 'operation',
37+
value: 'search_records',
38+
})
39+
})
40+
})

apps/sim/tools/agiloft/utils.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
buildRetrieveAttachmentUrl,
1818
buildSavedSearchUrl,
1919
buildSelectRecordsUrl,
20+
buildUpsertRecordBody,
2021
describeAgiloftError,
2122
ewCredentialBody,
2223
parseFieldList,
@@ -248,6 +249,20 @@ describe('EWCreate', () => {
248249
it('refuses an object value rather than writing [object Object] into the record', () => {
249250
expect(() => buildCreateRecordBody(baseParams, { nested: { a: 1 } })).toThrow(TypeError)
250251
})
252+
253+
/**
254+
* A raw `&` or `=` inside a value would otherwise split the body into extra
255+
* pairs, writing attacker-chosen fields onto the record.
256+
*/
257+
it('escapes separators in a value so it cannot open a second field', () => {
258+
const sent = new URLSearchParams(
259+
buildCreateRecordBody(baseParams, { note: 'a&$table=other=b' })
260+
)
261+
262+
expect(sent.get('note')).toBe('a&$table=other=b')
263+
expect(sent.get('$table')).toBe('contract')
264+
expect(sent.getAll('$table')).toHaveLength(1)
265+
})
251266
})
252267

253268
describe('EWNLPSearch', () => {
@@ -282,6 +297,18 @@ describe('EWNLPSearch', () => {
282297
expect(sent.getAll('field')).toEqual(['id', 'contract_title1'])
283298
})
284299

300+
/**
301+
* The contract only requires a non-empty string, so a list of separators
302+
* reaches here. Sending an empty `field` pair would ask Agiloft for a field
303+
* with no name.
304+
*/
305+
it('sends no field pair when the list holds nothing but separators', () => {
306+
const sent = new URLSearchParams(buildNlpSearchBody({ ...nlpParams, fields: ' , , ' }))
307+
308+
expect(sent.getAll('field')).toEqual([])
309+
expect(sent.get('nlp_query')).toBe('Active NDAs submitted last month')
310+
})
311+
285312
it('omits pagination when it was not requested', () => {
286313
const sent = new URLSearchParams(buildNlpSearchBody(nlpParams))
287314
expect(sent.has('page')).toBe(false)
@@ -294,3 +321,26 @@ describe('EWNLPSearch', () => {
294321
expect(sent.get('limit')).toBe('25')
295322
})
296323
})
324+
325+
describe('multi-value field encoding', () => {
326+
/**
327+
* An object nested in a multi-value field is as unencodable as a bare one.
328+
* String()-ing it would write "[object Object]" into the record and report
329+
* success, which is worse than refusing the call.
330+
*/
331+
it('refuses an object inside an array rather than writing [object Object]', () => {
332+
expect(() => buildCreateRecordBody(baseParams, { contactMethod: ['phone', { a: 1 }] })).toThrow(
333+
TypeError
334+
)
335+
expect(() =>
336+
buildUpsertRecordBody({ ...baseParams, match: 'id' }, { contactMethod: ['phone', { a: 1 }] })
337+
).toThrow(TypeError)
338+
})
339+
340+
it('keeps encoding scalar array entries after the guard', () => {
341+
const sent = new URLSearchParams(
342+
buildCreateRecordBody(baseParams, { contactMethod: ['phone', 42, true] })
343+
)
344+
expect(sent.getAll('contactMethod')).toEqual(['phone', '42', 'true'])
345+
})
346+
})

apps/sim/tools/agiloft/utils.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,25 +239,35 @@ export function encodeEwFormBody(fields: Array<[string, string]>): string {
239239
* string. Objects have no documented encoding at all, and String()-ing one
240240
* silently writes "[object Object]" into the record.
241241
*/
242+
/**
243+
* Renders one field value, refusing anything Agiloft has no encoding for.
244+
*
245+
* Applied to array entries as well as bare values: an object nested in a
246+
* multi-value field is just as unencodable as a bare one, and `String()` would
247+
* quietly write "[object Object]" into the record instead of failing.
248+
*/
249+
function encodeFieldValue(field: string, value: unknown): string {
250+
if (typeof value === 'object') {
251+
throw new TypeError(
252+
`Field "${field}" is an object, which Agiloft has no encoding for. Use a string, a number, or an array of values.`
253+
)
254+
}
255+
return String(value)
256+
}
257+
242258
function pushRecordFields(fields: Array<[string, string]>, data: Record<string, unknown>): void {
243259
for (const [field, value] of Object.entries(data)) {
244260
if (value === undefined || value === null) continue
245261

246262
if (Array.isArray(value)) {
247263
for (const entry of value) {
248264
if (entry === undefined || entry === null) continue
249-
fields.push([field, String(entry)])
265+
fields.push([field, encodeFieldValue(field, entry)])
250266
}
251267
continue
252268
}
253269

254-
if (typeof value === 'object') {
255-
throw new TypeError(
256-
`Field "${field}" is an object, which Agiloft has no encoding for. Use a string, a number, or an array of values.`
257-
)
258-
}
259-
260-
fields.push([field, String(value)])
270+
fields.push([field, encodeFieldValue(field, value)])
261271
}
262272
}
263273

0 commit comments

Comments
 (0)