CVE-2026-111919: off the project, still on the webhook
I found that a collaborator removed from a Vikunja project went on receiving every new task through the webhook they had created while still a member. Vikunja already had a routine that cleaned up after someone lost access, deleting their task assignments and subscriptions, but its list left out webhooks, and only one of the four ways to remove someone ever called it.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it ran against the released
vikunja/vikunja:2.6.0Docker image on my own machine, with test accounts I created for the purpose. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed release and the CVE record were public.
Taking someone off a project is two jobs. The first is to stop them reading it, and every access check in the application does that the moment the membership row is gone. The second is to clean up what they built while they were in: anything that keeps working on their behalf after they have left. That second job has no request to hang an access check on, so it has to be done on purpose, by code that knows which things to look for.
Vikunja, the self-hosted task manager, had written that code. It had the right idea and the wrong list.
What Vikunja already cleaned up
In October 2025 the project added cleanupTaskMembersAfterTeamRemoval to pkg/models/teams.go. The commit, 9358954c9, states the rule in its title:
“fix: cleanup team memberships, assignments and subscriptions when users lose access to a project”
The routine does exactly that. For each project the departing user can no longer read, tested with a live project.CanRead, it deletes their task assignments and their subscriptions:
_, err = s.In("task_id", taskIDs).And("user_id = ?", memberID).
Delete(&TaskAssginee{})
_, err = s.In("entity_id", taskIDs).
Where("entity_type = ? AND user_id = ?", SubscriptionEntityTask, memberID).
Delete(&Subscription{})
_, err = s.In("entity_id", projectsToCleanup).
Where("entity_type = ? AND user_id = ?", SubscriptionEntityProject, memberID).
Delete(&Subscription{})
So the decision had already been made: losing read access must delete the rows the user left behind. My report did not have to argue for that principle. It only had to point at two rows the list did not include.
The two rows that keep talking
A Vikunja project can carry two things a member creates that outlive any session:
- a webhook, which posts task events, titles and descriptions included, to a URL of the creator’s choosing;
- a link share, a token that opens the project to whoever holds it.
Neither was in the cleanup set. They are also the only two rows on the project that carry a live channel out of it. An orphaned task assignment is clutter. An orphaned webhook is a feed.
One caller
The second gap was sharper than the first. The routine had a single caller, a listener on the team-member-removed event (pkg/models/listeners.go:1642 at v2.6.0). Vikunja has four ways to take someone’s access away:
pkg/models/project_team.go:151 TeamProject.Delete unshare the project from a team
pkg/models/project_users.go:141 ProjectUser.Delete remove a user from the project
pkg/models/project_users.go:248 ProjectUser.Update downgrade a user's permission
pkg/models/team_members.go:91 TeamMember.Delete remove a user from a team <- the only one wired up
The ordinary operation, removing a person directly from a project, dispatched nothing at all. project_users.go did not mention cleanup once.
The run where the cleanup did fire
I could have shown this on ProjectUser.Delete, where nothing runs and the webhook trivially survives. That would have been the easier demonstration and the weaker one, because it leaves room to say the cleanup just needed wiring up. So the reproduction used the one path where Vikunja’s own routine does fire, and checked that it fired.
Before any of that, the harness reproduced a known, already-fixed Vikunja advisory, to show it could tell a fixed behaviour from a broken one. Then the owner removed the collaborator from the team that gave them access:
== OWNER removes collab from the TEAM ==
-- did the cleanup routine run? --
assignees now: [] <- it ran
collab direct read of task: HTTP 403 <- access is gone
-- webhook still delivering? --
WEBHOOK RECEIVED task_title='post-team-removal secret'
task_desc='cleanup ran, webhook did not'
The two lines in the middle are the controls. The routine ran, because the assignment rows were gone. The collaborator’s own reads were refused, because access really was revoked. The task in the webhook delivery was created after both of those, and its title and description still reached the URL the former collaborator had registered while they were a member.
The link share created by the same collaborator was also still redeemable after removal, and it allowed writes as well as reads.
One lab detail, stated in the report too: I enabled delivery to non-routable addresses so the webhook could reach a receiver on loopback. A real former collaborator would register a public URL, so this changes nothing about reachability.
I ran this on v2.6.0 only. For earlier releases I read the source at every tag instead. Project webhooks first shipped in v0.22.0, and the cleanup routine arrived in v1.0.0 without them. That gave the range >= 0.22.0, <= 2.6.0.
What the owner could still see
I wrote the weak part of the case into the report rather than wait for it to come back. The webhook is not hidden. After the removal, the project’s webhook list still shows it with "created_by": "collab", and the share list still shows the share. An owner who reviews the project’s settings after someone leaves will find both.
The collaborator also had write access before they left, so they could have copied everything that existed then. What they gain from the flaw is narrower: content created after their access ended. Vikunja had drawn that line itself, in GHSA-jp29-jrxc-92vf, “Favorited tasks remain readable after project access is revoked, including content written post-revocation”. The report claimed that and nothing more.
For the score I copied rather than argued. Vikunja’s own webhook advisory, GHSA-7c2g-p23p-4jg3 (CVE-2026-33677), uses CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N, 6.5. I filed with the same vector and said the auditability point argued for the low end of Medium.
The half I left out of the score
The link share was the stronger primitive. It read and wrote, and on the same reasoning it would have scored 8.1. I did not lead with it. A link share exists to be handed to people outside the project, so “the owner made a share, the share still works” has a real defence, and the webhook has no equivalent one. The report described the share, gave the 8.1, and left the judgement to the maintainer.
The fix shows which way that went.
2.7.0
On 1 October kolaente accepted the report as filed, with no changes to the vector, the weaknesses or the range. A second reporter, Tan-JunWei, was credited on the same advisory a minute later. The fix is cb1d7818, and Vikunja 2.7.0 shipped it on 2 October. Its commit message reads like an answer to both halves of the report:
“This runs in the request’s transaction for every explicit removal: unsharing a user or team, removing a team member (including external team sync, limited to teams the user was in), deleting a team and reassigning a project’s owner. […] It replaces the async listener that only cleaned up after team member removal.”
The old routine is gone. A new file, pkg/models/project_access_cleanup.go, deletes the user’s webhooks in the affected projects alongside their assignments and subscriptions, and every explicit removal path calls it. Deleting a user now deletes all their webhooks. The commit also adds a check at delivery time: a webhook whose creator is no longer active, or can no longer read the event’s project, is skipped. That covers access lost indirectly, for example when a project moves under a different parent. It is the delivery-time check the report offered as the cheaper alternative, added on top of the cleanup rather than instead of it.
The message ends with two decisions about scope:
“A downgrade to read keeps the webhook. Link shares are left alone by design.”
The first follows from the rule: someone downgraded to read can still see the data, so their webhook is not leaking anything. The second is the call the report left to the maintainer, made the way I expected. The 2.7.0 release notes list the fix among six security fixes and credit both reporters.
I have not rerun the reproduction against 2.7.0. Everything in this section comes from reading the commit and the release.
The identifier came from elsewhere
kolaente requested a CVE from GitHub in the same minute as accepting the report, on 1 October. The advisory went public on 3 October without one. GitHub reviewed it into its Advisory Database on 9 October, still without one. None of that is unusual this autumn: maintainers on other projects have described GitHub’s identifier queue as heavily backlogged.
The identifier came from VulnCheck. It reserved CVE-2026-111919 on 17 October and published the record the same afternoon. I had not asked them for it. The record credits both reporters, scores 6.5 in CVSS 3.1 and 7.1 in CVSS 4.0, and gives the range as 0.22.0 up to but not including 2.7.0. The advisory itself still lists no patched version, and its CVE field is still empty: the record links to the advisory, but the advisory does not link back yet. If you run Vikunja below 2.7.0, upgrade. If you can’t yet, check each project’s webhook list for webhooks whose creator is no longer a member.
Advisory GHSA-hjx8-qv73-f7cm · CVE CVE-2026-111919 · CVSS 6.5 Medium (v3.1), 7.1 High (v4.0) · Fix cb1d7818 · Affected >= 0.22.0, < 2.7.0 · Fixed 2.7.0
Reported 12 September · accepted and CVE requested from GitHub 1 October · fix released in 2.7.0 on 2 October · advisory published 3 October, crediting two reporters · reviewed into the GitHub Advisory Database 9 October without a CVE · CVE reserved and record published by VulnCheck 17 October.
Eurico Nicacio — @h3llh0und