GHSA-F27P-PW2P-9PR4

Vulnerability from github – Published: 2026-08-28 16:37 – Updated: 2026-08-28 16:37
VLAI
Summary
Vikunja has a project duplication bypasses write-permission check on the target parent project
Details

Summary

The project-duplication endpoint fails to enforce write access to the target parent project. Any authenticated (non-link-share) user can duplicate a project they can read into any parent project on the instance, regardless of whether they have write access to that parent — injecting an attacker-owned project into another user's or team's project hierarchy.

Details

ProjectDuplicate.CanCreate (pkg/models/project_duplicate.go) is meant to require write access to the parent the duplicate is placed under — its own comment says "Parent project exists + user has write access". The implementation does neither correctly:

func (pd *ProjectDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool, err error) {
    pd.Project = &Project{ID: pd.ProjectID}
    canRead, _, err := pd.Project.CanRead(s, a)
    if err != nil || !canRead {
        return canRead, err
    }
    if pd.ParentProjectID == 0 {
        return canRead, err
    }
    // Parent project exists + user has write access to is (-> can create new projects)
    parent := &Project{ID: pd.ParentProjectID}
    return parent.CanCreate(s, a)   // <-- bug
}

Two defects compound here:

  1. Wrong permission method. It calls parent.CanCreate ("may I create this project?") instead of parent.CanWrite ("may I create children inside this project?"). The latter is what the normal create path uses — POST /projects with a parent_project_id enforces parent.CanWrite via Project.CanCreate (pkg/models/project_permissions.go:196-199).

  2. Unhydrated struct. parent is constructed as &Project{ID: pd.ParentProjectID} and never loaded from the database, so its in-memory ParentProjectID is always 0. Inside Project.CanCreate the only branch that performs any permission check is if p.ParentProjectID != 0 { return parent.CanWrite(...) } — which therefore never executes. Control falls through to the link-share check and then return true, nil. The result is true for any authenticated non-link-share user, for any ParentProjectID.

Nothing downstream re-checks: ProjectDuplicate.CreateCreateProjectcheckProjectBeforeUpdateOrDelete (pkg/models/project.go:954) validates only that the parent exists, is not a pseudo-project, and introduces no cycle — no authorization.

Impact

An authenticated user can:

  • Duplicate any project they can read (including their own) and attach the copy as a child of any parent project ID on the instance, with no write access to that parent.
  • Inject an attacker-owned project into other users'/teams' project trees. The duplicate is owned by the attacker but appears inside the victim's hierarchy; members of the victim parent see it, and because Vikunja propagates parent access down the tree, they may inherit access to the injected project — enabling content injection / spam / phishing inside another tenant's workspace.

This is a bypass of the same parent-write guard that the ordinary create path enforces, so the duplicate route is an authorization hole for an operation that is otherwise correctly gated. The endpoint requires authentication; it does not expose or modify the victim's existing project data (the source is attacker-readable), so the impact is an integrity / access-control violation rather than confidentiality.

Proof of Concept

  1. As user A, create or have read access to any project S (e.g. id 100).
  2. Identify a parent project P (e.g. id 5) owned by user B, to which A has no access.
  3. Call PUT /api/v1/projects/100/duplicate with body {"parent_project_id": 5}.
  4. The request succeeds (201). A new project owned by A is created as a child of B's project 5, despite A having no write access to it. The equivalent POST /api/v1/projects with parent_project_id: 5 would be correctly rejected with 403.

Affected versions

Introduced with the namespace→project migration (commit fef253312, first released in v0.21.0) and present through the latest release (v2.3.0). The shared model also backs the new /api/v2 duplication route under review, so any v2 release would inherit the same flaw unless fixed in the model.

Recommended Fix

In ProjectDuplicate.CanCreate, check write access to the parent directly:

parent := &Project{ID: pd.ParentProjectID}
return parent.CanWrite(s, a)

