Skip to content

Commit f98dbfa

Browse files
committed
fix(cbinsights): reject malformed ID lists and bound the token cache
- Reject an organization ID list containing an invalid entry instead of dropping it. Silently filtering meant a typo ran the request against a narrower set — spending credits on the wrong organizations, or quietly widening a filtered search — and still reported success. - Apply the same rule to the optional firmographics ID filters, where a dropped filter broadens the search rather than narrowing it. - Bound the process-wide token cache so a long-lived worker serving many CB Insights accounts does not grow with the cumulative number of accounts seen. Expired entries are swept on write, then the oldest evicted.
1 parent a86468f commit f98dbfa

2 files changed

Lines changed: 133 additions & 9 deletions

File tree

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { cbinsightsListFundingsTool } from '@/tools/cbinsights/list_fundings'
1010
import { cbinsightsLookupOrganizationsTool } from '@/tools/cbinsights/lookup_organizations'
1111
import { cbinsightsRagTool } from '@/tools/cbinsights/rag'
1212
import { cbinsightsSearchFirmographicsTool } from '@/tools/cbinsights/search_firmographics'
13-
import { resetCbInsightsTokenCache } from '@/tools/cbinsights/utils'
13+
import { cbInsightsTokenCacheSize, resetCbInsightsTokenCache } from '@/tools/cbinsights/utils'
1414

1515
const CREDS = { clientId: 'id', clientSecret: 'secret' }
1616

@@ -133,6 +133,30 @@ describe('cbinsights authorization', () => {
133133
})
134134
})
135135

