Skip to content

Commit 98e2364

Browse files
committed
test(hubspot,stripe,salesforce): fuzz path params one at a time
The suites inherited a coverage hole from the Vercel template they were modelled on: `buildParams` filled every string parameter with the same fuzz value and the assertion swallowed a throw with `catch { return }`. The first guarded parameter therefore threw and silently retired every sibling from the run, so once a tool had one guard the rest of its path parameters stopped being tested. The reported property — "a newly added unguarded path parameter fails CI" — did not hold. HubSpot is where this bit hardest: the association tools put four parameters (`objectType`, `objectId`, `toObjectType`, `toObjectId`) into one path, so three of them were never exercised. Reverting the `objectId` guard in `delete_association` produced zero failures. The unit of coverage is now a (tool, parameter) pair. Each case fuzzes exactly one parameter and pins every sibling to a safe placeholder, so a throw is attributable to the parameter under test and is only then a pass. Discovery enumerates pairs from the barrel, so a new unguarded path parameter on an already-guarded tool now fails. Each pair also asserts a bare `.` and `..` are rejected outright. The shape check alone cannot see a dot in the final segment: `x/.` normalizes to `x/`, preserving both the segment count and every other segment. Adds a fixture pinning the exact path-parameter set of the three association tools, so dropping one from the sweep fails rather than quietly shrinking coverage. 92 pairs total: 28 Stripe, 41 HubSpot, 23 Salesforce. 2247 assertions.
1 parent 9f35e6e commit 98e2364

3 files changed

Lines changed: 326 additions & 158 deletions

File tree

apps/sim/tools/hubspot/path_safety.test.ts

Lines changed: 140 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,33 @@
1717
* Every assertion here resolves the built URL with `new URL(...)` — the same
1818
* normalization `fetch` performs — rather than string-matching the template
1919
* output, because string matching is exactly what let this through.
20+
*
21+
* The unit of coverage is a **(tool, parameter) pair**, not a tool. Fuzzing
22+
* every parameter of a tool at once and tolerating a throw is unsound: the
23+
* first guarded parameter throws and silently retires every sibling from the
24+
* suite. HubSpot is where that mattered most — the association tools put the
25+
* four parameters `objectType`, `objectId`, `toObjectType` and `toObjectType`
26+
* into a single path, so guarding the first one would have retired the other
27+
* three. Each case below fuzzes exactly one parameter while holding its
28+
* siblings at a safe value, which is what makes "a newly added unguarded path
29+
* parameter fails CI" actually true. A throw is only accepted as a pass
30+
* *because* the thrown-at parameter is the one under test.
2031
*/
2132
import { describe, expect, it } from 'vitest'
33+
import { hubspotCreateAssociationTool } from '@/tools/hubspot/create_association'
2234
import { hubspotDeleteAssociationTool } from '@/tools/hubspot/delete_association'
2335
import { hubspotDeleteCompanyTool } from '@/tools/hubspot/delete_company'
2436
import { hubspotDeleteContactTool } from '@/tools/hubspot/delete_contact'
2537
import { hubspotDeleteDealTool } from '@/tools/hubspot/delete_deal'
2638
import * as hubspotTools from '@/tools/hubspot/index'
39+
import { hubspotListAssociationsTool } from '@/tools/hubspot/list_associations'
2740
import type { ToolConfig } from '@/tools/types'
2841

