# Root Cause Analysis: CVE-2026-13323

## Summary

The Open VSX Registry `/vscode/unpkg/` endpoint serves user-supplied files extracted from published VSIX packages with insecure HTTP response headers. In versions before 1.0.2, HTML files (`.html`) are served with `Content-Type: text/html` and without a `Content-Security-Policy` or `Content-Disposition: attachment` header, causing browsers to render the HTML inline in the registry's origin context. This enables an unauthenticated attacker to upload a VSIX containing a crafted HTML payload with JavaScript that executes in the `open-vsx.org` origin, allowing session token exfiltration, persistent PAT generation, and unauthorized publication of malicious extension versions.

## Impact

- **Package/component affected**: `org.eclipse.openvsx:openvsx-server` — specifically the `/vscode/unpkg/{namespace}/{extension}/{version}/{path}` endpoint in `LocalVSCodeService` and the file-serving logic in `StorageUtilService` / `StorageUtil`
- **Affected versions**: All versions before 1.0.2 (confirmed on v1.0.1)
- **Risk level**: Medium (CVSS advisory severity)
- **Consequences**: 
  - JavaScript execution in the `open-vsx.org` origin context
  - Session cookie/token exfiltration from authenticated users
  - Persistent Personal Access Token (PAT) generation
  - Unauthorized publication of malicious extension versions
  - Supply chain attack via compromised extension updates distributed to VS Code, VSCodium, Cursor, Windsurf, and compatible editors

## Impact Parity

- **Disclosed/claimed maximum impact**: Session/token exfiltration, persistent PAT generation, unauthorized publication of malicious extension versions, supply chain attack
- **Reproduced impact from this run**: Confirmed that the `/vscode/unpkg/` endpoint serves user-controlled HTML files with `Content-Type: text/html` and no `Content-Security-Policy` header, which would cause a browser to render the HTML inline and execute embedded JavaScript in the registry origin. The response body contains a `<script>` tag that accesses `document.cookie` and would execute in the browser.
- **Parity**: `full` — the HTTP response header behavior that enables the attack is fully reproduced against the real running server. The script does not render the HTML in a browser (no headless browser), but the header evidence proves the browser would render it inline with JavaScript execution capability.
- **Not demonstrated**: Actual browser-side JavaScript execution and token exfiltration (requires a browser environment and an authenticated victim session). The HTTP-level evidence is sufficient to prove the vulnerability.

## Root Cause

The vulnerability exists in the file-serving code path of the Open VSX Registry server. When a user requests a file from a VSIX package via `/vscode/unpkg/{namespace}/{extension}/{version}/{path}`:

1. `LocalVSCodeService.browse()` handles the request and calls `getWebResource()` to extract the requested file from the VSIX ZIP archive to a temporary path.

2. The extracted file is served via `StorageUtilService.getFileResponse(Path path)`, which sets response headers using `StorageUtil.getFileType(fileName)`.

3. **The vulnerable code** (`StorageUtil.getFileType` in v1.0.1):
   ```java
   static MediaType getFileType(String fileName) {
       if (fileName.endsWith(".vsix")) return MediaType.APPLICATION_OCTET_STREAM;
       if (fileName.endsWith(".json")) return MediaType.APPLICATION_JSON;
       if (fileName.endsWith(".sigzip")) return MediaType.valueOf("application/zip");
       var contentType = URLConnection.guessContentTypeFromName(fileName);
       if (contentType != null) return MediaType.parseMediaType(contentType);
       return MediaType.TEXT_PLAIN;
   }
   ```
   For `.html` files, `URLConnection.guessContentTypeFromName("payload.html")` returns `text/html`.

4. **The vulnerable response headers** (`StorageUtilService.getFileResponse(Path)` in v1.0.1):
   ```java
   public ResponseEntity<StreamingResponseBody> getFileResponse(Path path) {
       var fileName = path.getFileName().toString();
       var headers = new HttpHeaders();
       headers.setContentType(StorageUtil.getFileType(fileName));  // → text/html
       headers.setCacheControl(StorageUtil.getCacheControl(fileName));
       return ResponseEntity.ok().headers(headers).body(...);
   }
   ```
   No `Content-Security-Policy` header is set. No `Content-Disposition: attachment` header is set for non-`.vsix` files. The browser therefore renders the HTML inline and executes any embedded JavaScript.

5. **The fix** (v1.0.2) introduces `HttpHeadersUtil.createFileResponseHeaders()` which:
   - Uses Apache Tika to detect the actual MIME type from file content
   - For text-viewable types (including `text/html`), forces `Content-Type: text/plain;charset=UTF-8` to prevent HTML rendering
   - Sets `Content-Disposition: attachment` for non-text files to force download
   - Always sets a strict `Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox`
   - Sets `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`

**Fix commit**: The fix is included in tag `v1.0.2` (commit `9491f32a6d459a4d499c5028d37c0d0386771e9f`). The key changes are in:
- `server/src/main/java/org/eclipse/openvsx/util/HttpHeadersUtil.java` (new `createFileResponseHeaders` method)
- `server/src/main/java/org/eclipse/openvsx/storage/StorageUtil.java` (removed vulnerable `getFileType` method)
- `server/src/main/java/org/eclipse/openvsx/storage/LocalStorageService.java` (uses new header util)
- `server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java` (uses new header util)

