Oct 10, 2026·5 min read·8 visits
A CORS misconfiguration in Vikunja appends configured public URLs to default localhost wildcard origins instead of replacing them. Paired with credentialed CORS and an unauthenticated token refresh endpoint, a script running on any local port can fetch a valid bearer JWT for an authenticated user.
Vikunja versions 2.2.0 through 2.6.0 contain a Cross-Origin Resource Sharing (CORS) misconfiguration flaw in `code.vikunja.io/api`. Default configurations permit wildcard origins for localhost (`http://127.0.0.1:*` and `http://localhost:*`) with credentialed requests (`Access-Control-Allow-Credentials: true`). Because configuring a public service URL appends to this default list rather than overriding it, production environments inadvertently trust all local origins. A local page or application on a user's machine can execute a credentialed cross-origin request to the token refresh endpoint, extract the returned JWT access token, and achieve complete account takeover.
Vikunja is an open-source, self-hosted task management platform. In affected versions (>= 2.2.0, <= 2.6.0), the API server (code.vikunja.io/api) implements a Cross-Origin Resource Sharing (CORS) policy that defaults to trusting all ports on 127.0.0.1 and localhost with credentialed HTTP requests permitted.
The critical design flaw lies in how Vikunja handles configuration parsing for cors.origins. Setting the mandatory service.publicurl parameter in production environments appends the configured domain to the default wildcard array rather than replacing it. Consequently, production deployments unintentionally continue allowing cross-origin requests from any service running locally on the user's host machine.
When a victim visits a local application or a compromised web page bound to a local port, a script on that page can issue a cross-origin HTTP request to the target Vikunja instance. Because credentialed CORS is enabled, the browser attaches the victim's session cookies (vikunja_refresh_token), allowing the attacker to retrieve a new bearer JWT access token and take control of the user account.
The vulnerability stems from three interacting design choices across configuration parsing, middleware origin validation, and authentication architecture.
First, pkg/config/config.go sets default origins to http://127.0.0.1:* and http://localhost:*. When service.publicurl is defined during initialization, the application appends this value to cors.origins. There is no configuration option or internal logic to purge the default wildcard entries upon setting a explicit public domain.
Second, pkg/routes/routes.go configures the CORS middleware with AllowCredentials: true and utilizes a custom matching function (matchCORSOrigin) via Echo's UnsafeAllowOriginFunc. This function parses and validates dynamic ports against the wildcard entries, allowing any port on localhost or 127.0.0.1 to pass cross-origin access checks with ambient credentials.
Third, the token refresh endpoint (POST /api/v1/user/token/refresh) accepts the vikunja_refresh_token HTTP cookie—marked with SameSite=None when operating over HTTPS—and outputs a new access token directly in the JSON response body. Because the CORS policy grants Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true to the requesting local origin, client-side JavaScript execution environment can read the token payload directly.
Analysis of the underlying source code in go-vikunja/vikunja highlights the exact failure sequence across initialization and routing.
In pkg/config/config.go, the default origins are set as array elements before configuration processing:
// pkg/config/config.go
// Default configuration sets wildcard localhost origins
viper.SetDefault("cors.origins", []string{
"http://127.0.0.1:*",
"http://localhost:*",
})Later in initialization, when service.publicurl is evaluated, the code appends the user's configured domain directly to the existing slice:
// pkg/config/config.go
// Appending public URL instead of replacing default origins
publicUrl := viper.GetString("service.publicurl")
if publicUrl != "" {
origins := viper.GetStringSlice("cors.origins")
origins = append(origins, publicUrl)
viper.Set("cors.origins", origins)
}In pkg/routes/routes.go, the CORS middleware registers AllowCredentials: true alongside the dynamic port matcher matchCORSOrigin:
// pkg/routes/routes.go
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowCredentials: true,
UnsafeAllowOriginFunc: func(origin string) (bool, error) {
return matchCORSOrigin(origin, config.CorsOrigins.GetStringSlice()), nil
},
}))When a request hits POST /api/v1/user/token/refresh, pkg/routes/api/v1/login.go retrieves the refresh token from the cookie, computes a fresh bearer JWT, and returns it in the response body. Since the response headers explicitly allow the calling local origin with credentials, the calling script gains unhindered access to the bearer token string.
An attack scenario requires a logged-in Vikunja user to interact with a web service or page served from localhost or 127.0.0.1 on any port (for example, a local developer server, a malicious local daemon, or a local service vulnerable to Cross-Site Scripting).
The attack flow operates through standard browser mechanisms without requiring special privilege escalation:
To execute the exploit, JavaScript executing within the local origin issues an asynchronous fetch request to the remote Vikunja instance:
fetch('https://vikunja.example.com/api/v1/user/token/refresh', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(data => {
const bearerToken = data.token;
console.log('Exfiltrated Bearer Token:', bearerToken);
});Because the browser sends the vikunja_refresh_token cookie and receives Access-Control-Allow-Credentials: true alongside the matching local origin in the response, the browser allows the JavaScript environment to access data.token. The attacker now possesses a valid API JWT for the victim's account.
This vulnerability achieves full confidentiality and integrity impact on the affected user account (VC:H/VI:H under CVSS v4.0). Once an access token is extracted, the attacker can impersonate the victim across all Vikunja API endpoints, accessing private tasks, projects, user settings, and connected integrations.
The overall severity is rated High (CVSS 7.2). The attack vector requires a local prerequisite or user interaction involving a local origin, satisfying AV:L / UI:P. However, because no authentication is needed to trigger the refresh endpoint itself beyond the presence of the cookie, PR:N applies.
This flaw particularly threatens developers, administrators, and users who run local web servers or tools while remaining logged into a remote production Vikunja deployment.
To resolve this issue, operators must upgrade to a fixed post-2.6.0 release of code.vikunja.io/api where configuration loading has been updated to treat user-specified origins as strict replacements for default values.
If immediate patching is not possible, proxy-level mitigations can be implemented using a reverse proxy (such as NGINX, HAProxy, Caddy, or Traefik) placed in front of the Vikunja API instance.
The reverse proxy should inspect incoming Origin headers and strip or override CORS response headers when the origin matches localhost or 127.0.0.1 pattern in production environments:
# Example NGINX mitigation: sanitize Origin headers
if ($http_origin ~* "^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$") {
return 403;
}Additionally, operators should audit their deployment configuration files to verify that CORS origins do not explicitly include local wildcard entries unless strictly operating in a dedicated local development environment.
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| Product | Affected Versions | Fixed Version |
|---|---|---|
code.vikunja.io/api Vikunja | >= 2.2.0, <= 2.6.0 | post-2.6.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-942 |
| CVSS Score | 7.2 (High) |
| CVSS 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 |
| Vulnerability Type | Permissive Cross-Domain Security Policy |
| Affected Package | code.vikunja.io/api |
| Exploit Status | Proof of Concept Available |
| CISA KEV Status | Not Listed |
The program uses a cross-domain security policy that authorizes unsafe or untrusted domains, allowing unexpected cross-origin interactions.
Vikunja versions 2.3.0 through 2.6.0 contain an insufficient session expiration vulnerability (CWE-613) within the WebSocket authentication handler. Although Vikunja enforces server-side session tracking and revocation for REST API routes, the WebSocket handshake handler validates cryptographic JWT signatures without querying the database session state. Consequently, revoked JWT tokens can establish new real-time WebSocket connections, and existing connections persist after session revocation.
A cross-tenant boundary breach vulnerability in Vikunja allows an authenticated user to trigger global task position recalculations across all tenant instances by creating a saved filter with an empty filter string payload.
An access revocation flaw in Vikunja allows removed collaborators to retain outbound webhooks and link shares created prior to revocation, enabling persistent exfiltration of sensitive task data.
Vikunja v2.6.0 contains a permission inheritance regression in pkg/models/project_access.go where explicit down-restrictions on sub-projects are overridden by higher parent project permissions due to MAX aggregation across project tree nodes.
An authorization bypass vulnerability in Vikunja's link share deletion handlers allows project members with Write privileges to delete Admin-tier link shares. The handler passes an unpopulated struct to the authorization check, causing the permission evaluation to fall back to default Write permissions instead of requiring Admin privileges.
An insufficient session invalidation vulnerability exists in pyLoad (pyload-ng) versions 0.5.0b3.dev98 through 0.5.0b3.dev101. When administrative actions like privilege revocation or password changes are executed via the public REST API, active user sessions on disk are not updated or invalidated. Consequently, affected sessions remain fully authenticated with stale permissions for up to 31 days.