136+
describe('cbinsights token cache', () => {
137+
/*
138+
* The cache is process-wide. Without a bound, a long-lived worker serving many
139+
* CB Insights accounts would grow with the cumulative number of accounts seen.
140+
*/
141+
it('stays bounded as distinct credential pairs accumulate', async () => {
142+
const responses = []
143+
for (let index = 0; index < 200; index++) {
144+
responses.push({ body: { token: `jwt-${index}` } }, { body: { orgs: [] } })
145+
}
146+
mockFetch(responses)
147+
148+
for (let index = 0; index < 200; index++) {
149+
await cbinsightsLookupOrganizationsTool.directExecution!({
150+
clientId: `id-${index}`,
151+
clientSecret: 'secret',
152+
names: 'a',
153+
} as never)
154+
}
155+
156+
expect(cbInsightsTokenCacheSize()).toBeLessThanOrEqual(128)
157+
})
158+
})
159+
136160
describe('cbinsights request building', () => {
137161
it('rejects a lookup with no search parameter', async () => {
138162
mockFetch([AUTH_OK])
@@ -186,6 +210,42 @@ describe('cbinsights request building', () => {
186210
).rejects.toThrow(/at most 100 organization IDs/)
187211
})
188212

213+
/*
214+
* Dropping the bad entries instead would run against a silently narrower set:
215+
* a typo would spend credits on the wrong organizations and still report
216+
* success. Both reviewers flagged this independently.
217+
*/
218+
it('rejects a required ID list containing an invalid entry rather than dropping it', async () => {
219+
mockFetch([AUTH_OK])
220+
await expect(
221+
cbinsightsListFundingsTool.directExecution!({
222+
...CREDS,
223+
orgIds: '129410, notanid, 1034157',
224+
} as never)
225+
).rejects.toThrow(/must contain only positive integers \(invalid: notanid\)/)
226+
})
227+
228+
it('rejects a mistyped optional filter rather than silently widening the search', async () => {
229+
mockFetch([AUTH_OK])
230+
await expect(
231+
cbinsightsSearchFirmographicsTool.directExecution!({
232+
...CREDS,
233+
keyword: 'fintech',
234+
sectorIds: 'four',
235+
} as never)
236+
).rejects.toThrow(/"sectorIds" must contain only positive integers/)
237+
})
238+
239+
it('still treats an unset optional filter as absent', async () => {
240+
mockFetch([AUTH_OK, { body: { orgs: [] } }])
241+
await cbinsightsSearchFirmographicsTool.directExecution!({
242+
...CREDS,
243+
keyword: 'fintech',
244+
sectorIds: '',
245+
} as never)
246+
expect(JSON.parse(String(calls[1].init.body))).toEqual({ keyword: 'fintech' })
247+
})
248+
189249
it('rejects a non-integer organization ID rather than interpolating it into the path', async () => {
190250
mockFetch([AUTH_OK])
191251
await expect(

apps/sim/tools/cbinsights/utils.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,31 @@ export const LIMIT_MAX = 100
4242
const tokenCache = new Map<string, { token: string; expiresAt: number }>()
4343
const TOKEN_CACHE_TTL_MS = 5 * 60 * 1000
4444

45+
/**
46+
* Ceiling on distinct credential pairs held at once.
47+
*
48+
* The cache is process-wide, so on a long-lived worker serving many CB Insights
49+
* accounts it would otherwise grow with the cumulative number of accounts seen.
50+
*/
51+
const TOKEN_CACHE_MAX_ENTRIES = 128
52+
53+
/**
54+
* Drops expired entries, then the oldest surviving ones if still over the cap.
55+
*
56+
* Map iteration is insertion-ordered, so the first surviving key is the least
57+
* recently authorized — evicting it costs at most one extra token exchange.
58+
*/
59+
function pruneTokenCache(): void {
60+
const now = Date.now()
61+
for (const [key, entry] of tokenCache) {
62+
if (entry.expiresAt <= now) tokenCache.delete(key)
63+
}
64+
for (const key of tokenCache.keys()) {
65+
if (tokenCache.size <= TOKEN_CACHE_MAX_ENTRIES) break
66+
tokenCache.delete(key)
67+
}
68+
}
69+
4570
async function credentialDigest(clientId: string, clientSecret: string): Promise<string> {
4671
const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`)
4772
const digest = await crypto.subtle.digest('SHA-256', bytes)
@@ -140,6 +165,8 @@ async function getToken(
140165

141166
const token = await authorize(clientId, clientSecret, signal)
142167
tokenCache.set(key, { token, expiresAt: Date.now() + TOKEN_CACHE_TTL_MS })
168+
/* Pruned after the insert so the cap bounds the cache including this entry. */
169+
pruneTokenCache()
143170
return token
144171
}
145172

@@ -148,6 +175,11 @@ export function resetCbInsightsTokenCache(): void {
148175
tokenCache.clear()
149176
}
150177

178+
/** Current number of cached tokens. Exported for tests. */
179+
export function cbInsightsTokenCacheSize(): number {
180+
return tokenCache.size
181+
}
182+
151183
interface CbInsightsRequestSpec {
152184
/** Path below the API origin, e.g. `/v2/firmographics`. */
153185
path: string
@@ -253,9 +285,7 @@ export function requireOrgIds(value: unknown): number[] {
253285
raw = [value]
254286
}
255287

256-
const orgIds = raw
257-
.map((entry) => (typeof entry === 'number' ? entry : Number(String(entry).trim())))
258-
.filter((entry) => Number.isInteger(entry) && entry > 0)
288+
const orgIds = toPositiveIntegers(raw, 'orgIds')
259289

260290
if (orgIds.length === 0) {
261291
throw new Error('CB Insights "orgIds" must contain at least one positive integer')
@@ -266,6 +296,36 @@ export function requireOrgIds(value: unknown): number[] {
266296
return orgIds
267297
}
268298

299+
/**
300+
* Converts every entry to a positive integer, rejecting the whole list if any
301+
* entry is not one.
302+
*
303+
* Dropping the bad entries instead would run the request against a silently
304+
* narrower set — a typo in an ID list would spend credits on the wrong
305+
* organizations, or quietly widen a filtered search, and still report success.
306+
*/
307+
function toPositiveIntegers(entries: readonly unknown[], paramName: string): number[] {
308+
const invalid: string[] = []
309+
const ids: number[] = []
310+
311+
for (const entry of entries) {
312+
const label = typeof entry === 'number' ? String(entry) : String(entry).trim()
313+
const parsed = typeof entry === 'number' ? entry : Number(label)
314+
if (!Number.isInteger(parsed) || parsed <= 0) {
315+
invalid.push(label)
316+
continue
317+
}
318+
ids.push(parsed)
319+
}
320+
321+
if (invalid.length > 0) {
322+
throw new Error(
323+
`CB Insights "${paramName}" must contain only positive integers (invalid: ${invalid.join(', ')})`
324+
)
325+
}
326+
return ids
327+
}
328+
269329
/** Coerces a numeric param and clamps it into the documented [1, 100] range. */
270330
export function clampLimit(value: unknown): number | undefined {
271331
if (value === undefined || value === null || value === '') return undefined
@@ -307,14 +367,18 @@ export function parseListParam(value: unknown, paramName: string): unknown[] | u
307367
return [value]
308368
}
309369

310-
/** Parses a list of IDs, keeping only positive integers. */
370+
/**
371+
* Parses an optional list of IDs, rejecting any entry that is not a positive
372+
* integer.
373+
*
374+
* An unset filter is fine and returns undefined; a filter the caller *did* set
375+
* but mistyped is not, because dropping it would silently widen the search
376+
* rather than narrow it — and the wider search still spends credits.
377+
*/
311378
export function parseIdListParam(value: unknown, paramName: string): number[] | undefined {
312379
const entries = parseListParam(value, paramName)
313380
if (!entries) return undefined
314-
const ids = entries
315-
.map((entry) => (typeof entry === 'number' ? entry : Number(String(entry).trim())))
316-
.filter((entry) => Number.isInteger(entry) && entry > 0)
317-
return ids.length > 0 ? ids : undefined
381+
return toPositiveIntegers(entries, paramName)
318382
}
319383

320384
/** Parses a list of free-text values. */

0 commit comments

Comments
 (0)