# Variant Root Cause Analysis — CVE-2026-13323 (Open VSX Registry inline HTML serving)

## Summary

A materially-distinct **alternate trigger** of CVE-2026-13323 was found and validated against the real Open VSX Registry server: an attacker **smuggles an HTML file as the extension icon** by setting `"icon": "payload.html"` in `package.json` and shipping `extension/payload.html` (an HTML document with `<script>`) inside the VSIX. `ExtensionProcessor.getIcon()` performs **no icon-type validation**, so the HTML file is extracted at publish time and stored as the `ICON` `FileResource` (named `payload.html`). It is then served via a **different entry point and a different sink** than the original repro — `GET /api/{namespace}/{extension}/{version}/file/payload.html` (`RegistryAPI.getFile` → `LocalRegistryService.getFile` → `StorageUtilService.getFileResponse(FileResource)` → `LocalStorageService.getFile(FileResource)`) — instead of the repro's `GET /vscode/unpkg/{ns}/{ext}/{ver}/extension/payload.html` (`LocalVSCodeService.browse` → `getFileResponse(Path)`).

On the **vulnerable v1.0.1** server, this alternate endpoint serves the smuggled HTML with `Content-Type: text/html`, **no `Content-Security-Policy`**, and **no `Content-Disposition: attachment`** — i.e. the browser renders it inline in the registry origin and executes the embedded JavaScript. The icon URL is advertised in the extension metadata JSON (`files.icon = .../file/payload.html`), so it is exposed to victims exactly like the original CVE surface.