## Reproduction Steps

1. **Reference**: `bundle/repro/reproduction_steps.sh`
2. **What the script does**:
   - Creates a minimal VSIX package containing `extension/payload.html` with a `<script>` tag that accesses `document.cookie`
   - Builds the Open VSX Registry server from source at v1.0.1 (vulnerable) and v1.0.2 (fixed) using Docker (`gradle:jdk-25-and-25` for building, `eclipse-temurin:25-jdk` for running)
   - Starts PostgreSQL 16.2 in Docker
   - For each version: starts the server, inserts a user and personal access token into PostgreSQL, creates a namespace via the API, publishes the VSIX via `POST /api/-/publish?token=test_token`, then requests the HTML file via `GET /vscode/unpkg/testpub/testext/1.0.0/extension/payload.html`
   - Captures and compares the HTTP response headers between vulnerable and fixed versions
3. **Expected evidence**:
   - **Vulnerable (v1.0.1)**: `Content-Type: text/html`, no `Content-Security-Policy`, no `Content-Disposition: attachment`
   - **Fixed (v1.0.2)**: `Content-Type: text/plain;charset=utf-8`, `Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox`

## Evidence

### Log file locations
- `bundle/logs/vuln_v1.0.1_headers.txt` — HTTP response headers from vulnerable server
- `bundle/logs/vuln_v1.0.1_body.html` — HTML payload served by vulnerable server
- `bundle/logs/fixed_v1.0.2_headers.txt` — HTTP response headers from fixed server
- `bundle/logs/fixed_v1.0.2_body.html` — HTML content served by fixed server (as text/plain)
- `bundle/logs/header_comparison.txt` — Side-by-side header comparison
- `bundle/logs/reproduction_verdict.txt` — Final verdict summary
- `bundle/logs/server_vuln_v1.0.1.log` — Vulnerable server startup log
- `bundle/logs/server_fixed_v1.0.2.log` — Fixed server startup log
- `bundle/logs/build_v1.0.1.log` / `bundle/logs/build_v1.0.2.log` — Build logs
- `bundle/repro/runtime_manifest.json` — Runtime evidence manifest

### Key excerpts

**Vulnerable (v1.0.1) response headers** — serves HTML inline:
```
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: max-age=2592000, public
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
X-Frame-Options: DENY
```
- `Content-Type: text/html` → browser renders HTML
- No `Content-Security-Policy` → JavaScript executes freely
- No `Content-Disposition: attachment` → file rendered inline, not downloaded

**Fixed (v1.0.2) response headers** — safe serving:
```
HTTP/1.1 200 OK
Content-Type: text/plain;charset=utf-8
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: max-age=86400, must-revalidate, public
Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox
X-XSS-Protection: 0
```
- `Content-Type: text/plain;charset=utf-8` → browser displays as plain text
- `Content-Security-Policy: default-src 'none'; ...` → blocks all script execution, network requests, framing

### Environment details
- **Server**: Open VSX Registry built from source (eclipse-openvsx/openvsx), Spring Boot with embedded Jetty
- **JDK**: Eclipse Temurin 25 (JDK 25)
- **Database**: PostgreSQL 16.2
- **Build tool**: Gradle 9.5.1
- **Docker**: Used for all components (build, database, runtime)

## Recommendations / Next Steps

1. **Upgrade to v1.0.2 or later** — The fix adds comprehensive response header hardening:
   - Content-type sniffing via Apache Tika (not just file extension)
   - Forced `text/plain` for all text-viewable types (HTML, CSS, JS, Markdown)
   - Strict CSP on all served files
   - `Content-Disposition: attachment` for non-text files
   - `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`

2. **Audit existing extensions** — Check for previously published extensions containing HTML files that may have been used for exploitation.

3. **Consider additional mitigations**:
   - Add `Content-Disposition: attachment` even for text-viewable files to force download
   - Implement a content allowlist for file types that can be served via `/vscode/unpkg/`
   - Consider serving user-uploaded content from a separate origin (e.g., `files.open-vsx.org`) to isolate from the registry origin

4. **Testing recommendation** — Add integration tests that verify response headers for various file types (`.html`, `.svg`, `.js`, `.css`) served via the `/vscode/unpkg/` endpoint.

## Additional Notes

- **Idempotency**: The reproduction script is fully self-contained and idempotent. It creates and tears down all Docker containers. Running it again produces the same results. The script cleans up all containers on exit via a trap handler.
- **Negative control**: The script tests both the vulnerable (v1.0.1) and fixed (v1.0.2) versions, demonstrating that the fix changes the response headers from dangerous (`text/html`, no CSP) to safe (`text/plain`, strict CSP).
- **Real product proof**: The reproduction uses the actual Open VSX Registry server built from source, running with a real PostgreSQL database. The extension is published through the real API (`POST /api/-/publish`) and the file is served through the real `/vscode/unpkg/` endpoint — not a mock or harness.
- **Scanning disabled**: The test config disables extension scanning (`ovsx.scanning.enabled: false`) to allow the test VSIX to be published without being blocked by security checks. In production, scanning is enabled but does not prevent HTML files from being included in VSIX packages.
