Skip to content

Commit 5222387

Browse files
committed
fix(cbinsights): stop paging and blank input bypassing the search guards
- Measure the firmographics empty-search guard against the filters alone. limit, nextPageToken, and sort were in the same object, so a request carrying only paging slipped past it and issued an unfiltered search over the whole database — which still spends credits. - Reject a mistyped numeric bound instead of dropping it. A bad headcount, funding, or valuation filter silently widened the search, the same failure mode already fixed for ID lists. - Treat an empty comma segment identically on the required and optional paths. A trailing or doubled comma is a separator artifact that cannot change which records are requested, so both paths now discard it; every other malformed entry is still rejected.
1 parent 803a3ec commit 5222387

3 files changed

Lines changed: 139 additions & 25 deletions

File tree

apps/sim/tools/cbinsights/cbinsights.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,73 @@ describe('cbinsights request building', () => {
246246
expect(JSON.parse(String(calls[1].init.body))).toEqual({ keyword: 'fintech' })
247247
})
248248

249+
/*
250+
* An empty segment is a separator artifact, not a value — dropping it cannot
251+
* change which organizations are requested. The required and optional paths
252+
* must agree, or the same typing succeeds on one operation and fails on
253+
* another.
254+
*/
255+
it('tolerates a trailing or doubled comma identically on both paths', async () => {
256+
mockFetch([AUTH_OK, { body: { orgs: [] } }, { body: { orgs: [] } }])
257+
258+
await cbinsightsListFundingsTool.directExecution!({
259+
...CREDS,
260+
orgIds: '129410, 1034157,',
261+
} as never)
262+
expect(JSON.parse(String(calls[1].init.body)).orgIds).toEqual([129410, 1034157])
263+
264+
await cbinsightsSearchFirmographicsTool.directExecution!({
265+
...CREDS,
266+
sectorIds: '1,,2',
267+
} as never)
268+
expect(JSON.parse(String(calls[2].init.body)).sectorIds).toEqual([1, 2])
269+
})
270+
271+
it('rejects a mistyped numeric bound rather than dropping it', async () => {
272+
mockFetch([AUTH_OK])
273+
await expect(
274+
cbinsightsSearchFirmographicsTool.directExecution!({
275+
...CREDS,
276+
keyword: 'fintech',
277+
minCurrentHeadcount: 'fifty',
278+
} as never)
279+
).rejects.toThrow(/"minCurrentHeadcount" must be a number/)
280+
})
281+
282+
/*
283+
* The guard measures the filters alone. Paging or sort slipping past it would
284+
* issue an unfiltered search over the whole database — and still bill for it.
285+
*/
286+
it('refuses a firmographics search carrying only paging or sort', async () => {
287+
mockFetch([AUTH_OK])
288+
await expect(
289+
cbinsightsSearchFirmographicsTool.directExecution!({
290+
...CREDS,
291+
limit: 100,
292+
nextPageToken: 'tok',
293+
sortField: 'mosaicOverall',
294+
} as never)
295+
).rejects.toThrow(/at least one search parameter/)
296+
})
297+
298+
it('still sends paging and sort alongside a real filter', async () => {
299+
mockFetch([AUTH_OK, { body: { orgs: [] } }])
300+
await cbinsightsSearchFirmographicsTool.directExecution!({
301+
...CREDS,
302+
keyword: 'fintech',
303+
limit: 25,
304+
nextPageToken: 'tok',
305+
sortField: 'mosaicOverall',
306+
} as never)
307+
308+
expect(JSON.parse(String(calls[1].init.body))).toEqual({
309+
keyword: 'fintech',
310+
limit: 25,
311+
nextPageToken: 'tok',
312+
sort: { field: 'mosaicOverall', direction: 'desc' },
313+
})
314+
})
315+
249316
it('rejects a non-integer organization ID rather than interpolating it into the path', async () => {
250317
mockFetch([AUTH_OK])
251318
await expect(

apps/sim/tools/cbinsights/search_firmographics.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ export const cbinsightsSearchFirmographicsTool: ToolConfig<
307307
request: { url: () => '', method: 'POST', headers: () => ({}) },
308308

309309
directExecution: async (params, signal) => {
310-
const body = compactBody({
310+
const filters = compactBody({
311311
keyword: params.keyword?.trim(),
312312
orgIds: parseIdListParam(params.orgIds, 'orgIds'),
313313
orgNames: parseStringListParam(params.orgNames, 'orgNames'),
@@ -338,19 +338,47 @@ export const cbinsightsSearchFirmographicsTool: ToolConfig<
338338
params.lastFundingRoundCategoryIds,
339339
'lastFundingRoundCategoryIds'
340340
),
341-
minCurrentHeadcount: parseIntegerParam(params.minCurrentHeadcount),
342-
maxCurrentHeadcount: parseIntegerParam(params.maxCurrentHeadcount),
343-
minTotalFundingInMillions: parseNumberParam(params.minTotalFundingInMillions),
344-
maxTotalFundingInMillions: parseNumberParam(params.maxTotalFundingInMillions),
345-
minValuationInMillions: parseNumberParam(params.minValuationInMillions),
346-
maxValuationInMillions: parseNumberParam(params.maxValuationInMillions),
341+
minCurrentHeadcount: parseIntegerParam(params.minCurrentHeadcount, 'minCurrentHeadcount'),
342+
maxCurrentHeadcount: parseIntegerParam(params.maxCurrentHeadcount, 'maxCurrentHeadcount'),
343+
minTotalFundingInMillions: parseNumberParam(
344+
params.minTotalFundingInMillions,
345+
'minTotalFundingInMillions'
346+
),
347+
maxTotalFundingInMillions: parseNumberParam(
348+
params.maxTotalFundingInMillions,
349+
'maxTotalFundingInMillions'
350+
),
351+
minValuationInMillions: parseNumberParam(
352+
params.minValuationInMillions,
353+
'minValuationInMillions'
354+
),
355+
maxValuationInMillions: parseNumberParam(
356+
params.maxValuationInMillions,
357+
'maxValuationInMillions'
358+
),
347359
minLastFundingDate: params.minLastFundingDate?.trim(),
348360
maxLastFundingDate: params.maxLastFundingDate?.trim(),
349361
vcBacked: parseBooleanParam(params.vcBacked),
350-
limit: clampLimit(params.limit),
351-
nextPageToken: params.nextPageToken,
352362
})
353363

364+
/*
365+
* The guard has to measure the *filters* alone. Folding limit, the page
366+
* token, or the sort into the same object would let a request carrying only
367+
* paging past it — which is an unfiltered search over the whole database,
368+
* and it still spends credits.
369+
*/
370+
if (Object.keys(filters).length === 0) {
371+
throw new Error('CB Insights firmographics search requires at least one search parameter')
372+
}
373+
374+
const body: Record<string, unknown> = {
375+
...filters,
376+
...compactBody({
377+
limit: clampLimit(params.limit),
378+
nextPageToken: params.nextPageToken,
379+
}),
380+
}
381+
354382
/* The API takes one sort object; the block exposes it as two plain fields
355383
so neither has to be typed as JSON. */
356384
const sortField = params.sortField?.trim()
@@ -361,10 +389,6 @@ export const cbinsightsSearchFirmographicsTool: ToolConfig<
361389
}
362390
}
363391

364-
if (Object.keys(body).length === 0) {
365-
throw new Error('CB Insights firmographics search requires at least one search parameter')
366-
}
367-
368392
return cbInsightsRequest<{
369393
orgs?: unknown
370394
nextPageToken?: unknown

apps/sim/tools/cbinsights/utils.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ export function requireOrgIds(value: unknown): number[] {
279279
}
280280
raw = parsed
281281
} else {
282-
raw = trimmed.split(',')
282+
raw = splitCommaList(trimmed)
283283
}
284284
} else if (value !== undefined && value !== null) {
285285
raw = [value]
@@ -334,6 +334,23 @@ export function clampLimit(value: unknown): number | undefined {
334334
return Math.min(Math.max(Math.trunc(parsed), LIMIT_MIN), LIMIT_MAX)
335335
}
336336

337+
/**
338+
* Splits a hand-typed comma list, discarding empty segments.
339+
*
340+
* An empty segment is a separator artifact — a trailing comma, or a double one —
341+
* and carries no value, so dropping it cannot change *which* records are
342+
* requested. That is the opposite of dropping a mistyped entry like `notanid`,
343+
* which silently discards an ID the caller meant to include. Both the required
344+
* and the optional paths route through here so the same typing produces the same
345+
* result on every operation.
346+
*/
347+
function splitCommaList(value: string): string[] {
348+
return value
349+
.split(',')
350+
.map((entry) => entry.trim())
351+
.filter((entry) => entry !== '')
352+
}
353+
337354
/**
338355
* Parses a JSON-array param that may arrive already parsed, tolerating a bare
339356
* comma-separated list for the flat ID filters.
@@ -346,10 +363,7 @@ export function parseListParam(value: unknown, paramName: string): unknown[] | u
346363
const trimmed = value.trim()
347364
if (trimmed === '') return undefined
348365
if (!trimmed.startsWith('[')) {
349-
const entries = trimmed
350-
.split(',')
351-
.map((entry) => entry.trim())
352-
.filter(Boolean)
366+
const entries = splitCommaList(trimmed)
353367
return entries.length > 0 ? entries : undefined
354368
}
355369
let parsed: unknown
@@ -389,16 +403,25 @@ export function parseStringListParam(value: unknown, paramName: string): string[
389403
return values.length > 0 ? values : undefined
390404
}
391405

392-
/** Coerces an optional numeric filter, dropping a value that is not a number. */
393-
export function parseNumberParam(value: unknown): number | undefined {
406+
/**
407+
* Coerces an optional numeric filter, rejecting a value that is not a number.
408+
*
409+
* Dropping it would widen the search rather than narrow it — a mistyped headcount
410+
* or valuation bound would silently disappear and the broader query would still
411+
* spend credits. Same reasoning as the ID lists.
412+
*/
413+
export function parseNumberParam(value: unknown, paramName: string): number | undefined {
394414
if (value === undefined || value === null || value === '') return undefined
395-
const parsed = typeof value === 'number' ? value : Number(value)
396-
return Number.isFinite(parsed) ? parsed : undefined
415+
const parsed = typeof value === 'number' ? value : Number(String(value).trim())
416+
if (!Number.isFinite(parsed)) {
417+
throw new Error(`CB Insights "${paramName}" must be a number (received "${String(value)}")`)
418+
}
419+
return parsed
397420
}
398421

399-
/** Coerces an optional integer filter. */
400-
export function parseIntegerParam(value: unknown): number | undefined {
401-
const parsed = parseNumberParam(value)
422+
/** Coerces an optional integer filter, rejecting a value that is not a number. */
423+
export function parseIntegerParam(value: unknown, paramName: string): number | undefined {
424+
const parsed = parseNumberParam(value, paramName)
402425
return parsed === undefined ? undefined : Math.trunc(parsed)
403426
}
404427

0 commit comments

Comments
 (0)