Skip to content

Label with zero task attachments is permanently unattachable by anyone but its creator, even with full tasks_labels:create API token scope #3592

Description

@pacewicz

Summary

LabelTask.CanCreate calls Label.hasAccessToLabel (pkg/models/label_permissions.go). For a non-owner, that function grants access only if the label already has a live attachment on a task in a project the caller can reach. A label with zero attachments has no such row, so nobody but its created_by user can ever attach it. No API token scope changes that.

This comes from the fix for GHSA-hj5c-mhh2-g7jq (#2578), which restricted label use to labels the caller already has an access path to. The problem: an existing attachment is the only access path a non-owner gets, and nothing creates the first one. GET /labels doesn't apply this check at all, so a client can list a label, hold a token scoped to create it on tasks, and still get a flat 403 the first time it tries.

Why normal use never hits this

The web UI creates and attaches a label in one action: type a new name in a task's label picker, and the same request both creates the label and attaches it to that task, as the same user. That satisfies the bootstrap condition immediately.

The gap opens only when two different identities are involved: one creates the label ahead of time (an admin seeding a taxonomy), and another is first to attach it (a teammate, or here, a bot user on a scoped API token). That's the shape of any multi-agent setup built on the bot-user + scoped-token API (PUT /user/bots, PUT /tokens with owner_id in the body, PUT /projects/{id}/users).

Repro

  1. As admin/JWT, create a label with no attachments:
    PUT /api/v1/labels {"title":"test-label"}   -> {"id": N, ...}
    
  2. Create a bot user and a scoped token with label permissions, share a project with it:
    PUT /api/v1/user/bots {"username":"bot-x","name":"Bot X"}
    PUT /api/v1/tokens {"permissions":{"tasks_labels":["create","read_all","update_bulk"], "tasks":["create","update","read_all","read_one"], ...}, "owner_id":<botid>, "expires_at":"..."}
    PUT /api/v1/projects/{id}/users {"username":"bot-x","permission":1}
    
  3. As the bot token, create a task in that project and attach label N:
    PUT /api/v1/projects/{id}/tasks {"title":"probe"}          -> 201, task id T
    PUT /api/v1/tasks/{T}/labels {"label_id": N}                -> 403 {"code":0,"message":"Forbidden"}
    
    This 403s on every attempt, on every new task.
  4. As admin, attach label N to any one task, once:
    PUT /api/v1/tasks/{any_task}/labels {"label_id": N}          -> 201
    
  5. Repeat step 3 with the bot token. It now succeeds, and keeps succeeding on any future task, for as long as at least one live attachment of label N survives in a project the bot can reach.
  6. For contrast, GET /api/v1/labels on the bot token already listed label N in step 3, before step 4 ran.

Root cause (source read on main)

pkg/models/label_task_permissions.go:

func (lt *LabelTask) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
	label, err := getLabelByIDSimple(s, lt.LabelID)
	...
	hasAccessTolabel, _, err := label.hasAccessToLabel(s, a)
	if err != nil || !hasAccessTolabel {
		return false, err
	}
	canDoLabelTask, err := canDoLabelTask(s, lt.TaskID, a)
	...
	return hasAccessTolabel && canDoLabelTask, nil
}

pkg/models/label.go, hasAccessToLabel:

accessBranches := []builder.Cond{labelAttachedToAccessibleTask}
if !isLinkShare {
	accessBranches = append(accessBranches,
		builder.Eq{"labels.created_by_id": a.GetID()},
		builder.In("labels.created_by_id",
			builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": a.GetID()}),
		),
	)
}

labelAttachedToAccessibleTask needs an existing label_tasks row joined to an accessible, non-deleted task. With zero attachments that branch is always false. The other two branches cover "you created it" and "its creator is a bot you own." Neither covers "I have write access to a project that shares this label with me by any other route."

#2876 added the bot-owner branch, but for the other direction: a human reading labels their own bot created. It doesn't touch this case, where a human creates the label and a bot is first to use it.

Fix directions

  • Let the label creator's project access transitively grant first-use rights to anyone they've shared that project with. Changes the security model more than the other options.
  • Split the check: leave read/rename/delete owner-only, but on the attach path (LabelTask.CanCreate), allow it whenever the caller has write access to the target task's project, regardless of attachment history. This matches what GET /labels already exposes, without reopening the original leak, which was about reading a label's task associations across projects the caller doesn't own, not about attaching an already-visible label to a task the caller can already write to.
  • Short of a permission change, document the trap: a label seeded ahead of a bot's or teammate's first use needs one manual attachment first, or every non-owner attach 403s indefinitely with no signal that the cause is "this label has never been used."

Environment

  • Vikunja 2.5.0, self-hosted LXC, SQLite backend
  • Reproduced via the bot-user + scoped-token API (/user/bots, owner_id-in-body /tokens), not the web UI
  • Checked against go-vikunja/vikunja@main (pkg/models/label_task_permissions.go, pkg/models/label.go); the code path is unchanged there, so this isn't specific to 2.5.0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-processedFix agent has picked this uparea/api-tokensPersonal API tokens, token scopes, bot/service accountsarea/labelsTask labels and label management (the product feature)area/permissionsSharing, link sharing, roles, access control, assignee roles

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions