fix(security): list items need write access to their list (FLAW-27)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
Creating, checking, editing, deleting, reordering and clearing list items did not check the list at all: any logged-in user with a list or item ID could change it, viewers of shared lists as well. All item endpoints and converting a thought into a list item now require owner or editor rights and that the item belongs to the list (404 without access, 403 for viewers). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11c14fa9ff
commit
3900c6104e
5 files changed
+125
No files matched your search
@@ -500,3 +500,48 @@ func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderIt
|
||||
_, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID)
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ErrListNotFound: the list does not exist or the user has no access to it (deliberately the same).
|
||||
var ErrListNotFound = errors.New("Liste nicht gefunden")
|
||||
|
||||
// ErrListReadOnly: the list is shared with the user as viewer.
|
||||
var ErrListReadOnly = errors.New("Diese Liste ist für dich nur lesbar")
|
||||
|
||||
// ListRole is the user's role in a list: owner, editor, viewer or "" without access.
|
||||
func (s *Store) ListRole(userID, listID string) string {
|
||||
var role string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT CASE WHEN l.user_id = ? THEN 'owner'
|
||||
WHEN MAX(CASE WHEN ls.role = 'editor' THEN 1 ELSE 0 END) = 1 THEN 'editor'
|
||||
ELSE 'viewer' END
|
||||
FROM lists l
|
||||
LEFT JOIN list_shares ls ON ls.list_id = l.id AND (
|
||||
(ls.target_type = 'user' AND ls.target_id = ?)
|
||||
OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
)
|
||||
WHERE l.id = ? AND (l.user_id = ? OR ls.id IS NOT NULL)
|
||||
GROUP BY l.id`, userID, userID, userID, listID, userID).Scan(&role)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// EnsureListItemsWritable checks that the user may change the items of a list (owner or editor) and,
|
||||
// if itemID is given, that the item belongs to that list.
|
||||
func (s *Store) EnsureListItemsWritable(userID, listID, itemID string) error {
|
||||
switch s.ListRole(userID, listID) {
|
||||
case "":
|
||||
return ErrListNotFound
|
||||
case "viewer":
|
||||
return ErrListReadOnly
|
||||
}
|
||||
if itemID != "" {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE id = ? AND list_id = ?`, itemID, listID).Scan(&n)
|
||||
if n == 0 {
|
||||
return ErrListNotFound
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -375,6 +375,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
convertedRefID = doc.ID
|
||||
}
|
||||
} else if convertTo == "list_item" && targetListID != "" {
|
||||
// Same rule as adding an item in the list itself: owner or editor
|
||||
if err := s.EnsureListItemsWritable(userID, targetListID, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newStatus = domain.StatusConverted
|
||||
convertedType = "list_item"
|
||||
item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID)
|
||||
|
||||
@@ -169,3 +169,38 @@ func TestDataSurvivesRestart(t *testing.T) {
|
||||
t.Fatalf("user_version = %d, want %d", v, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListItemsNeedWriteAccess(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "listacl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
openSharing(t, store)
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
eve, _ := store.CreateUser("eve@example.com", "hash", "Eve", "member")
|
||||
list, _ := store.CreateList(anna.ID, "Einkauf", "", "shopping")
|
||||
item, _ := store.CreateListItem(list.ID, "Milch", "", "", 0, anna.ID)
|
||||
other, _ := store.CreateList(eve.ID, "Eves Liste", "", "shopping")
|
||||
otherItem, _ := store.CreateListItem(other.ID, "Geheim", "", "", 0, eve.ID)
|
||||
_, _ = store.AddListShare(anna.ID, list.ID, "user", ben.ID, "viewer")
|
||||
|
||||
if err := store.EnsureListItemsWritable(anna.ID, list.ID, item.ID); err != nil {
|
||||
t.Errorf("owner must write: %v", err)
|
||||
}
|
||||
if err := store.EnsureListItemsWritable(ben.ID, list.ID, item.ID); err != ErrListReadOnly {
|
||||
t.Errorf("viewer must be read-only, got %v", err)
|
||||
}
|
||||
if err := store.EnsureListItemsWritable(eve.ID, list.ID, ""); err != ErrListNotFound {
|
||||
t.Errorf("stranger must not see the list, got %v", err)
|
||||
}
|
||||
if err := store.EnsureListItemsWritable(anna.ID, list.ID, otherItem.ID); err != ErrListNotFound {
|
||||
t.Errorf("an item of another list must not be reachable through my list, got %v", err)
|
||||
}
|
||||
// Turning a thought into an item of someone else's list is refused as well
|
||||
th, _ := store.CreateThoughtWithDetails(eve.ID, "Spam", "normal", "manual", "", "", "todo", "")
|
||||
if _, err := store.UpdateThoughtTriage(eve.ID, th.ID, "keep", "list_item", list.ID, "", ""); err == nil {
|
||||
t.Errorf("thought must not become an item of a list without write access")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user