2942
/**
3043
* The bare `.` and `..` entries are the whole point: their omission is why an
3144
* `encodeURIComponent`-only fix looks correct while the hole stays live.
3245
*/
33-
const TRAVERSAL_IDS = [
46+
const TRAVERSAL_VALUES = [
3447
'..',
3548
'.',
3649
' .. ',
@@ -57,10 +70,17 @@ const LEGITIMATE_IDS = [
5770
'hs_object_id',
5871
] as const
5972

60-
const SAFE_ID = 'SAFEID'
73+
/** The value the parameter under test carries when a path is being mapped. */
74+
const TARGET = 'TARGETVALUE'
75+
76+
/** The value every *other* string parameter is pinned to while one is fuzzed. */
77+
const SIBLING = 'SIBLINGVALUE'
6178

6279
type AnyTool = ToolConfig<any, any>
6380

81+
/** Parameters that carry credentials or the host, never a path segment. */
82+
const FIXED_PARAMS: Record<string, unknown> = { accessToken: 'token' }
83+
6484
function isHubSpotTool(value: unknown): value is AnyTool {
6585
return (
6686
typeof value === 'object' &&
@@ -70,112 +90,182 @@ function isHubSpotTool(value: unknown): value is AnyTool {
7090
)
7191
}
7292

93+
/** The placeholder a sibling parameter holds, chosen so the URL still builds. */
94+
function siblingValue(type: string | undefined): unknown {
95+
if (type === 'json' || type === 'array') return []
96+
if (type === 'number') return 1
97+
if (type === 'boolean') return false
98+
return SIBLING
99+
}
100+
73101
/**
74-
* Builds a param object for a tool, filling every declared string param with
75-
* `value` so whichever one reaches the path is exercised.
102+
* Builds a param object with exactly one parameter under test.
103+
*
104+
* Every sibling is pinned to a safe placeholder, so a failure can only be
105+
* attributed to `target`. This is the whole difference from a fill-everything
106+
* sweep, where the first parameter to throw hides all the others.
76107
*/
77-
function buildParams(tool: AnyTool, value: string): Record<string, unknown> {
78-
const params: Record<string, unknown> = { accessToken: 'token' }
108+
function buildParams(tool: AnyTool, target: string, value: string): Record<string, unknown> {
109+
const params: Record<string, unknown> = { ...FIXED_PARAMS }
79110
for (const [name, def] of Object.entries(tool.params ?? {})) {
80-
if (name === 'accessToken') continue
81-
const type = (def as { type?: string }).type
82-
if (type === 'json' || type === 'array') {
83-
params[name] = []
84-
} else if (type === 'number') {
85-
params[name] = 1
86-
} else if (type === 'boolean') {
87-
params[name] = false
88-
} else {
89-
params[name] = value
90-
}
111+
if (name in FIXED_PARAMS) continue
112+
params[name] = name === target ? value : siblingValue((def as { type?: string }).type)
91113
}
92114
return params
93115
}
94116

95-
function buildUrl(tool: AnyTool, value: string): URL {
117+
function buildUrl(tool: AnyTool, target: string, value: string): URL {
96118
const url = tool.request?.url
97119
if (typeof url !== 'function') {
98120
throw new Error(`${tool.id} does not build its URL from params`)
99121
}
100-
return new URL(url(buildParams(tool, value) as any))
122+
return new URL(url(buildParams(tool, target, value) as any))
101123
}
102124

103125
function segmentsOf(pathname: string): string[] {
104126
return pathname.split('/')
105127
}
106128

107-
const DYNAMIC_PATH_TOOLS = Object.values(hubspotTools)
129+
/** True when `target` actually lands in the path rather than the query string. */
130+
function reachesPath(tool: AnyTool, target: string): boolean {
131+
try {
132+
return buildUrl(tool, target, TARGET).pathname.includes(TARGET)
133+
} catch {
134+
return false
135+
}
136+
}
137+
138+
interface PathParamCase {
139+
name: string
140+
param: string
141+
tool: AnyTool
142+
}
143+
144+
const PATH_PARAM_CASES: PathParamCase[] = Object.values(hubspotTools)
108145
.filter(isHubSpotTool)
109146
.filter((tool) => typeof tool.request?.url === 'function')
110-
.filter((tool) => {
111-
try {
112-
return buildUrl(tool, SAFE_ID).pathname.includes(SAFE_ID)
113-
} catch {
114-
return false
115-
}
116-
})
117-
.map((tool) => ({ name: tool.id, tool }))
147+
.flatMap((tool) =>
148+
Object.keys(tool.params ?? {})
149+
.filter((param) => !(param in FIXED_PARAMS))
150+
.filter((param) => reachesPath(tool, param))
151+
.map((param) => ({ name: `${tool.id} / ${param}`, param, tool }))
152+
)
118153

119-
describe('hubspot path-id traversal safety', () => {
120-
it('covers every HubSpot tool that interpolates an id into its path', () => {
121-
expect(DYNAMIC_PATH_TOOLS.length).toBeGreaterThanOrEqual(28)
154+
describe('hubspot path-parameter traversal safety', () => {
155+
it('covers every (hubspot tool, path parameter) pair', () => {
156+
expect(PATH_PARAM_CASES.length).toBeGreaterThanOrEqual(35)
122157
})
123158

124-
describe.each(DYNAMIC_PATH_TOOLS)('$name', ({ tool }) => {
125-
const baseline = segmentsOf(buildUrl(tool, SAFE_ID).pathname)
159+
describe.each(PATH_PARAM_CASES)('$name', ({ tool, param }) => {
160+
const baseline = segmentsOf(buildUrl(tool, param, TARGET).pathname)
126161

127-
it.each(TRAVERSAL_IDS)('cannot reshape the path with %j', (value) => {
162+
it.each(TRAVERSAL_VALUES)('cannot reshape the path with %j', (value) => {
128163
let url: URL
129164
try {
130-
url = buildUrl(tool, value)
165+
url = buildUrl(tool, param, value)
131166
} catch {
132167
return
133168
}
134169

135170
expect(url.origin).toBe('https://api.hubapi.com')
171+
expect(url.pathname.startsWith('/')).toBe(true)
136172

137173
const actual = segmentsOf(url.pathname)
138174
expect(actual).toHaveLength(baseline.length)
139175
baseline.forEach((segment, index) => {
140-
if (segment === SAFE_ID) return
176+
if (segment === TARGET) return
141177
expect(actual[index]).toBe(segment)
142178
})
143179
})
144180

181+
/**
182+
* The shape check above cannot see a bare dot in the *final* segment: `x/.`
183+
* normalizes to `x/`, which keeps the segment count and leaves every other
184+
* segment intact. Rejection is the only observable difference, so assert it
185+
* directly for every parameter rather than only the hand-picked ones below.
186+
*/
187+
it.each(['.', '..'])('rejects the bare %j segment', (value) => {
188+
expect(() => buildUrl(tool, param, value)).toThrow(/path traversal/)
189+
})
190+
145191
it.each(LEGITIMATE_IDS)('passes %j through unchanged', (value) => {
146-
const actual = segmentsOf(buildUrl(tool, value).pathname)
192+
const actual = segmentsOf(buildUrl(tool, param, value).pathname)
147193

148194
expect(actual).toHaveLength(baseline.length)
149195
baseline.forEach((segment, index) => {
150-
expect(actual[index]).toBe(segment === SAFE_ID ? value : segment)
196+
expect(actual[index]).toBe(segment === TARGET ? value : segment)
151197
})
152198
})
153199
})
154200
})
155201

156-
const HIGH_RISK_TOOLS: ReadonlyArray<{ name: string; tool: AnyTool }> = [
157-
{ name: 'hubspot_delete_contact', tool: hubspotDeleteContactTool },
158-
{ name: 'hubspot_delete_company', tool: hubspotDeleteCompanyTool },
159-
{ name: 'hubspot_delete_deal', tool: hubspotDeleteDealTool },
160-
{ name: 'hubspot_delete_association', tool: hubspotDeleteAssociationTool },
202+
const HIGH_RISK_CASES: ReadonlyArray<{ name: string; tool: AnyTool; param: string }> = [
203+
{ name: 'hubspot_delete_contact', tool: hubspotDeleteContactTool, param: 'contactId' },
204+
{ name: 'hubspot_delete_company', tool: hubspotDeleteCompanyTool, param: 'companyId' },
205+
{ name: 'hubspot_delete_deal', tool: hubspotDeleteDealTool, param: 'dealId' },
206+
{ name: 'hubspot_delete_association', tool: hubspotDeleteAssociationTool, param: 'objectId' },
161207
]
162208

163-
describe.each(HIGH_RISK_TOOLS)('$name id path safety', ({ tool }) => {
209+
describe.each(HIGH_RISK_CASES)('$name path safety', ({ tool, param }) => {
164210
it('rejects a bare dot-dot segment instead of silently popping the resource', () => {
165-
expect(() => buildUrl(tool, '..')).toThrow(/path traversal/)
211+
expect(() => buildUrl(tool, param, '..')).toThrow(/path traversal/)
166212
})
167213

168214
it('rejects a bare dot segment', () => {
169-
expect(() => buildUrl(tool, '.')).toThrow(/path traversal/)
215+
expect(() => buildUrl(tool, param, '.')).toThrow(/path traversal/)
170216
})
171217

172-
it('does not let the id inject query parameters', () => {
173-
const url = buildUrl(tool, '12345?properties=email')
218+
it('does not let the value inject query parameters', () => {
219+
const url = buildUrl(tool, param, '12345?properties=email')
174220

175221
expect(url.searchParams.get('properties')).toBeNull()
176222
})
177223

178-
it('preserves a legitimate numeric id verbatim after trimming', () => {
179-
expect(buildUrl(tool, ' 12345 ').pathname).toContain('/12345')
224+
it('preserves a legitimate value verbatim after trimming', () => {
225+
expect(buildUrl(tool, param, ' 12345 ').pathname).toContain(`/${'12345'}`)
226+
})
227+
})
228+
229+
/**
230+
* The regression this file exists to prevent. A fill-everything sweep reported
231+
* these tools as covered while only the *first* of their four path parameters
232+
* was ever reached, so reverting any of the other three produced no failure.
233+
*/
234+
describe('association tools expose every path parameter separately', () => {
235+
const ASSOCIATION_TOOLS = [
236+
{
237+
name: 'hubspot_create_association',
238+
tool: hubspotCreateAssociationTool,
239+
params: ['objectType', 'objectId', 'toObjectType', 'toObjectId'],
240+
},
241+
{
242+
name: 'hubspot_delete_association',
243+
tool: hubspotDeleteAssociationTool,
244+
params: ['objectType', 'objectId', 'toObjectType', 'toObjectId'],
245+
},
246+
{
247+
name: 'hubspot_list_associations',
248+
tool: hubspotListAssociationsTool,
249+
params: ['objectType', 'objectId', 'toObjectType'],
250+
},
251+
] as const
252+
253+
it.each(ASSOCIATION_TOOLS)(
254+
'$name contributes every path parameter it interpolates',
255+
({ tool, params }) => {
256+
const covered = PATH_PARAM_CASES.filter((entry) => entry.tool === tool).map(
257+
(entry) => entry.param
258+
)
259+
260+
expect([...covered].sort()).toEqual([...params].sort())
261+
}
262+
)
263+
264+
it.each(
265+
ASSOCIATION_TOOLS.flatMap(({ name, tool, params }) =>
266+
params.map((param) => ({ name, tool, param }))
267+
)
268+
)('$name rejects a dot-dot segment in $param', ({ tool, param }) => {
269+
expect(() => buildUrl(tool, param, '..')).toThrow(/path traversal/)
180270
})
181271
})

0 commit comments

Comments
 (0)