@@ -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+ } )
0 commit comments