Skip to content

GHSA-m687-p538-r5hp CVE-2026-111922 GO-2026-6739

Vikunja: Permissive Cross-domain Security Policy trusts every localhost origin which should not be trusted

Medium Go
Severity
0.0 / 10
Medium · CVSS 4.0
Exploit chance (EPSS)
0.15%
Chance of exploitation in the next 30 days. Higher than 4.16% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-942 Permissive Cross-domain Security Policy with Untrusted Domains
Fix
None yet
No fixed version published

Description

Summary

Vikunja ships with cors.origins defaulting to http://127.0.0.1:* and http://localhost:*, and sends Access-Control-Allow-Credentials: true, so a page served from any port on the user's own machine may make credentialed cross-origin requests to the API and read the responses. The mandatory service.publicurl is appended to that default list rather than replacing it, so a fully configured production deployment still trusts every localhost origin. Combined with the token refresh endpoint, which authenticates with the vikunja_refresh_token cookie and returns a bearer JWT in the response body, one fetch() from such a page yields a working access token for whoever is logged in, and from there the whole account.

Vulnerability Details

cors.enable defaults to true and cors.origins defaults to the two localhost wildcards:

https://github.com/go-vikunja/vikunja/blob/a881ac39eecd07575a5aede8e74727c28d5fd578/pkg/config/config.go#L495-L497

The middleware is registered with AllowCredentials: true and an UnsafeAllowOriginFunc that implements the port wildcard through matchCORSOrigin, since Echo v5 will not accept a wildcard port itself:

https://github.com/go-vikunja/vikunja/blob/a881ac39eecd07575a5aede8e74727c28d5fd578/pkg/routes/routes.go#L263-L281

The detail that turns a development convenience into a production exposure is the last line of configuration handling. Enabling CORS without a public URL is a fatal error, so every deployment sets one, and that value is appended to the origin list rather than substituted for it:

https://github.com/go-vikunja/vikunja/blob/a881ac39eecd07575a5aede8e74727c28d5fd578/pkg/config/config.go#L828-L830

An operator who configures nothing but their own hostname therefore ends up allowing three origins with credentials: their site, and any port on localhost and 127.0.0.1. There is no supported configuration in which the localhost entries are quietly dropped, and nothing in the logs distinguishes the intended origin from the inherited ones.

The escalation path is the refresh endpoint. It is deliberately unauthenticated because it authenticates with the refresh cookie instead of a bearer token, and it returns the new access token in the JSON body:

https://github.com/go-vikunja/vikunja/blob/a881ac39eecd07575a5aede8e74727c28d5fd578/pkg/routes/api/v1/login.go#L129-L150

The cookie is HttpOnly, but that offers no protection here, because the attacking page never reads the cookie. It issues a credentialed request and the browser attaches the cookie itself. SetRefreshTokenCookie marks the cookie SameSite=None whenever the public URL is https, precisely so the cookie survives split-origin deployments, which is also what allows a cross-site send from a localhost page:

https://github.com/go-vikunja/vikunja/blob/a881ac39eecd07575a5aede8e74727c28d5fd578/pkg/modules/auth/auth.go#L84-L104

So a single fetch('https://vikunja.example.com/api/v1/user/token/refresh', {method: 'POST', credentials: 'include'}) from any page on any localhost port returns a bearer token for the logged-in user, and the CORS response headers permit the page to read it. The token carries the user's full API authority.

Proof of Concept

Three files, built together in one directory and run with:

poc.zip

docker build -f Dockerfile -t poc . && docker run --rm poc
  • Dockerfile builds Vikunja from commit a881ac39eecd07575a5aede8e74727c28d5fd578 and bakes the reproducer in.
  • poc.sh is the entrypoint. It starts the application on sqlite with service.publicurl as the only setting configured, waits for the health check, and runs the exploit.
  • exploit.py registers a user, logs them in, then acts as a page on http://localhost:31337: it reads the refresh cookie attributes, sends the CORS preflight, makes the credentialed refresh request, and asks the API who the returned token belongs to.