On the **fixed v1.0.2** server, the same alternate endpoint serves the same file as `Content-Type: text/plain;charset=utf-8` with the strict `Content-Security-Policy: default-src 'none'; ...`, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: DENY`. The fix therefore **covers this variant**. **This is an alternate trigger on the vulnerable version, NOT a bypass of the fix.**

## Fix Coverage / Assumptions

The v1.0.2 fix (commit `9491f32a6d459a4d499c5028d37c0d0386771e9f`) centralizes all file-serving response headers into `HttpHeadersUtil.createFileResponseHeaders(...)` (`server/src/main/java/org/eclipse/openvsx/util/HttpHeadersUtil.java`) and deletes the vulnerable extension-based `StorageUtil.getFileType(...)`.

- **Invariant the fix relies on**: *every* code path that serves a user-controlled file to an HTTP client now builds its headers via `createFileResponseHeaders`, which (a) defaults to `application/octet-stream`, (b) uses **Apache Tika content detection** (not the extension) to classify the file, (c) forces `text/plain;charset=UTF-8` for any text-viewable type (`text/html`, `text/plain`, `text/markdown`, `text/css`, `text/javascript`), (d) forces `Content-Disposition: attachment` for non-text files, and (e) **always** sets a strict `Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox` plus `nosniff` and `X-Frame-Options: DENY`.
- **Code paths explicitly covered**: `StorageUtilService.getFileResponse(Path)` and `getFileResponse(ArrayNode)`; `LocalStorageService.getFile(FileResource)` and `getNamespaceLogo(Namespace)`; `AwsStorageService`/`AzureBlobStorageService`/`GoogleCloudStorageService.uploadFile`; `UpstreamVSCodeService.streamResponse` and its JSON responses. All five serving endpoints (`/vscode/unpkg/`, `/vscode/asset/`, `/api/.../file/**` (×2 target-platform variants), `/api/{ns}/logo/{fileName}`) route through these.
- **What the fix does NOT cover / gaps** (analyzed, not exploitable for in-origin JS):
  - **Gap A — cloud (AWS S3) partial headers**: `AwsStorageService.uploadFile` only persists `Content-Type`, `Content-Disposition`, `Cache-Control`; the CSP/nosniff/X-Frame-Options are dropped on the S3 object. Not a bypass because the forced `text/plain`/`octet-stream`+attachment Content-Type already prevents inline HTML rendering, and the S3 origin differs from `open-vsx.org` (no session-cookie exfil).
  - **Gap B — `application/xml` passthrough has no `Content-Disposition`**: served inline as `application/xml` but with strict CSP + nosniff; modern browsers do not execute script in `application/xml` and CSP blocks any XSLT-driven load. Not exploitable.
  - **Gap C — publish-time extraction has no icon-type validation** (this variant): `ExtensionProcessor.getIcon()` is **unchanged** by the fix and accepts any file the manifest `icon` field points at. The smuggling path therefore exists in both versions; only the *serving* layer was hardened. This is the basis of the variant and is mitigated at serving time on v1.0.2.
  - **Gap D — namespace logos are pre-restricted** to `image/png`/`image/jpeg` at upload (`UserService.updateNamespaceDetailsLogo`), so HTML/SVG logos are rejected. Not a vector (confirmed negative).

## Variant / Alternate Trigger

**Variant — HTML smuggled as the extension ICON.**

- **Entry point**: `GET /api/{namespace}/{extension}/{version}/file/{iconName}` — e.g. `GET /api/vpub/vext/1.0.0/file/payload.html`. Controller: `RegistryAPI.getFile` (`server/.../RegistryAPI.java:611`).
- **Data path (smuggling)**: `ExtensionProcessor.getIcon()` (`server/.../ExtensionProcessor.java:506`) reads the file at `package.json`'s `icon` path with **no MIME/extension check**, sets `iconResource.setName(iconName)` ("payload.html") and `iconResource.setType(FileResource.ICON)` (lines 524–525). The icon `FileResource` is persisted and its URL advertised via `getFileUrls(..., ICON)`.
- **Serving path (v1.0.1, vulnerable sink)**: `LocalRegistryService.getFile(...)` (`LocalRegistryService.java:223`) → `storageUtil.getFileResponse(resource)` (line 233) → `LocalStorageService.getFile(FileResource)` (`LocalStorageService.java:89`) → `getFileResponseHeaders(name)` (`LocalStorageService.java:124`) → `StorageUtil.getFileType("payload.html")` (`StorageUtil.java:23`) → `URLConnection.guessContentTypeFromName` → `text/html`, with **no CSP** and **no `Content-Disposition`**.
- **Serving path (v1.0.2, fixed)**: same chain, but `LocalStorageService.getFile(FileResource)` now calls `HttpHeadersUtil.createFileResponseHeaders(path)` (`LocalStorageService.java:92`); Tika detects the HTML bytes as `text/html` → forced to `text/plain;charset=utf-8` + strict CSP. Safe.
- **Why it is materially distinct from the repro**: different controller (`RegistryAPI` vs `VSCodeAPI`), different handler (`getFile` vs `browse`), different lookup (pre-extracted DB `FileResource` by name vs on-demand VSIX web-resource extraction to a temp `Path`), different sink overload (`getFileResponse(FileResource)`/`LocalStorageService.getFile(FileResource)` vs `getFileResponse(Path)`). Same root cause (insecure headers on user content), same trust boundary (unauthenticated publisher → authenticated victim via the registry origin), same impact class.

Other candidate entry points examined and ruled out as separate vectors (they reach the same patched sinks, or are pre-restricted):
- `/vscode/asset/{ns}/{ext}/{ver}/{type}/{path}` (`LocalVSCodeService.getAsset`) — same `getFileResponse(Path)` sink; patched.
- `/api/{ns}/{ext}/{ver}/file/README|LICENSE|CHANGELOG` — README/CHANGELOG/LICENSE are extracted only from fixed `.md`/`.txt`/`extension/README` paths (`ExtensionProcessor.java:45-46`), never `.html`; not a vector.
- `/api/{ns}/logo/{fileName}` — logo upload validated to png/jpg only; not a vector.

## Impact

- **Package/component affected**: `org.eclipse.openvsx:openvsx-server` — `RegistryAPI.getFile` / `LocalRegistryService.getFile` / `StorageUtilService.getFileResponse(FileResource)` / `LocalStorageService.getFile(FileResource)` (v1.0.1 insecure sink); smuggling via `ExtensionProcessor.getIcon`.
- **Affected versions (as tested)**: alternate trigger reproduces on **v1.0.1** (commit `e92a1a7a448be08570cc4c4969717ed3e2260015`). **Not** reproducible on **v1.0.2** (commit `9491f32a6d459a4d499c5028d37c0d0386771e9f`) — the fix covers it.
- **Risk level / consequences (on v1.0.1)**: same as the parent CVE — JavaScript execution in the `open-vsx.org` origin via a victim visiting the advertised icon URL; enables session cookie/token exfiltration, persistent PAT generation, unauthorized publication of malicious extension versions, and downstream supply-chain impact (VS Code, VSCodium, Cursor, Windsurf). Medium severity.

## Impact Parity

- **Disclosed/claimed maximum impact** (parent CVE): session/token exfiltration, persistent PAT generation, unauthorized malicious extension publication, supply-chain attack.
- **Reproduced impact from this variant run**: confirmed that the alternate `/api/.../file/payload.html` endpoint serves the attacker-controlled HTML with `Content-Type: text/html` and **no CSP** on v1.0.1 — i.e. the exact header condition that lets a browser render the HTML inline and execute the embedded `<script>` (which reads `document.cookie`) in the registry origin. The smuggled icon URL is returned in the extension metadata `files.icon`, proving the surface is exposed to victims.
- **Parity**: `full` (HTTP-level header evidence is the same decisive proof used by the parent repro; the variant reaches the identical exploitable header state via a different entry point).
- **Not demonstrated**: actual browser-side JS execution and live token exfiltration (requires a headless browser + an authenticated victim session). This is the same limitation as the parent repro and is sufficient to prove the vulnerability.

## Root Cause

The same underlying bug — *user-controlled files served with a `Content-Type` that the browser renders as active content (HTML/SVG) and without a `Content-Security-Policy` or `Content-Disposition: attachment`* — is reachable from a second entry point because:

1. The vulnerable v1.0.1 sink (`StorageUtil.getFileType` + manual headers in `LocalStorageService.getFileResponseHeaders`) was invoked from **two independent call sites**: the on-demand web-resource path (`getFileResponse(Path)`, used by `/vscode/unpkg/` and `/vscode/asset/`) **and** the persisted-`FileResource` path (`LocalStorageService.getFile(FileResource)`, used by `/api/.../file/**`). The original repro exercised only the first.
2. The publish pipeline (`ExtensionProcessor.getIcon`) lets a publisher choose **any** file as the extension icon with no type check, so an HTML document can be placed into the persisted-`FileResource` serving path. The fix did not change `ExtensionProcessor` (it is not in the v1.0.1→v1.0.2 diff), so the smuggling vector is present in both versions.
3. On v1.0.1, the persisted-`FileResource` sink applies the same insecure `getFileType(name)` → `text/html` logic, producing the identical dangerous response as the repro.

The v1.0.2 fix removes `getFileType` and routes **both** sinks through `createFileResponseHeaders`, which uses Tika content detection and forces `text/plain` + strict CSP. Because the persisted-`FileResource` sink (`LocalStorageService.getFile(FileResource)`) was also rewired (line 92), the variant is mitigated on v1.0.2 — hence **alternate trigger, not a bypass**.

Fix commit: `9491f32a6d459a4d499c5028d37c0d0386771e9f` (tag `v1.0.2`).

## Reproduction Steps

1. **Reference**: `bundle/vuln_variant/reproduction_steps.sh`.
2. **What the script does** (self-contained, reuses the pre-built server jars from the repro stage at `bundle/logs/openvsx-server-v1.0.1.jar` and `bundle/logs/openvsx-server-v1.0.2.jar`):
   - Builds a VSIX whose `package.json` sets `"icon": "payload.html"` and that contains `extension/payload.html` (HTML with a `<script>` reading `document.cookie`).
   - Starts PostgreSQL 16.2 (port 5436) and, for each of v1.0.1 and v1.0.2, starts the real Open VSX server (Eclipse Temurin JDK 25), seeds a user + PAT, creates a namespace, and publishes the VSIX via `POST /api/-/publish`.
   - Requests the **variant** URL `GET /api/vpub/vext/1.0.0/file/payload.html` and the **control** URL `GET /vscode/unpkg/vpub/vext/1.0.0/extension/payload.html`, capturing response headers + body for both.
   - Fetches `GET /api/vpub/vext/1.0.0` to record the advertised `files.icon` URL.
   - Computes a verdict: `INLINE_HTML=1` iff `Content-Type: text/html` **and** no CSP.
3. **Expected evidence**:
   - v1.0.1 variant: `Content-Type: text/html`, no CSP, no `Content-Disposition` → `INLINE_HTML=1` (alternate trigger reproduced on vulnerable).
   - v1.0.2 variant: `Content-Type: text/plain;charset=utf-8`, strict CSP, `nosniff`, `X-Frame-Options: DENY` → `INLINE_HTML=0` (fix covers it).
   - Exit code: `1` (variant only on vulnerable; not a bypass).

## Evidence

### Log file locations
- `bundle/logs/vuln_variant/variant_repro.log` — full run transcript.
- `bundle/logs/vuln_variant/vuln_variant_headers.txt` — v1.0.1 **variant** response headers (the key proof).
- `bundle/logs/vuln_variant/vuln_variant_body.html` — HTML body served inline by v1.0.1.
- `bundle/logs/vuln_variant/fixed_variant_headers.txt` — v1.0.2 **variant** response headers (proves fix).
- `bundle/logs/vuln_variant/fixed_variant_body.html` — same HTML served as text/plain by v1.0.2.
- `bundle/logs/vuln_variant/vuln_control_headers.txt` / `fixed_control_headers.txt` — control (`/vscode/unpkg/`) headers.
- `bundle/logs/vuln_variant/vuln_publish.json` / `fixed_publish.json` — publish responses (show `files.icon = .../file/payload.html`).
- `bundle/logs/vuln_variant/vuln_metadata.json` / `fixed_metadata.json` — extension metadata advertising the icon URL.
- `bundle/logs/vuln_variant/vuln_icon_url.txt` / `fixed_icon_url.txt` — `http://localhost:8080/api/vpub/vext/1.0.0/file/payload.html`.
- `bundle/logs/vuln_variant/variant_result.json` — machine-readable verdict.
- `bundle/logs/vuln_variant/variant_verdict.txt` — human-readable verdict.
- `bundle/logs/vuln_variant/server_vuln_v1.0.1.log` / `server_fixed_v1.0.2.log` — server startup logs.
- `bundle/logs/vuln_variant/fixed_version.txt` / `latest_version.txt` — exact tested commit SHAs.

### Key excerpts

**v1.0.1 VARIANT** (`/api/vpub/vext/1.0.0/file/payload.html`) — inline HTML, no CSP:
```
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
```
(no `Content-Security-Policy`, no `Content-Disposition`)

**v1.0.2 VARIANT** (same URL) — safe:
```
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
```

**Publish metadata** (both versions) advertises the variant surface:
`"icon":"http://localhost:8080/api/vpub/vext/1.0.0/file/payload.html"`

**Verdict**: `variant_on_vulnerable=true`, `variant_bypass_on_fixed=false`.

### Environment details
- Server: Open VSX Registry built from source (eclipse-openvsx/openvsx), Spring Boot 3.5.14, embedded Jetty, JDK 25 (Eclipse Temurin).
- Database: PostgreSQL 16.2.
- Tested commits: v1.0.1 `e92a1a7a448be08570cc4c4969717ed3e2260015`; v1.0.2 `9491f32a6d459a4d499c5028d37c0d0386771e9f`.
- Scanning disabled in the test config (mirrors the repro config); production scanning does not reject HTML icon files (no icon-type validation exists in `ExtensionProcessor.getIcon`).

## Recommendations / Next Steps

1. **Add icon-type validation at publish** in `ExtensionProcessor.getIcon()` / the publish flow: reject icons whose Tika-detected MIME type is not an image (`image/png`, `image/jpeg`, `image/svg+xml`). This matches the validation already present for namespace logos (`UserService.updateNamespaceDetailsLogo`) and would block HTML smuggling into the `ICON` slot entirely — defense in depth on top of the serving-layer fix.
2. **Propagate the safety-net CSP/nosniff/X-Frame-Options to cloud objects** for AWS S3 (use `PutObjectRequest` response-header overrides or a CloudFront response-headers policy), Azure, and GCS, so the CSP safety net survives the 302 redirect to the cloud origin.
3. **Add `Content-Disposition: attachment` to the passthrough types** (`application/json`, `application/xml`) in `createFileResponseHeaders` so the only remaining inline-serving branch is the forced `text/plain`.
4. **Add integration tests** asserting safe headers for `.html`, `.svg`, `.js`, `.css`, a smuggled-HTML icon, and a namespace-logo SVG across `/vscode/unpkg/`, `/vscode/asset/`, and `/api/.../file/**`.

## Additional Notes

- **Idempotency**: confirmed — the script was run twice; both runs completed cleanly with identical results (exit 1, identical `variant_result.json`) and no leftover Docker containers (the `trap cleanup EXIT` removes `openvsx-pg-variant` / `openvsx-runtime-variant`).
- **Negative-control soundness**: the `/vscode/unpkg/` control path behaved identically to the variant on each version (text/html on v1.0.1, text/plain+CSP on v1.0.2), confirming the harness exercises the real serving code and that the fix changes behavior on both sinks.
- **Real product proof**: uses the actual Open VSX server (built from source) with a real PostgreSQL DB; the extension is published through the real `POST /api/-/publish` API and the file is served through the real `/api/.../file/**` and `/vscode/unpkg/` endpoints — not a mock.
- **Outcome classification**: this is a validated **alternate trigger** of the same root cause on the vulnerable version. The v1.0.2 fix **does** cover it (the persisted-`FileResource` sink was rewired to `createFileResponseHeaders`), so it is **not a bypass**. No bypass of the fix was found after examining all five serving endpoints, all four storage backends, the upstream proxy, the logo upload validation, and the publish extraction paths.
