Skip to content

Commit 202416a

Browse files
committed
fix(jira): guard the add-attachment route, reject orderBy against its enum
- add-attachment interpolated cloudId and issueKey raw into a POST that carries the user's OAuth token; both siblings already validate them - orderBy was user-or-llm with no enum check, so '-created&maxResults=5000' appended arbitrary query params; reject against the documented enum rather than encode, since '+created' has no verifiable decode guarantee - remove_watcher sent '?accountId=' instead of failing on a missing id - cover the 20 transformResponse rebuild sites, which had none, and replace the count ratchet with an exact tool-name list
1 parent 8545946 commit 202416a

4 files changed

Lines changed: 207 additions & 59 deletions

File tree

apps/sim/app/api/tools/jira/add-attachment/route.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ import { POST } from '@/app/api/tools/jira/add-attachment/route'
5050
const CLOUD_ID = '1324a887-45db-1bf4-1e99-ef0ff456d421'
5151
const ORIGIN = 'https://api.atlassian.com'
5252

53-
const FILE = { key: 'workspace/u1/report.pdf', name: 'report.pdf', size: 12, type: 'application/pdf' }
53+
const FILE = {
54+
key: 'workspace/u1/report.pdf',
55+
name: 'report.pdf',
56+
size: 12,
57+
type: 'application/pdf',
58+
}
5459

