It's a Plan: Privilege Escalation to Project Owner via Invite Token

A member on the default role reads the plaintext token of a pending owner invite, registers the invited address and becomes owner of the project.

Advisory ID: TP-2026-123
Product: It's a Plan (self-hosted project management and issue tracking with AI agents)
Vulnerability type: Improper privilege management (CWE-269)
CVE: not requested
CVSS 3.1: 7.1 (High) · CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:H/A:H
Affected versions: >= 1.0.0, < 1.7.0
Vendor advisory: GHSA-mr34-5m49-6fvq
Fixed in: 1.7.0
Reported: 11 October 2026

Summary

It's a Plan is a self-hosted project management tool with teams, projects and role-based permissions. GET /projects/:projectKey/invites returns the pending invites of a project including the redeemable plaintext token, and the required permission members_invite.read is part of the default role of every new team. Accepting an invite compares only the invited address with the session's address, and the shipped configuration allows open registration without email verification. A member therefore registers the address of a pending owner invite itself and redeems it. A member on the default role becomes owner of its project, removes the legitimate owner and can delete the project.

Root cause

The listProjectInvites route sits behind the gate memberAdmin: ['members_invite', 'read'] (apps/api/src/modules/invites/index.ts:168-177), and the response schema contains token: t.String() (apps/api/src/modules/invites/model.ts:31). defaultMemberPermissions() sets p.members_invite.read = true (packages/db/src/permissions.ts:96), and insertOwnedTeam writes that matrix into every new team as the default role Member with isDefault: true (apps/api/src/modules/teams/service.ts:793-798). loadActionableInvite rejects only an invite whose address does not match the session and checks neither emailVerified nor anything else (apps/api/src/modules/invites/index.ts:41-50). defaultAuthSettings ships registration: 'open' and requireEmailVerification: false (packages/auth/src/instance.ts:67-76). The team_invite table has no expiry column, so a pending owner invite stays readable and redeemable indefinitely (packages/db/src/schema/app.ts:366-414).

Proof of Concept

GET /projects/ALPHA/invites                      (member, default role)
-> 200 [{"token":"cdd664bd-…","email":"[email protected]","role":"owner","status":"pending", …}]

POST /api/auth/sign-up/email  {"email":"[email protected]", …}
-> 200 {"user":{…,"emailVerified":false}}

POST /invites/cdd664bd-…/accept
-> 200 {"projectKey":"ALPHA","role":"owner", …}

The token is the only barrier of the invite, and acceptance requires no proof that the address belongs to the redeemer. It requires a pending owner invite to an address that has no account yet; the member cannot create an invite itself.

Impact

  • Privilege escalation from the default role to owner of the project.
  • Removal of the legitimate owner and irreversible deletion of the project with its documents, dashboards, webhooks and member list.
  • Disclosure of the webhook signing secret, which the default role cannot read.
  • Neither the owner nor the invited address receives a notification of the acceptance.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.