GHSA-96Q5-XM3P-7M84

Vulnerability from github – Published: 2026-04-10 15:31 – Updated: 2026-04-10 19:36
VLAI?
Summary
Vikunja: Link Share JWT tokens remain valid for 72 hours after share deletion or permission downgrade
Details

Title

Link Share JWT tokens remain valid for 72 hours after share deletion or permission downgrade

Description

Vikunja's link share authentication constructs authorization objects entirely from JWT claims without any server-side database validation. When a project owner deletes a link share or downgrades its permissions, all previously issued JWTs continue to grant the original permission level for up to 72 hours (the default service.jwtttl).

GetLinkShareFromClaims at pkg/models/link_sharing.go lines 88-119 performs zero database queries — it builds the LinkSharing struct purely from JWT claim values (id, hash, project_id, permission, sharedByID). This struct is passed directly to permission checks:

Function File Lines DB queries
GetLinkShareFromClaims link_sharing.go 88-119 0
Project.CanRead (link share) project_permissions.go 105-108 0
Project.CanWrite (link share) project_permissions.go 50-53 0
Project.IsAdmin (link share) project_permissions.go 192-194 0

Contrast with user tokens: User JWTs use a 10-minute TTL (ServiceJWTTTLShort) with sid claim and server-side sessions enabling revocation. Link share JWTs use a 72-hour TTL (ServiceJWTTTL) with no sid, no server-side session, and no refresh mechanism.

Permalink: - GetLinkShareFromClaims: pkg/models/link_sharing.go:88-119 - NewLinkShareJWTAuthtoken: pkg/modules/auth/auth.go:141-160 - Permission checks: pkg/models/project_permissions.go:50-53, 105-108, 192-194 - TTL defaults: pkg/config/config.go:337-339

PoC

# 1. Create an Admin-level link share on project 42
curl -X PUT "https://vikunja.example.com/api/v1/projects/42/shares" \
  -H "Authorization: Bearer <owner-jwt>" \
  -H "Content-Type: application/json" \
  -d '{"permission": 2}'
# Response: {"id": 5, "hash": "abc123", ...}

# 2. Obtain link share JWT (72h TTL, no sid claim)
curl -X POST "https://vikunja.example.com/api/v1/shares/abc123/auth"
# Response: {"token": "<link-share-jwt>"}

# 3. Delete the link share
curl -X DELETE "https://vikunja.example.com/api/v1/projects/42/shares/5" \
  -H "Authorization: Bearer <owner-jwt>"
# 200 OK — share row removed from database

# 4. Use the deleted share's JWT — STILL WORKS for up to 72 hours
curl -X GET "https://vikunja.example.com/api/v1/projects/42/tasks" \
  -H "Authorization: Bearer <link-share-jwt>"
# 200 OK — full task list returned with Admin permissions

# 5. Permission downgrade variant:
# Delete Admin share → create Read-only share → old JWT still has Admin access

Impact

  • Revoked link shares remain functional for up to 72 hours (default TTL)
  • Project owners cannot respond to security events (leaked URLs, access revocation) in real time
  • Permission downgrades have no effect on outstanding tokens
  • Scope: single project per token, severity scales with permission level (Admin > Write > Read)

Fix

Add database validation in GetLinkShareFromClaims:

func GetLinkShareFromClaims(claims jwt.MapClaims) (share *LinkSharing, err error) {
    id, is := claims["id"].(float64)
    if !is {
        return nil, &ErrLinkShareTokenInvalid{}
    }
    // Validate against database
    s := db.NewSession()
    defer s.Close()
    share, err = GetLinkShareByID(s, int64(id))
    if err != nil {
        return nil, err  // Share was deleted
    }
    // Verify permission not downgraded
    claimedPermission := Permission(claims["permission"].(float64))
    if share.Permission < claimedPermission {
        return nil, &ErrLinkShareTokenInvalid{}
    }
    return share, nil
}

