@@ -425,3 +425,117 @@ func TestListChecklists_UnknownTask(t *testing.T) {
425425 t .Fatalf ("expected 404, got %d" , res .StatusCode )
426426 }
427427}
428+
429+ // ── Cross-project ownership guards ───────────────────────────────────────────
430+
431+ const (
432+ otherProjectID = "project-2"
433+ otherTaskID = "task-2"
434+ )
435+
436+ // otherProjectCallerReq mints a foreign checklist/item under otherTaskID,
437+ // which genuinely belongs to otherProjectID — used to set up the victim
438+ // resource for the cross-project tests below.
439+ func otherProjectCallerReq () plugintest.Request {
440+ return plugintest.Request {
441+ Caller : plugin.CallerIdentity {
442+ ProjectID : otherProjectID ,
443+ CallerID : "member-2" ,
444+ CallerRole : "PROJECT_MEMBER" ,
445+ },
446+ PathParams : map [string ]string {},
447+ }
448+ }
449+
450+ // setupForeignItem re-seeds the tasks table with both the default
451+ // project-1/task-1 pair and a second, genuinely unrelated project-2/task-2
452+ // pair, then creates a real checklist + item under task-2 as a project-2
453+ // caller. Returns the foreign checklist and item IDs.
454+ func setupForeignItem (t * testing.T , tc * plugintest.Context ) (foreignChecklistID , foreignItemID string ) {
455+ t .Helper ()
456+ tc .DB .SeedRows ("tasks" , []string {"id" , "project_id" , "deleted_at" }, [][]any {
457+ {testTaskID , testProjectID , nil },
458+ {otherTaskID , otherProjectID , nil },
459+ })
460+
461+ clRes := tc .Call ("POST" , "/tasks/:taskId/checklists" ,
462+ withPathParams (otherProjectCallerReq (), map [string ]string {"taskId" : otherTaskID }).
463+ WithJSONBody (map [string ]string {"title" : "Someone else's checklist" }))
464+ var clEnv struct {
465+ Data checklist `json:"data"`
466+ }
467+ if err := json .Unmarshal (clRes .Body , & clEnv ); err != nil {
468+ t .Fatalf ("failed to create foreign checklist: %s" , clRes .BodyString ())
469+ }
470+
471+ itemRes := tc .Call ("POST" , "/tasks/:taskId/checklists/:checklistId/items" ,
472+ withPathParams (otherProjectCallerReq (), map [string ]string {
473+ "taskId" : otherTaskID ,
474+ "checklistId" : clEnv .Data .ID ,
475+ }).WithJSONBody (map [string ]string {"title" : "Someone else's item" }))
476+ var itemEnv struct {
477+ Data checklistItem `json:"data"`
478+ }
479+ if err := json .Unmarshal (itemRes .Body , & itemEnv ); err != nil {
480+ t .Fatalf ("failed to create foreign item: %s" , itemRes .BodyString ())
481+ }
482+ return clEnv .Data .ID , itemEnv .Data .ID
483+ }
484+
485+ // TestUpdateItem_CrossProjectChecklistRejected pins the fix for a real IDOR:
486+ // updateItem previously fetched/deleted/re-inserted the target item by bare
487+ // id, with no check that the checklist in the URL actually owns it — a
488+ // caller with tasks.write on their own project (testTaskID/testProjectID,
489+ // which legitimately passes taskBelongsToProject) could hijack and
490+ // reparent an arbitrary item from a checklist belonging to a completely
491+ // different project, just by knowing its UUID.
492+ func TestUpdateItem_CrossProjectChecklistRejected (t * testing.T ) {
493+ tc := setupPlugin (t )
494+ foreignChecklistID , foreignItemID := setupForeignItem (t , tc )
495+
496+ res := tc .Call ("PATCH" , "/tasks/:taskId/checklists/:checklistId/items/:itemId" ,
497+ withPathParams (callerReq (), map [string ]string {
498+ "taskId" : testTaskID , // caller's own, legitimate task
499+ "checklistId" : foreignChecklistID ,
500+ "itemId" : foreignItemID ,
501+ }).WithJSONBody (map [string ]any {"title" : "hijacked" }))
502+ if res .StatusCode != 404 {
503+ t .Fatalf ("expected 404 (checklist belongs to a different task/project), got %d: %s" , res .StatusCode , res .BodyString ())
504+ }
505+
506+ // The foreign item must be completely untouched.
507+ rows := tc .DB .AllRows ("task_checklist_items" )
508+ for _ , row := range rows {
509+ if row [0 ] == foreignItemID && row [2 ] == "hijacked" {
510+ t .Fatal ("foreign item was modified despite the 404" )
511+ }
512+ }
513+ }
514+
515+ // TestDeleteItem_CrossProjectChecklistRejected mirrors the update case for
516+ // delete: deleteItem checked taskBelongsToProject but never verified the
517+ // URL's checklistId actually belongs to that task.
518+ func TestDeleteItem_CrossProjectChecklistRejected (t * testing.T ) {
519+ tc := setupPlugin (t )
520+ foreignChecklistID , foreignItemID := setupForeignItem (t , tc )
521+
522+ res := tc .Call ("DELETE" , "/tasks/:taskId/checklists/:checklistId/items/:itemId" ,
523+ withPathParams (callerReq (), map [string ]string {
524+ "taskId" : testTaskID ,
525+ "checklistId" : foreignChecklistID ,
526+ "itemId" : foreignItemID ,
527+ }))
528+ if res .StatusCode != 404 {
529+ t .Fatalf ("expected 404 (checklist belongs to a different task/project), got %d: %s" , res .StatusCode , res .BodyString ())
530+ }
531+
532+ found := false
533+ for _ , row := range tc .DB .AllRows ("task_checklist_items" ) {
534+ if row [0 ] == foreignItemID {
535+ found = true
536+ }
537+ }
538+ if ! found {
539+ t .Fatal ("foreign item was deleted despite the 404" )
540+ }
541+ }
0 commit comments