{"repro_id":"REPRO-2026-00202","version":8,"title":"Open VSX Registry serves HTML inline enabling session/token exfiltration","repro_type":"security","status":"published","severity":"medium","description":"Stored XSS vulnerability in Open VSX Registry: the /vscode/unpkg/{publisher}/{name}/{version}/{path} endpoint serves user-supplied files (HTML, SVG) from within VSIX extension packages with content type deduced from file extension but without Content-Security-Policy, Content-Disposition: attachment, or X-Content-Type-Options: nosniff headers. An attacker registers a publisher account, uploads a VSIX containing a crafted HTML/SVG payload, and induces an authenticated user to visit the resulting URL. The browser renders the file inline in the open-vsx.org origin context, enabling session token exfiltration, PAT generation, and unauthorized publication of malicious extension versions. This constitutes a supply-chain risk since Open VSX extensions are distributed to VS Code, VSCodium, Cursor, Windsurf, and compatible editors.","root_cause":"# Root Cause Analysis: CVE-2026-13323\n\n## Summary\n\nThe 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.\n\n## Impact\n\n- **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`\n- **Affected versions**: All versions before 1.0.2 (confirmed on v1.0.1)\n- **Risk level**: Medium (CVSS advisory severity)\n- **Consequences**: \n  - JavaScript execution in the `open-vsx.org` origin context\n  - Session cookie/token exfiltration from authenticated users\n  - Persistent Personal Access Token (PAT) generation\n  - Unauthorized publication of malicious extension versions\n  - Supply chain attack via compromised extension updates distributed to VS Code, VSCodium, Cursor, Windsurf, and compatible editors\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: Session/token exfiltration, persistent PAT generation, unauthorized publication of malicious extension versions, supply chain attack\n- **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.\n- **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.\n- **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.\n\n## Root Cause\n\nThe 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}`:\n\n1. `LocalVSCodeService.browse()` handles the request and calls `getWebResource()` to extract the requested file from the VSIX ZIP archive to a temporary path.\n\n2. The extracted file is served via `StorageUtilService.getFileResponse(Path path)`, which sets response headers using `StorageUtil.getFileType(fileName)`.\n\n3. **The vulnerable code** (`StorageUtil.getFileType` in v1.0.1):\n   ```java\n   static MediaType getFileType(String fileName) {\n       if (fileName.endsWith(\".vsix\")) return MediaType.APPLICATION_OCTET_STREAM;\n       if (fileName.endsWith(\".json\")) return MediaType.APPLICATION_JSON;\n       if (fileName.endsWith(\".sigzip\")) return MediaType.valueOf(\"application/zip\");\n       var contentType = URLConnection.guessContentTypeFromName(fileName);\n       if (contentType != null) return MediaType.parseMediaType(contentType);\n       return MediaType.TEXT_PLAIN;\n   }\n   ```\n   For `.html` files, `URLConnection.guessContentTypeFromName(\"payload.html\")` returns `text/html`.\n\n4. **The vulnerable response headers** (`StorageUtilService.getFileResponse(Path)` in v1.0.1):\n   ```java\n   public ResponseEntity<StreamingResponseBody> getFileResponse(Path path) {\n       var fileName = path.getFileName().toString();\n       var headers = new HttpHeaders();\n       headers.setContentType(StorageUtil.getFileType(fileName));  // → text/html\n       headers.setCacheControl(StorageUtil.getCacheControl(fileName));\n       return ResponseEntity.ok().headers(headers).body(...);\n   }\n   ```\n   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.\n\n5. **The fix** (v1.0.2) introduces `HttpHeadersUtil.createFileResponseHeaders()` which:\n   - Uses Apache Tika to detect the actual MIME type from file content\n   - For text-viewable types (including `text/html`), forces `Content-Type: text/plain;charset=UTF-8` to prevent HTML rendering\n   - Sets `Content-Disposition: attachment` for non-text files to force download\n   - Always sets a strict `Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox`\n   - Sets `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`\n\n**Fix commit**: The fix is included in tag `v1.0.2` (commit `9491f32a6d459a4d499c5028d37c0d0386771e9f`). The key changes are in:\n- `server/src/main/java/org/eclipse/openvsx/util/HttpHeadersUtil.java` (new `createFileResponseHeaders` method)\n- `server/src/main/java/org/eclipse/openvsx/storage/StorageUtil.java` (removed vulnerable `getFileType` method)\n- `server/src/main/java/org/eclipse/openvsx/storage/LocalStorageService.java` (uses new header util)\n- `server/src/main/java/org/eclipse/openvsx/storage/StorageUtilService.java` (uses new header util)\n\n## Reproduction Steps\n\n1. **Reference**: `bundle/repro/reproduction_steps.sh`\n2. **What the script does**:\n   - Creates a minimal VSIX package containing `extension/payload.html` with a `<script>` tag that accesses `document.cookie`\n   - 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)\n   - Starts PostgreSQL 16.2 in Docker\n   - 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`\n   - Captures and compares the HTTP response headers between vulnerable and fixed versions\n3. **Expected evidence**:\n   - **Vulnerable (v1.0.1)**: `Content-Type: text/html`, no `Content-Security-Policy`, no `Content-Disposition: attachment`\n   - **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`\n\n## Evidence\n\n### Log file locations\n- `bundle/logs/vuln_v1.0.1_headers.txt` — HTTP response headers from vulnerable server\n- `bundle/logs/vuln_v1.0.1_body.html` — HTML payload served by vulnerable server\n- `bundle/logs/fixed_v1.0.2_headers.txt` — HTTP response headers from fixed server\n- `bundle/logs/fixed_v1.0.2_body.html` — HTML content served by fixed server (as text/plain)\n- `bundle/logs/header_comparison.txt` — Side-by-side header comparison\n- `bundle/logs/reproduction_verdict.txt` — Final verdict summary\n- `bundle/logs/server_vuln_v1.0.1.log` — Vulnerable server startup log\n- `bundle/logs/server_fixed_v1.0.2.log` — Fixed server startup log\n- `bundle/logs/build_v1.0.1.log` / `bundle/logs/build_v1.0.2.log` — Build logs\n- `bundle/repro/runtime_manifest.json` — Runtime evidence manifest\n\n### Key excerpts\n\n**Vulnerable (v1.0.1) response headers** — serves HTML inline:\n```\nHTTP/1.1 200 OK\nContent-Type: text/html\nCache-Control: max-age=2592000, public\nX-Content-Type-Options: nosniff\nX-XSS-Protection: 0\nX-Frame-Options: DENY\n```\n- `Content-Type: text/html` → browser renders HTML\n- No `Content-Security-Policy` → JavaScript executes freely\n- No `Content-Disposition: attachment` → file rendered inline, not downloaded\n\n**Fixed (v1.0.2) response headers** — safe serving:\n```\nHTTP/1.1 200 OK\nContent-Type: text/plain;charset=utf-8\nX-Content-Type-Options: nosniff\nX-Frame-Options: DENY\nCache-Control: max-age=86400, must-revalidate, public\nContent-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox\nX-XSS-Protection: 0\n```\n- `Content-Type: text/plain;charset=utf-8` → browser displays as plain text\n- `Content-Security-Policy: default-src 'none'; ...` → blocks all script execution, network requests, framing\n\n### Environment details\n- **Server**: Open VSX Registry built from source (eclipse-openvsx/openvsx), Spring Boot with embedded Jetty\n- **JDK**: Eclipse Temurin 25 (JDK 25)\n- **Database**: PostgreSQL 16.2\n- **Build tool**: Gradle 9.5.1\n- **Docker**: Used for all components (build, database, runtime)\n\n## Recommendations / Next Steps\n\n1. **Upgrade to v1.0.2 or later** — The fix adds comprehensive response header hardening:\n   - Content-type sniffing via Apache Tika (not just file extension)\n   - Forced `text/plain` for all text-viewable types (HTML, CSS, JS, Markdown)\n   - Strict CSP on all served files\n   - `Content-Disposition: attachment` for non-text files\n   - `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY`\n\n2. **Audit existing extensions** — Check for previously published extensions containing HTML files that may have been used for exploitation.\n\n3. **Consider additional mitigations**:\n   - Add `Content-Disposition: attachment` even for text-viewable files to force download\n   - Implement a content allowlist for file types that can be served via `/vscode/unpkg/`\n   - Consider serving user-uploaded content from a separate origin (e.g., `files.open-vsx.org`) to isolate from the registry origin\n\n4. **Testing recommendation** — Add integration tests that verify response headers for various file types (`.html`, `.svg`, `.js`, `.css`) served via the `/vscode/unpkg/` endpoint.\n\n## Additional Notes\n\n- **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.\n- **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).\n- **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.\n- **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.\n","cve_id":"CVE-2026-4983","cwe_id":"CWE-79","source_url":"eclipse-openvsx/openvsx","reproduced_at":"2026-07-02T19:23:20.337554+00:00","duration_secs":2181.0,"tool_calls":250,"handoffs":2,"total_cost_usd":4.482306189999998,"agent_costs":{"hypothesis_generator":0.0095384,"judge":0.0219512,"repro":3.04672032,"support":0.28255014,"vuln_variant":1.1215461299999998},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0095384},"judge":{"gpt-5.4-mini":0.0219512},"repro":{"accounts/fireworks/routers/glm-5p2-fast":3.04672032},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.28255014},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.1215461299999998}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T19:23:21.441702+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":22191,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11187,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":21278,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":17815,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":1009,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1449,"category":"other"},{"path":"bundle/logs/vuln_v1.0.1_headers.txt","filename":"vuln_v1.0.1_headers.txt","size":315,"category":"other"},{"path":"bundle/logs/vuln_v1.0.1_body.html","filename":"vuln_v1.0.1_body.html","size":596,"category":"other"},{"path":"bundle/logs/fixed_v1.0.2_headers.txt","filename":"fixed_v1.0.2_headers.txt","size":460,"category":"other"},{"path":"bundle/logs/fixed_v1.0.2_body.html","filename":"fixed_v1.0.2_body.html","size":596,"category":"other"},{"path":"bundle/logs/header_comparison.txt","filename":"header_comparison.txt","size":1047,"category":"other"},{"path":"bundle/logs/vuln_analysis.json","filename":"vuln_analysis.json","size":134,"category":"other"},{"path":"bundle/logs/fixed_analysis.json","filename":"fixed_analysis.json","size":238,"category":"other"},{"path":"bundle/logs/reproduction_verdict.txt","filename":"reproduction_verdict.txt","size":446,"category":"other"},{"path":"bundle/logs/server_vuln_v1.0.1.log","filename":"server_vuln_v1.0.1.log","size":38725,"category":"log"},{"path":"bundle/logs/server_fixed_v1.0.2.log","filename":"server_fixed_v1.0.2.log","size":38730,"category":"log"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":15995,"category":"log"},{"path":"bundle/logs/create_vsix.py","filename":"create_vsix.py","size":3195,"category":"script"},{"path":"bundle/logs/application.yml","filename":"application.yml","size":2115,"category":"other"},{"path":"bundle/logs/build_v1.0.1.log","filename":"build_v1.0.1.log","size":2910,"category":"log"},{"path":"bundle/logs/build_v1.0.2.log","filename":"build_v1.0.2.log","size":2911,"category":"log"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":912,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":985,"category":"other"},{"path":"bundle/testpub.testext-1.0.0.vsix","filename":"testpub.testext-1.0.0.vsix","size":1952,"category":"other"},{"path":"bundle/logs/vuln_variant/variant_repro.log","filename":"variant_repro.log","size":34024,"category":"log"},{"path":"bundle/logs/vuln_variant/server_vuln_v1.0.1.log","filename":"server_vuln_v1.0.1.log","size":42429,"category":"log"},{"path":"bundle/logs/vuln_variant/vuln_publish.json","filename":"vuln_publish.json","size":1449,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_variant_headers.txt","filename":"vuln_variant_headers.txt","size":315,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_variant_body.html","filename":"vuln_variant_body.html","size":669,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_control_headers.txt","filename":"vuln_control_headers.txt","size":315,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_control_body.html","filename":"vuln_control_body.html","size":669,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_metadata.json","filename":"vuln_metadata.json","size":1571,"category":"other"},{"path":"bundle/logs/vuln_variant/vuln_icon_url.txt","filename":"vuln_icon_url.txt","size":60,"category":"other"},{"path":"bundle/logs/vuln_variant/server_fixed_v1.0.2.log","filename":"server_fixed_v1.0.2.log","size":38729,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_publish.json","filename":"fixed_publish.json","size":1449,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_variant_headers.txt","filename":"fixed_variant_headers.txt","size":460,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_variant_body.html","filename":"fixed_variant_body.html","size":669,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_control_headers.txt","filename":"fixed_control_headers.txt","size":460,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_control_body.html","filename":"fixed_control_body.html","size":669,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_metadata.json","filename":"fixed_metadata.json","size":1571,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_icon_url.txt","filename":"fixed_icon_url.txt","size":60,"category":"other"},{"path":"bundle/logs/vuln_variant/variant_verdict.txt","filename":"variant_verdict.txt","size":722,"category":"other"},{"path":"bundle/logs/vuln_variant/variant_result.json","filename":"variant_result.json","size":559,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":161,"category":"other"},{"path":"bundle/logs/vuln_variant/latest_version.txt","filename":"latest_version.txt","size":161,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":11637,"category":"documentation"},{"path":"bundle/vuln_variant/create_variant_vsix.py","filename":"create_variant_vsix.py","size":3434,"category":"script"},{"path":"bundle/vuln_variant/vpub.vext-1.0.0.vsix","filename":"vpub.vext-1.0.0.vsix","size":2031,"category":"other"},{"path":"bundle/vuln_variant/application.yml","filename":"application.yml","size":2123,"category":"other"},{"path":"bundle/vuln_variant/write_verdict.py","filename":"write_verdict.py","size":1148,"category":"script"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":6815,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3394,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1799,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":3523,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":3959,"category":"other"}]}