Alternatives: shorter TTL with refresh mechanism, token blocklist, or session tracking matching user token pattern.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.2.2"
      },
      "package": {
        "ecosystem": "Go",
        "name": "code.vikunja.io/api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.3.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-35594"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-613"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-10T15:31:11Z",
    "nvd_published_at": "2026-04-10T16:16:32Z",
    "severity": "MODERATE"
  },
  "details": "## Title\nLink Share JWT tokens remain valid for 72 hours after share deletion or permission downgrade\n\n## Description\n\nVikunja\u0027s link share authentication constructs authorization objects entirely from JWT claims without any server-side database validation. When a project owner deletes a link share or downgrades its permissions, all previously issued JWTs continue to grant the **original** permission level for up to **72 hours** (the default `service.jwtttl`).\n\n`GetLinkShareFromClaims` at `pkg/models/link_sharing.go` lines 88-119 performs **zero database queries** \u2014 it builds the `LinkSharing` struct purely from JWT claim values (`id`, `hash`, `project_id`, `permission`, `sharedByID`). This struct is passed directly to permission checks:\n\n| Function | File | Lines | DB queries |\n|----------|------|-------|------------|\n| `GetLinkShareFromClaims` | `link_sharing.go` | 88-119 | 0 |\n| `Project.CanRead` (link share) | `project_permissions.go` | 105-108 | 0 |\n| `Project.CanWrite` (link share) | `project_permissions.go` | 50-53 | 0 |\n| `Project.IsAdmin` (link share) | `project_permissions.go` | 192-194 | 0 |\n\n**Contrast with user tokens:** User JWTs use a 10-minute TTL (`ServiceJWTTTLShort`) with `sid` claim and server-side sessions enabling revocation. Link share JWTs use a 72-hour TTL (`ServiceJWTTTL`) with no `sid`, no server-side session, and no refresh mechanism.\n\n**Permalink:**\n- `GetLinkShareFromClaims`: `pkg/models/link_sharing.go:88-119`\n- `NewLinkShareJWTAuthtoken`: `pkg/modules/auth/auth.go:141-160`\n- Permission checks: `pkg/models/project_permissions.go:50-53, 105-108, 192-194`\n- TTL defaults: `pkg/config/config.go:337-339`\n\n### PoC\n\n```bash\n# 1. Create an Admin-level link share on project 42\ncurl -X PUT \"https://vikunja.example.com/api/v1/projects/42/shares\" \\\n  -H \"Authorization: Bearer \u003cowner-jwt\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"permission\": 2}\u0027\n# Response: {\"id\": 5, \"hash\": \"abc123\", ...}\n\n# 2. Obtain link share JWT (72h TTL, no sid claim)\ncurl -X POST \"https://vikunja.example.com/api/v1/shares/abc123/auth\"\n# Response: {\"token\": \"\u003clink-share-jwt\u003e\"}\n\n# 3. Delete the link share\ncurl -X DELETE \"https://vikunja.example.com/api/v1/projects/42/shares/5\" \\\n  -H \"Authorization: Bearer \u003cowner-jwt\u003e\"\n# 200 OK \u2014 share row removed from database\n\n# 4. Use the deleted share\u0027s JWT \u2014 STILL WORKS for up to 72 hours\ncurl -X GET \"https://vikunja.example.com/api/v1/projects/42/tasks\" \\\n  -H \"Authorization: Bearer \u003clink-share-jwt\u003e\"\n# 200 OK \u2014 full task list returned with Admin permissions\n\n# 5. Permission downgrade variant:\n# Delete Admin share \u2192 create Read-only share \u2192 old JWT still has Admin access\n```\n\n### Impact\n\n- Revoked link shares remain functional for up to 72 hours (default TTL)\n- Project owners cannot respond to security events (leaked URLs, access revocation) in real time\n- Permission downgrades have no effect on outstanding tokens\n- Scope: single project per token, severity scales with permission level (Admin \u003e Write \u003e Read)\n\n### Fix\n\nAdd database validation in `GetLinkShareFromClaims`:\n\n```go\nfunc GetLinkShareFromClaims(claims jwt.MapClaims) (share *LinkSharing, err error) {\n    id, is := claims[\"id\"].(float64)\n    if !is {\n        return nil, \u0026ErrLinkShareTokenInvalid{}\n    }\n    // Validate against database\n    s := db.NewSession()\n    defer s.Close()\n    share, err = GetLinkShareByID(s, int64(id))\n    if err != nil {\n        return nil, err  // Share was deleted\n    }\n    // Verify permission not downgraded\n    claimedPermission := Permission(claims[\"permission\"].(float64))\n    if share.Permission \u003c claimedPermission {\n        return nil, \u0026ErrLinkShareTokenInvalid{}\n    }\n    return share, nil\n}\n```\n\nAlternatives: shorter TTL with refresh mechanism, token blocklist, or session tracking matching user token pattern.",
  "id": "GHSA-96q5-xm3p-7m84",
  "modified": "2026-04-10T19:36:07Z",
  "published": "2026-04-10T15:31:11Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/security/advisories/GHSA-96q5-xm3p-7m84"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-35594"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/pull/2581"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/commit/379d8a5c19334ffe4846003f590e202c31a75479"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/go-vikunja/vikunja"
    },
    {
      "type": "WEB",
      "url": "https://github.com/go-vikunja/vikunja/releases/tag/v2.3.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Vikunja: Link Share JWT tokens remain valid for 72 hours after share deletion or permission downgrade"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

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…