5560
function body(overrides: Record<string, unknown> = {}) {
5661
return {
@@ -141,9 +146,7 @@ describe('POST /api/tools/jira/add-attachment path safety', () => {
141146

142147
/** cloudId sits earlier in the path, so it is validated first, like the siblings. */
143148
it('reports cloudId before issueKey when both are hostile', async () => {
144-
const response = await POST(
145-
createMockRequest('POST', body({ cloudId: '..', issueKey: '..' }))
146-
)
149+
const response = await POST(createMockRequest('POST', body({ cloudId: '..', issueKey: '..' })))
147150

148151
expect(response.status).toBe(400)
149152
expect((await response.json()).error).toMatch(/cloudId/)

apps/sim/tools/jira/__probe2.test.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

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

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,196 @@ describe('Jira path traversal safety', () => {
260260
})
261261
})
262262
})
263+
264+
/**
265+
* The second, invisible class of guarded site.
266+
*
267+
* Every Jira tool builds its provider URL twice: once in `request.url`, and
268+
* again inside `transformResponse` for the branch that first has to discover
269+
* the cloud ID from the domain. A reflective probe of `request.url` — which is
270+
* all the suite above does — cannot see the rebuild, so a guard removed from
271+
* one of them would have gone unnoticed, and `jira_bulk_read`, whose only
272+
* guarded site lives in `transformResponse`, was absent from the suite
273+
* entirely.
274+
*
275+
* These tests drive the real `transformResponse` with `cloudId` unset, record
276+
* every URL it fetches, and apply the same three assertion families to the
277+
* recorded URLs.
278+
*/
279+
const CLOUD_ID = '1324a887-45db-1bf4-1e99-ef0ff456d421'
280+
281+
const { mockResolveAtlassianCloudId } = vi.hoisted(() => ({
282+
mockResolveAtlassianCloudId: vi.fn(),
283+
}))
284+
285+
vi.mock('@/lib/atlassian/discovery', () => ({
286+
resolveAtlassianCloudId: mockResolveAtlassianCloudId,
287+
selectAtlassianCloudId: () => CLOUD_ID,
288+
}))
289+
290+
/**
291+
* `jira_bulk_read` resolves its cloud ID from the dispatcher's own discovery
292+
* response rather than from a second lookup, so it needs the id supplied.
293+
* Every other tool must have it unset to reach its rebuild branch.
294+
*/
295+
const REBUILD_CLOUD_ID: Record<string, string | undefined> = {
296+
jira_bulk_read: CLOUD_ID,
297+
}
298+
299+
/** Tools whose `transformResponse` rebuilds a guarded provider path. */
300+
const EXPECTED_REBUILD_TOOLS = [
301+
'jira_add_comment',
302+
'jira_add_watcher',
303+
'jira_add_worklog',
304+
'jira_assign_issue',
305+
'jira_bulk_read',
306+
'jira_delete_attachment',
307+
'jira_delete_comment',
308+
'jira_delete_issue',
309+
'jira_delete_issue_link',
310+
'jira_delete_worklog',
311+
'jira_get_attachments',
312+
'jira_get_comments',
313+
'jira_get_project',
314+
'jira_get_transitions',
315+
'jira_get_worklogs',
316+
'jira_remove_watcher',
317+
'jira_retrieve',
318+
'jira_transition_issue',
319+
'jira_update_comment',
320+
'jira_update_worklog',
321+
]
322+
323+
interface RebuildResult {
324+
urls: URL[]
325+
error?: Error
326+
}
327+
328+
/** A permissive payload every tool's response reader tolerates. */
329+
function fakeJson() {
330+
return {
331+
comments: [],
332+
worklogs: [],
333+
transitions: [],
334+
issues: [],
335+
values: [],
336+
fields: {},
337+
id: '1',
338+
key: 'PROJ',
339+
}
340+
}
341+
342+
function fakeResponse() {
343+
return {
344+
ok: true,
345+
status: 200,
346+
statusText: 'OK',
347+
text: async () => '{}',
348+
json: async () => fakeJson(),
349+
} as unknown as Response
350+
}
351+
352+
async function runRebuild(
353+
tool: AnyTool,
354+
overrides: Record<string, string> = {}
355+
): Promise<RebuildResult> {
356+
const recorded: string[] = []
357+
vi.stubGlobal(
358+
'fetch',
359+
vi.fn(async (input: unknown) => {
360+
recorded.push(String(input))
361+
return fakeResponse()
362+
})
363+
)
364+
mockResolveAtlassianCloudId.mockResolvedValue(CLOUD_ID)
365+
366+
const params = buildParams(tool, overrides)
367+
params.cloudId = REBUILD_CLOUD_ID[tool.id]
368+
369+
let error: Error | undefined
370+
try {
371+
await tool.transformResponse!(fakeResponse(), params)
372+
} catch (caught) {
373+
error = caught as Error
374+
}
375+
376+
return {
377+
urls: recorded
378+
.filter((url) => url.startsWith(`${ORIGIN}${PATH_PREFIX}`))
379+
.map((url) => new URL(url)),
380+
error,
381+
}
382+
}
383+
384+
/** Params whose probe token lands in the path of at least one rebuilt URL. */
385+
function rebuildPathParamsOf(tool: AnyTool, urls: URL[]): string[] {
386+
return Object.entries(tool.params ?? {})
387+
.filter(([, def]) => {
388+
const { type, visibility } = def as { type?: string; visibility?: string }
389+
return visibility === 'user-or-llm' && (type === undefined || type === 'string')
390+
})
391+
.map(([name]) => name)
392+
.filter((name) =>
393+
urls.some(
394+
(url) => url.pathname.includes(tokenFor(name)) && !url.search.includes(tokenFor(name))
395+
)
396+
)
397+
}
398+
399+
describe('Jira transformResponse rebuild safety', () => {
400+
beforeEach(() => {
401+
vi.clearAllMocks()
402+
})
403+
404+
it('covers exactly the tools that rebuild a guarded path', async () => {
405+
const found: string[] = []
406+
for (const tool of ALL_TOOLS) {
407+
if (typeof tool.transformResponse !== 'function') continue
408+
const { urls } = await runRebuild(tool)
409+
if (rebuildPathParamsOf(tool, urls).length > 0) found.push(tool.id)
410+
}
411+
412+
expect(found.sort()).toEqual(EXPECTED_REBUILD_TOOLS)
413+
})
414+
415+
describe.each(EXPECTED_REBUILD_TOOLS)('%s', (id) => {
416+
const tool = ALL_TOOLS.find((candidate) => candidate.id === id)!
417+
418+
it('guards every identifier it interpolates into a rebuilt path', async () => {
419+
const baseline = await runRebuild(tool)
420+
expect(baseline.error).toBeUndefined()
421+
expect(baseline.urls.length).toBeGreaterThan(0)
422+
423+
const pathParams = rebuildPathParamsOf(tool, baseline.urls)
424+
expect(pathParams.length).toBeGreaterThan(0)
425+
426+
for (const param of pathParams) {
427+
for (const value of REJECTED) {
428+
const { urls, error } = await runRebuild(tool, { [param]: value })
429+
expect(error?.message).toMatch(param)
430+
expect(urls).toEqual([])
431+
}
432+
433+
for (const value of NEUTRALIZED) {
434+
const { urls } = await runRebuild(tool, { [param]: value })
435+
expect(urls).toHaveLength(baseline.urls.length)
436+
urls.forEach((url, index) => {
437+
expectSameShapeAgainst(baseline.urls[index], param, url)
438+
expect(url.searchParams.get('foo')).toBeNull()
439+
})
440+
}
441+
442+
for (const value of LEGITIMATE) {
443+
const { urls } = await runRebuild(tool, { [param]: value })
444+
expect(urls).toHaveLength(baseline.urls.length)
445+
urls.forEach((url, index) => {
446+
const expected = segmentsOf(baseline.urls[index]).map((segment) =>
447+
segment.replace(tokenFor(param), encodeURIComponent(value.trim()))
448+
)
449+
expect(segmentsOf(url)).toEqual(expected)
450+
})
451+
}
452+
}
453+
})
454+
})
455+
})

apps/sim/tools/jira/query_safety.test.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,12 @@ describe('jira_get_comments orderBy', () => {
8787
expect(url.searchParams.get('orderBy')).toBe('-created')
8888
})
8989

90-
it.each([
91-
'-created&maxResults=5000',
92-
'created&expand=renderedBody',
93-
'-created#',
94-
'updated',
95-
])('rejects orderBy=%j instead of appending it to the query', (orderBy) => {
96-
expect(() => buildRequestUrl({ orderBy })).toThrow(/orderBy/)
97-
})
90+
it.each(['-created&maxResults=5000', 'created&expand=renderedBody', '-created#', 'updated'])(
91+
'rejects orderBy=%j instead of appending it to the query',
92+
(orderBy) => {
93+
expect(() => buildRequestUrl({ orderBy })).toThrow(/orderBy/)
94+
}
95+
)
9896

9997
/**
10098
* An empty orderBy previously produced a bare `orderBy=`, which is not in
@@ -177,8 +175,6 @@ describe('jira_remove_watcher accountId', () => {
177175
params({ cloudId: undefined })
178176
)
179177

180-
expect(fetchedUrls()).toEqual([
181-
`${ISSUE_BASE}/watchers?accountId=5b10ac8d82e05b22cc7d4ef5`,
182-
])
178+
expect(fetchedUrls()).toEqual([`${ISSUE_BASE}/watchers?accountId=5b10ac8d82e05b22cc7d4ef5`])
183179
})
184180
})

0 commit comments

Comments
 (0)