Nothing is stubbed and no internal function is called; everything after startup is ordinary HTTP against the running application. In the output [+] lines are measured checks and [*] lines are commentary, so only the checks and the final verdict are evidence.

A run against a881ac39eecd07575a5aede8e74727c28d5fd578 prints:

[*] Vikunja built from commit a881ac39ee, sqlite, service.publicurl=https://vikunja.example.com.
[*] service.publicurl is the only setting configured. cors.enable and cors.origins keep their defaults.

[+] The victim logs in and the refresh cookie is issued SameSite=None; Secure, so it is sent cross-site.
[+] A page on http://localhost:31337 is allowed to send credentials: Access-Control-Allow-Origin: http://localhost:31337, Access-Control-Allow-Credentials: true
[+] That page reads the response of a credentialed refresh, which carries a bearer token: eyJhbGciOiJIUzI1NiIsInR5cCI6...
[+] The token authenticates as: 'victim'

EXPLOIT SUCCESSFUL

Suggested Fix

The fix is for service.publicurl to replace the localhost defaults rather than extend them, so that configuring a deployment narrows the allowed origins instead of widening them. If the localhost entries are retained deliberately for desktop clients, they should not be combined with AllowCredentials: true, since it is the combination that allows a third-party origin to read authenticated responses.

Attribution

This vulnerability was found using Google's security automation tooling, abd triaged manually with manual report writing by Ada Logics. Please credit Google and Ada Logics in any advisories.

Affected versions

Go

PackageVulnerableFixed in
code.vikunja.io/api
>= 2.2.0, <= 2.6.0
No fix yet
code.vikunja.io/api
all versions
No fix yet

How to fix

No fixed version has been published yet. Until one is, remove or replace the package, or turn off or isolate the affected feature. This page updates when a fix ships.

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

Attack vector
Local
Attack complexity
Low
Attack requirements
Present
Privileges required
None
User interaction
Passive
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
None
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

CVSS 3.1 6.3

CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N

Attack vector
Local
Attack complexity
High
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

Weakness

CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains

The product uses a web-client protection mechanism such as a Content Security Policy (CSP) or cross-domain policy file, but the policy includes untrusted domains with which the web client is allowed to communicate.

How to prevent it
  • Architecture and Design, Operation: Define a restrictive Content Security Policy [REF-1486] or cross-domain policy file.
  • Architecture and Design, Operation: Avoid using wildcards in the CSP / cross-domain policy file. Any domain matching the wildcard expression will be implicitly trusted, and can perform two-way interaction with the target server.
  • Architecture and Design, Operation: For Flash, modify crossdomain.xml to use meta-policy options such as 'master-only' or 'none' to reduce the possibility of an attacker planting extraneous cross-domain policy files on a server.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-fmmf-xq98-g327
Vikunja: Write-level project members can delete admin-tier link shares through an unloaded permission check
Medium Also affects code.vikunja.io/api
GHSA-pjr3-86v4-5p7w
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)
Medium Also affects code.vikunja.io/api
GHSA-hjx8-qv73-f7cm
Vikunja: Webhooks and link shares survive every revocation path, so a removed collaborator keeps a live feed
High Also affects code.vikunja.io/api
GHSA-fprf-r6rv-xg99
Vikunja: Saved filter creation with an empty filter string recalculates task positions across all tenants
Medium Also affects code.vikunja.io/api
GHSA-4hv6-xc92-j86g
Vikunja: WebSocket authentication ignores server-side session state, so revoked sessions keep receiving live pushes
High Also affects code.vikunja.io/api
GHSA-w2ch-4xgr-22ww
Vikunja: Task relation deletion does not check read access to the other task, allowing cross-project relation removal
Low Also affects code.vikunja.io/api
GHSA-8wvg-r2j4-3737
Vikunja: Assignee email addresses disclosed to read-only project members via the task assignees endpoint
Medium Also affects code.vikunja.io/api
GHSA-3hc7-r24j-rpwc
Vikunja: Cross-project task disclosure through subtask expansion
Medium Also affects code.vikunja.io/api
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor