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

Vikunja's default CORS origins trust any localhost port with credentials, letting a local page read a refresh-endpoint bearer token and take over the account.

- Payout: Research
- Severity: high
- Program: Vikunja
- Found by: arthurscchan, AdamKorcz
- Disclosed: 2026-10-09
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-m687-p538-r5hp
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-m687-p538-r5hp
- Tags: area:web domain:api domain:web impact:ato kind:research platform:ghsa sev:high tech:go technique:source-review vuln:account-takeover vuln:auth-bypass vuln:cors

**Target:** Vikunja API CORS middleware and token refresh endpoint
**Root cause:** cors.origins defaults to http://localhost:* and http://127.0.0.1:* with AllowCredentials true, and service.publicurl is appended to rather than replacing that list, so production deployments still trust every localhost origin.
**Steps:**
1. Configure only service.publicurl=https://vikunja.example.com; cors.enable and cors.origins keep defaults.
2. Victim logs in; refresh cookie issued SameSite=None; Secure.
3. From a page on http://localhost:31337 send preflight; server returns `Access-Control-Allow-Origin: http://localhost:31337` and `Access-Control-Allow-Credentials: true`.
4. Send `fetch('https://vikunja.example.com/api/v1/user/token/refresh', {method: 'POST', credentials: 'include'})` and read the bearer JWT from the response body.
5. Use the token to authenticate as the victim.
**How found:** Code review of config defaults and CORS middleware: noticed publicurl is appended to the default origin list instead of replacing it, and that the refresh endpoint returns a bearer token in the body while authenticating via a SameSite=None cookie.
**Exploitation:** Any page served from a localhost port (e.g. a malicious local app or dev server) issues a credentialed POST to the refresh endpoint; the browser attaches the SameSite=None refresh cookie, CORS headers permit reading the response, and the returned bearer JWT grants full API authority over the victim's account.
**Escalation:** Refresh token exchange converts a cross-origin read into a full-account bearer token, bypassing HttpOnly cookie protection.
**Insight:** When a config value is 'required' but merged with defaults, check whether the merge widens trust; and look for unauthenticated endpoints that exchange a cookie for a bearer token in the response body, since CORS + credentials turns those into token theft.

