Vikunja: Permissive Cross-domain Security Policy trusts every localhost origin which should not be trusted
GHSA-m687-p538-r5hp is a high-severity vulnerability with a CVSS 4.0 base score of 7.2, classed as Permissive Cross-domain Security Policy with Untrusted Domains (CWE-942), in code.vikunja.io/api (Go). Affected versions: >= 2.2.0, <= 2.6.0. No fixed version has been published yet.
Published 9 Oct 2026Updated 9 Oct 2026
- CVE ID
- No CVE assigned
- GHSA ID
- GHSA-m687-p538-r5hp
- EPSS
- Not scored
- Weakness (CWE)
- CWE-942Permissive Cross-domain Security Policy with Untrusted Domains
- KEV status
- Not in KEV
- Published
- 9 Oct 2026
- Updated
- 9 Oct 2026
- Ecosystem
- Go
Summary
Source: OSV (GitHub advisory)
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:
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:
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:
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:
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:
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:
docker build -f Dockerfile -t poc . && docker run --rm poc
Dockerfilebuilds Vikunja from commita881ac39eecd07575a5aede8e74727c28d5fd578and bakes the reproducer in.poc.shis the entrypoint. It starts the application on sqlite withservice.publicurlas the only setting configured, waits for the health check, and runs the exploit.exploit.pyregisters a user, logs them in, then acts as a page onhttp://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.
Technical details
Source: GitHub Advisory Database
CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:NHigh7.2Base score from GitHub Advisory Database
- Attack Vector AV
- Local
- Attack Complexity AC
- Low
- Attack Requirements AT
- Present
- Privileges Required PR
- None
- User Interaction UI
- Passive
- Confidentiality (Vulnerable System) VC
- High
- Integrity (Vulnerable System) VI
- High
- Availability (Vulnerable System) VA
- None
- Confidentiality (Subsequent Systems) SC
- None
- Integrity (Subsequent Systems) SI
- None
- Availability (Subsequent Systems) SA
- None
Affected packages
Source: OSV (GitHub advisory)
- code.vikunja.io/apiGo
pkg:golang/code.vikunja.io/api- Affected versions
- >= 2.2.0, <= 2.6.0
- Fixed in
- None after 2.6.0
| Package | Affected versions | Fixed in |
|---|---|---|
code.vikunja.io/api Go pkg:golang/code.vikunja.io/api |
| None after 2.6.0 |
Fix
Source: OSV (GitHub advisory)
No fixed version has been published
Every version in the affected ranges above is vulnerable. Look for mitigations in the advisory and references, and watch the vendor for a release.
Vulnerability intelligence
Root cause analysis
Vulnerable functions
References
Credits
- arthurscchanreporter
- AdamKorczreporter
Questions about GHSA-m687-p538-r5hp
- Does GHSA-m687-p538-r5hp have a CVE ID?
- No CVE ID has been assigned to GHSA-m687-p538-r5hp yet. It is tracked only as a GitHub advisory.
- What is the CVSS score of GHSA-m687-p538-r5hp?
- GHSA-m687-p538-r5hp has a CVSS 4.0 base score of 7.2 (High), vector 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.
- Which versions are affected by GHSA-m687-p538-r5hp?
- code.vikunja.io/api (Go): >= 2.2.0, <= 2.6.0.
- How do I fix GHSA-m687-p538-r5hp?
- No fixed version has been published yet. Check the advisory and references for mitigations, and watch the vendor for a release.
- What is the Package URL (PURL) for GHSA-m687-p538-r5hp?
- The affected package's PURL is pkg:golang/code.vikunja.io/api.
Data sources
Upstream records
Fetched 2 days ago (13 Oct 2026, 12:55 UTC). Records refresh automatically after 24 hours.
Where each part of this page came from
- Title and description
- OSV (GitHub advisory)
- Severity score
- GitHub Advisory Database
- Weakness (CWE)
- GitHub Advisory Database
- Affected packages
- OSV (GitHub advisory)
- Fix
- OSV (GitHub advisory)
- References
- Computed by CVEminer
OSV and GitHub Advisory Database content is licensed CC BY 4.0. Values marked as computed or derived were calculated by CVEminer from the upstream data.