Skip to content

Commit 280113b

Browse files
authored
Merge pull request #17 from Paca-AI/fix/jsonbody-tinygo-collision
Fix cross-project checklist item ownership check (IDOR)
2 parents 34dd3e8 + 5389969 commit 280113b

3 files changed

Lines changed: 130 additions & 6 deletions

File tree

‎backend/items.go‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ func (p *checklistPlugin) updateItem(req *plugin.Request, res *plugin.Response)
116116
if !p.taskBelongsToProject(taskID, projectID, res) {
117117
return
118118
}
119+
if !p.checklistOwnedByTask(checklistID, taskID, res) {
120+
return
121+
}
119122

120123
type updateItemBody struct {
121124
Title *string `json:"title"`
@@ -133,11 +136,14 @@ func (p *checklistPlugin) updateItem(req *plugin.Request, res *plugin.Response)
133136
return
134137
}
135138

136-
// Fetch current state so we can preserve unpatched fields.
139+
// Fetch current state so we can preserve unpatched fields. Scoped by
140+
// checklist_id too (not just id) so an itemID belonging to a different
141+
// checklist/task/project 404s here instead of being adopted into this
142+
// checklist by the re-INSERT below.
137143
cur, curErr := p.db.Query(
138144
`SELECT id, checklist_id, title, is_checked, assignee_id, position, created_by, created_at, updated_at
139-
FROM task_checklist_items WHERE id = $1`,
140-
itemID,
145+
FROM task_checklist_items WHERE id = $1 AND checklist_id = $2`,
146+
itemID, checklistID,
141147
)
142148
if curErr != nil {
143149
p.log.Error("updateItem fetch: " + curErr.Error())
@@ -169,9 +175,10 @@ func (p *checklistPlugin) updateItem(req *plugin.Request, res *plugin.Response)
169175
}
170176

171177
// Simulate UPDATE as DELETE + re-INSERT. task_checklist_items has no child
172-
// FK references, so this is safe in both tests and production.
178+
// FK references, so this is safe in both tests and production. Scoped by
179+
// checklist_id as defense-in-depth, matching the fetch above.
173180
if _, err = p.db.Exec(
174-
`DELETE FROM task_checklist_items WHERE id = $1`, itemID,
181+
`DELETE FROM task_checklist_items WHERE id = $1 AND checklist_id = $2`, itemID, checklistID,
175182
); err != nil {
176183
p.log.Error("updateItem delete: " + err.Error())
177184
res.Error(500, "failed to update item")
@@ -237,6 +244,9 @@ func (p *checklistPlugin) deleteItem(req *plugin.Request, res *plugin.Response)
237244
if !p.taskBelongsToProject(taskID, projectID, res) {
238245
return
239246
}
247+
if !p.checklistOwnedByTask(checklistID, taskID, res) {
248+
return
249+
}
240250

241251
// Fetch item title before deletion for activity record.
242252
titleResult, err := p.db.Query(

‎backend/plugin_test.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

‎plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "com.paca.checklist",
33
"displayName": "Checklist",
44
"description": "Adds named checklists with checkable items to tasks.",
5-
"version": "0.2.11",
5+
"version": "0.2.12",
66
"minCoreVersion": "v0.13.3",
77
"permissions": ["db.read", "db.write", "events.subscribe"],
88
"backend": {

0 commit comments

Comments
 (0)