Project.CanWrite loads the project from the database and evaluates real permissions, fixing both the wrong-method and the unhydrated-struct defects at once and matching the documented contract. (It also rejects archived parents, which is desirable.)

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.3.0"
      },
      "package": {
        "ecosystem": "Go",
        "name": "code.vikunja.io/api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.21.0"
            },
            {
              "fixed": "2.4.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-54766"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-285",
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-28T16:37:26Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe project-duplication endpoint fails to enforce write access to the target parent project. Any authenticated (non-link-share) user can duplicate a project they can read into **any** parent project on the instance, regardless of whether they have write access to that parent \u2014 injecting an attacker-owned project into another user\u0027s or team\u0027s project hierarchy.\n\n## Details\n\n`ProjectDuplicate.CanCreate` (`pkg/models/project_duplicate.go`) is meant to require write access to the parent the duplicate is placed under \u2014 its own comment says \"Parent project exists + user has write access\". The implementation does neither correctly:\n\n```go\nfunc (pd *ProjectDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool, err error) {\n    pd.Project = \u0026Project{ID: pd.ProjectID}\n    canRead, _, err := pd.Project.CanRead(s, a)\n    if err != nil || !canRead {\n        return canRead, err\n    }\n    if pd.ParentProjectID == 0 {\n        return canRead, err\n    }\n    // Parent project exists + user has write access to is (-\u003e can create new projects)\n    parent := \u0026Project{ID: pd.ParentProjectID}\n    return parent.CanCreate(s, a)   // \u003c-- bug\n}\n```\n\nTwo defects compound here:\n\n1. **Wrong permission method.** It calls `parent.CanCreate` (\"may I create *this* project?\") instead of `parent.CanWrite` (\"may I create children *inside* this project?\"). The latter is what the normal create path uses \u2014 `POST /projects` with a `parent_project_id` enforces `parent.CanWrite` via `Project.CanCreate` (`pkg/models/project_permissions.go:196-199`).\n\n2. **Unhydrated struct.** `parent` is constructed as `\u0026Project{ID: pd.ParentProjectID}` and never loaded from the database, so its in-memory `ParentProjectID` is always `0`. Inside `Project.CanCreate` the only branch that performs any permission check is `if p.ParentProjectID != 0 { return parent.CanWrite(...) }` \u2014 which therefore never executes. Control falls through to the link-share check and then `return true, nil`. The result is `true` for any authenticated non-link-share user, for any `ParentProjectID`.\n\nNothing downstream re-checks: `ProjectDuplicate.Create` \u2192 `CreateProject` \u2192 `checkProjectBeforeUpdateOrDelete` (`pkg/models/project.go:954`) validates only that the parent exists, is not a pseudo-project, and introduces no cycle \u2014 no authorization.\n\n## Impact\n\nAn authenticated user can:\n\n- Duplicate any project they can read (including their own) and attach the copy as a child of **any** parent project ID on the instance, with no write access to that parent.\n- Inject an attacker-owned project into other users\u0027/teams\u0027 project trees. The duplicate is owned by the attacker but appears inside the victim\u0027s hierarchy; members of the victim parent see it, and because Vikunja propagates parent access down the tree, they may inherit access to the injected project \u2014 enabling content injection / spam / phishing inside another tenant\u0027s workspace.\n\nThis is a bypass of the same parent-write guard that the ordinary create path enforces, so the duplicate route is an authorization hole for an operation that is otherwise correctly gated. The endpoint requires authentication; it does not expose or modify the victim\u0027s existing project data (the source is attacker-readable), so the impact is an integrity / access-control violation rather than confidentiality.\n\n## Proof of Concept\n\n1. As user A, create or have read access to any project `S` (e.g. id 100).\n2. Identify a parent project `P` (e.g. id 5) owned by user B, to which A has **no** access.\n3. Call `PUT /api/v1/projects/100/duplicate` with body `{\"parent_project_id\": 5}`.\n4. The request succeeds (201). A new project owned by A is created as a child of B\u0027s project 5, despite A having no write access to it. The equivalent `POST /api/v1/projects` with `parent_project_id: 5` would be correctly rejected with 403.\n\n## Affected versions\n\nIntroduced with the namespace\u2192project migration (commit `fef253312`, first released in v0.21.0) and present through the latest release (v2.3.0). The shared model also backs the new `/api/v2` duplication route under review, so any v2 release would inherit the same flaw unless fixed in the model.\n\n## Recommended Fix\n\nIn `ProjectDuplicate.CanCreate`, check write access to the parent directly:\n\n```go\nparent := \u0026Project{ID: pd.ParentProjectID}\nreturn parent.CanWrite(s, a)\n```\n\n`Project.CanWrite` loads the project from the database and evaluates real permissions, fixing both the wrong-method and the unhydrated-struct defects at once and matching the documented contract. (It also rejects archived parents, which is desirable.)",
  "id": "GHSA-f27p-pw2p-9pr4",
  "modified": "2026-08-28T16:37:26Z",
  "published": "2026-08-28T16:37:26Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/security/advisories/GHSA-f27p-pw2p-9pr4"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/pull/3239"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/commit/d911caaa11c748c3abc6b98b3189afea2677bcb0"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/go-vikunja/vikunja"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/releases/tag/v2.4.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Vikunja has a project duplication bypasses write-permission check on the target parent project"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…