Vikunja coordinated wave: scoped tokens mint unscoped sessions, sibling endpoints disagree on every gate, and revocation misses the artifacts that carry live access¶
Signal: GitHub Security Advisories published a coordinated ~25-advisory wave against Vikunja (self-hosted todo/project manager, Go + Vue) at 2026-10-09T20:40–20:57Z, spread across fix bands 2.4.0, 2.6.0, and unfixed-at-publication. This is the third separate Vikunja wave (Aug 2 principal-type/cross-object page, Aug 28 write-path FK page, now this one) — each wave exposed axes the previous one didn't, which is the same-product re-sweep rule earning its keep. The wave's unifying operator lesson: Vikunja has parallel surfaces for everything — v1 vs v2 API, REST vs CalDAV vs WebSocket, single-read vs list, scoped token vs OAuth vs link share — and almost every leg is one surface enforcing a control its sibling forgets.
Authorized validation only
Disposable instance, synthetic users/projects/share links, marker-only rows, recorder/rollback for every mutation. Never enumerate a public instance, retain tokens, or touch real users' credentials/sessions.
Advisory table¶
| GHSA | CVE | Sev | Boundary |
|---|---|---|---|
| GHSA-v3p6-34mc-hj7v | CVE-2026-57458 | high | A scoped API token holding only {"oauth":["authorize"]} can call /api/v1/oauth/authorize, receive a code, redeem it at /api/v1/oauth/token, and walk out with a normal bearer JWT + refresh token carrying no scope restriction. Runtime-verified: scoped token denied GET /api/v1/user; the minted token allowed it. |
| GHSA-32r8-5843-4qw2 | CVE-2026-76216 | high | Principal-type confusion, second wave at three sinks the first fix never touched: web.Auth is a one-method interface (GetID() int64) satisfied by both users and link shares; LinkSharing.GetID() returns the raw positive share ID. TeamMember.CanDelete runs the self-removal fast path u.ID == a.GetID() before IsAdmin (the only place that rejects link shares); BotUser.isOwner compares owner-ID to GetID(). A link share numbered N is treated as user N for team removal, bot takeover, roster disclosure. The safe negated form (share.ID * -1) exists in the codebase and simply isn't used at these sinks. |
| GHSA-qfwc-vx6f-3g6g | CVE-2026-91985 | high | Single-read vs list gate drift: the link-share list endpoint requires project admin (fixed in an earlier advisory), but GET /api/v{1,2}/projects/{p}/shares/{s} only requires project read — and serializes the share's hash, which is the bearer credential exchanged at POST /shares/{share}/auth for a share JWT at the share's permission level. Read-only member → mint a write- or admin-level project token. The password field was cleared; the hash wasn't. |
| GHSA-fmmf-xq98-g327 | — | medium | Permission check on a caller-constructed zero-value object: the share DELETE handler passes &LinkSharing{ID, ProjectID} built from URL params — never loading the stored row. CanDelete branches on share.Permission == PermissionAdmin, but the in-memory zero value is PermissionRead(0), so deleting an admin-tier share only needs project Write. Create correctly rejects Write members; delete consults an object with no stored state. |
| GHSA-pjr3-86v4-5p7w | — | medium | Inheritance-semantics regression is attack surface: 2.6.0's recursive-CTE permission engine resolves effective permission as MAX over the whole reachable project subtree, silently ignoring an owner's explicit lower share of a child project to a user who holds a higher grant on the parent (2.5.0 used nearest-ancestor-wins and honored it). Deliberately read-restricted sub-project becomes writable/deletable/re-shareable. |
| GHSA-9rg3-v78m-26q8 | CVE-2026-91983 | medium | Scope enforcement matches method + route path only; the query string is never consulted. A token scoped to tasks:read calls task reads with ?expand=comments,reactions,time_entries_count and the embed loader (addMoreInfoToTasks, which only sees the owner user, not token scopes) returns data from permission groups the token was explicitly denied. tasks_comments, reactions, time_entries scopes are advisory for anything expand-reachable. |
| GHSA-6rvj-qwjf-3m4q | CVE-2026-91972 | high | Pre-auth rate-limit route-family drift: v1 applies an unconditional per-IP floor (unauthRateLimit, ignores the config flag) to login/register/reset; registerAPIRoutesV2 passes it only to /api/v2/ws — every v2 pre-auth endpoint on a stock install is unthrottled while its v1 twin 429s. Verified PoC: 25× v1 login → 429 from attempt 5; 25× v2 login → zero 429s. Brute-force floor bypass via version sibling. |
| GHSA-m469-88xx-8rx2 | CVE-2026-91973 | medium | Third throttle-free auth surface (late-wave addition): /dav, /.well-known, /feeds register on the root Echo instance with bare BasicAuth and no limiter at all — CalDAV auth falls through to CheckUserCredentials with the plain account password when no CalDAV app token matches, so password guessing over /dav never returns 429 while /api/v1/login throttles from the tenth attempt. Same sibling-parity rule as the v2 leg one row above, now on the protocol sibling: TOTP/bot accounts are correctly refused, so the target set is password-only accounts. |
| GHSA-vfxw-3x8p-2vjr | CVE-2026-91981 | medium | Same v1-vs-v2 drift on principal class: link-share tokens are rejected from user-search on v1 but accepted on v2 — GET /api/v2/projects/{id}/users/search (returns full member list, walks parent chain) and GET /api/v2/users?q= (instance-wide username existence oracle). A share link becomes a directory enumerator. |
| GHSA-g38j-7v97-x298 | — | medium | REST vs CalDAV parity: creating a task relation over REST is 403-gated by TaskRelation.CanCreate; the CalDAV storage provider calls rel.Create() directly with an unscoped UID lookup. Any authenticated user grafts subtask relations onto foreign tasks — and the task UIDs needed are disclosed by CalDAV to anyone who ever had read access (revocation doesn't unlearn them), making the removed collaborator the realistic attacker. Also feeds the subtask-expansion disclosure below. |
| GHSA-3hc7-r24j-rpwc | — | medium | Cross-project task disclosure through subtask expansion (the read twin of the write above — relation edges created via CalDAV are rendered into your own task tree). |
| GHSA-8wvg-r2j4-3737 | — | medium | Assignee email addresses disclosed to read-only project members via the assignees endpoint (the response carries the full user object where a permission-level field projection was intended). |
| GHSA-w2ch-4xgr-22ww | — | low | Relation DELETE checks write access on the URL-side task only, never read access on the other task — cross-project relation unlinking. One-sided edge authorization, same rule as the Mistral/Actual either-end edge addressing family. |
| GHSA-hjx8-qv73-f7cm | — | medium | Revocation cleanup set omits the artifacts that matter: a cleanupTaskMembersAfterTeamRemoval routine exists precisely because losing read access must delete leftover rows — its set is {task_assignees, subscriptions}. webhooks and link_shares — the only two rows carrying a live channel into the project — are not in it, and the routine is wired to only one of four revocation paths. A removed collaborator keeps a live push feed via webhook. |
| GHSA-4hv6-xc92-j86g | — | medium | Session revocation doesn't cover the WebSocket boundary: 2.6.0 introduced server-side sessions (DELETE /api/v2/user/sessions/{id}, TOTP enable calls DeleteAllUserSessions); user JWTs carry a sid claim, but /api/v2/ws authenticates by signature only and never resolves sid against the sessions table. Pre-revocation connections keep streaming forever; even new WS connections accept a token whose session row was deleted. |
| GHSA-m687-p538-r5hp | — | high | Ship-default CORS trusts every localhost origin with credentials: cors.origins defaults to http://127.0.0.1:* + http://localhost:*, AllowCredentials: true (port wildcard implemented via a custom match func because the framework rejects wildcard ports), and the mandatory publicurl is appended to the default list rather than replacing it — every production deployment still trusts all localhost ports. One fetch() from any local page + the refresh endpoint (authenticates via vikunja_refresh_token cookie, returns a bearer JWT in the body) = account takeover of whoever is logged in. Dev-convenience defaults that no config step removes. |
| GHSA-88f6-4rjv-x774 | CVE-2026-91982 | medium | Step-up asymmetry: GET /api/v1/user/settings/totp returns the raw TOTP secret + otpauth:// URL to anyone holding the access token — no password re-check — while .../disable demands the password. Stolen token / XSS / unlocked browser → clone the second factor indefinitely. The destructive action is gated; the extraction isn't. |
| GHSA-39p5-2wrr-xh29 | CVE-2026-91980 | medium | Association-as-read-oracle: project admin (of your own throwaway project) may attach any team ID on the instance; the teams listing returns each attached team's full member roster. One throwaway project → instance-wide org chart. Gate checked: "are you admin of the project" — never "is this team one you may reference". |
| GHSA-w39f-h553-h2mx | CVE-2026-91984 | medium | Task-position writes authorize only the task side; body project_view_id never validated against the task's project — inject position rows into any tenant's view (sequential view IDs). Same one-sided-FK pattern as the Aug 28 bucket-relocation fix, surviving in the sibling endpoint — the fix even names the dual-side check it applied to buckets. |
| GHSA-fprf-r6rv-xg99 | — | medium | Fail-open scoping: saved-filter creation recalculates task positions; when the requesting user has no accessible projects, the recalculation search loses its project scope entirely and returns every task in the instance, writing position rows into the requester's new views. Cross-tenant integrity violation + write amplification scaling with instance size. Zero-privilege precondition = the instance-wide code path. |
| GHSA-r6w9-259g-gwrv | CVE-2026-62376 | high | Password-reset / email-confirm / account-deletion tokens stored plaintext in user_tokens (SQL-level read → instant ATO) — while CalDAV tokens in the same product correctly use bcrypt-hashed storage. Sibling-parity once more; operator value: these tables are standing targets once you have any DB read primitive (backup leak, SSRF-with-SQL, config disclosure). |
| GHSA-xv7q-fvmc-jx96 | CVE-2026-62367 | high | OIDC emailfallback links SSO logins to local accounts on the email claim alone — no email_verified check (field doesn't even exist in the claims struct), no password — full ATO class of nOAuth/Grafana CVE-2023-3128; the prior fix added a TOTP gate, not an email-verified gate. Primary treatment folded on the Sept 16 IdP page next to the same-morning Pingvin Share leg. |
| GHSA-vvcv-vpph-h844 / GHSA-rj9j-8772-4h6c | CVE-2026-68581 / 68582 | high / critical | Re-publications with expanded write-ups of the two legs already promoted on the Aug 2 Vikunja page (link-share/user ID collision into token management; path-selected view loaded pre-authorization). No new axis; the expanded narratives confirm the original matrices. |
Tracked without publication from the same wave: unbounded-decode/CSV/recursion/decompression-bomb DoS quintet (CVE-2026-91968/91969/91970/91979/91971 — resource-exhaustion class), duplicate advisory markers c2g7/55p4/jfw3, and the yopass unbounded-Prometheus-label DoS + pacioli consent-marker leg + ageLANServer AoE3 JSON-array DoS from the same 20:4xZ batch.
Durable operator rules¶
- Credential-minting flows are scope-escalation targets — enumerate what a scoped token can ask the server to issue. A token whose only granted scope is "call the authorizer" defeats the entire scope model in two requests because the output of the minting flow isn't bound to the input's scope. On any API with scoped tokens + an OAuth/refresh/token-exchange flow: hold a minimal scope, attempt every issuance endpoint, then replay the issued credential against out-of-scope routes. Report the exact scope → minted-capability ladder.
- Sibling-surface parity is Vikunja's entire defect genealogy — and it generalizes everywhere. v1 vs v2 routes, REST vs CalDAV vs WebSocket, list vs single-read, create vs delete, read vs disable, hashed-token storage vs plaintext: in every leg, one surface enforces and its twin doesn't. Build the parity matrix first on this product class: every control you confirm on surface A gets an immediate negative test on surface A′ (older version prefix, alternate protocol, sibling verb, sibling route shape). The Aug 2 page (principal collision, view-load), the Aug 28 page (body-FK rebinding), and this wave are all instances of the same audit algorithm.
- Permission checks must consult stored state, not caller-shaped objects. The zero-value fall-through (delete handler hands the gate a
{ID, ProjectID}stub whosePermissionfield defaults toRead) is a grep target: find everyCanX/policy call whose argument is constructed from request input rather than loaded from the database. Differential proof: perform the operation against a high-permission stored row with a principal that the stored value should deny but the zero value admits. - Authorization-cache and cache-key hygiene at the policy-voter layer (Vikunja's sibling leg is the Contao voter below): any memoized allow/deny must key on (principal, object, action) — a boolean cached per-principal leaks the first object's answer to every object the request touches.
- Revocation is a lifecycle battery, not an endpoint. Enumerate every artifact that carries standing access — webhooks, share links, sessions, WS connections, tokens, expanded relations, learned UIDs — then test each revocation path (member removal, session delete, TOTP enable, share revoke) against each artifact. Vikunja shipped a cleanup routine with a stated invariant whose row set omitted exactly the two live-channel tables, and wired it to 1 of 4 paths. Read the vendor's own cleanup/
ON DELETEsemantics as an inventory of what they knew to revoke; the omissions are the bug. - Ship-default browser trust survives into production when config appends to defaults.
localhost:*+AllowCredentials+ a credentials-bearing cookie endpoint reachable by any local page = local-origin token theft (malware, malicious desktop apps, or any XSS on a localhost dev service you also run). Check every self-hosted app's CORS/config defaults for wildcard-origin-with-credentials and note whether the mandatory public setting replaces or joins the list. - Read-vs-step-up asymmetry on secret material: wherever a secret-bearing resource gates its destructive action behind re-auth, test the read. The TOTP-secret-after-enable leg is the reusable probe: any second-factor enrollment flow that leaves the secret retrievable by bearer-token alone converts token theft into durable 2FA cloning.
- Zero-privilege preconditions can widen blast radius. The fail-open recalculation triggers because the user has no accessible projects — audit the "empty result" branch of every scoped search; empty-scope collapse to unscoped is a repeat fail-open shape (see the Sept 18 ignored-scope page lineage).
- Same-product re-sweep validated a third time: three Vikunja waves in ten weeks, each a new axis family on the same codebase after the previous wave's fixes. When a product has proven advisory velocity, re-test its sibling surfaces after each fix band rather than assuming the wave exhausted it.
Replayable validation boundaries (lab only)¶
- Scope-ladder harness: create a token scoped only to
oauth.authorize→ attempt the authorize/token exchange → replay the minted JWT against one out-of-scope read route. Evidence: 403-vs-200 pair scoped-token/minted-token against identical routes. Redact all token values; report scope JSON + route decisions. - Parity matrix runner: for each confirmed control (share list admin-gate, v1 login throttle, REST relation gate, v1 user-search rejection, bucket dual-side FK check), fire the same synthetic request at the v2 route, the CalDAV verb, the WS endpoint, and the zero-value-prone verb (DELETE). Deliverable = route-decision table, positive and negative controls on a patched build.
- Revocation battery fixture: attacker user creates webhook + link share + WS connection inside a victim project as a member → removed from team → then poll webhook delivery, replay share, hold WS open after
DELETE /sessions. Marker-only payload; capture which channels survive which revocation path. - CORS posture (own instance): from a page served on
http://localhost:<any-other-port>,fetch()one authenticated read endpoint withcredentials: includeand then the refresh endpoint; evidence = response readability + JWT issuance, on your instance only. - TOTP-extraction probe: own account, own token; confirm secret field presence in
GET /settings/totppost-enable. Never test against another user's token.
Cross-references¶
- Prior Vikunja waves: Aug 2 principal-type/cross-object page, Aug 28 write-path FK follow-up on that page (this wave's GHSA-w39f explicitly survives from the GHSA-569v bucket fix — incomplete-fix sibling).
email_verified-unchecked OAuth auto-link: Sept 16 IdP page (Pingvin Share + Vikunja legs, same day).- Session/auth-cache revocation side channel: June 8 pyLoad page (
check_apikeycache); Mistral either-end edge addressing on the Oct 8 page for one-sided edge authorization. - Scope-ignored-parameter and ignored-scope lineage: Sept 18 ignored-scope page.
- Stale derived index / denormalized-auth time bombs: Sept 21 auth-precedence page (MISP trio).