# Patch Analysis — CVE-2026-13323 (Open VSX Registry inline HTML serving)

## Summary

CVE-2026-13323 is the Open VSX Registry serving **user-supplied files extracted from published VSIX packages** with insecure HTTP response headers (`Content-Type: text/html`, no `Content-Security-Policy`, no `Content-Disposition: attachment`), causing browsers to render attacker-controlled HTML/JavaScript inline in the `open-vsx.org` origin. The fix in **v1.0.2** (commit `9491f32a6d459a4d499c5028d37c0d0386771e9f`) centralizes all file-serving response-header generation into a new `HttpHeadersUtil.createFileResponseHeaders(...)` and removes the vulnerable `StorageUtil.getFileType(...)`.

## Target threat model (from `SECURITY.md`)

`SECURITY.md` only defines a reporting process and "supports the latest released version". It does **not** carve out any "not a vulnerability" exception for served file content, MIME handling, or stored XSS. There is no statement that "serving user-uploaded files inline is by design." Therefore, inline-rendering of attacker-controlled HTML/JS in the registry origin is squarely within the project's security scope. (E.g., the `SECURITY.md` reporting template explicitly lists "cross-site scripting" as an in-scope issue type.)

## What the fix changes

### New central helper — `server/src/main/java/org/eclipse/openvsx/util/HttpHeadersUtil.java`

`HttpHeadersUtil.createFileResponseHeaders(@Nullable InputStream, @Nullable String fileName)`:

1. Defaults `Content-Type` to `application/octet-stream`.
2. **Always** sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`.
3. Special-cases `.vsix` → `application/octet-stream`, `.sigzip` → `application/zip` (both with `Content-Disposition: attachment`, no cache control).
4. For every other file, runs **Apache Tika** content+name detection (`tika.detect(inputStream, fileName)`):
   - If the detected type is in `TEXT_VIEWABLE_MEDIA_TYPES` (`text/plain`, `text/html`, `text/markdown`, `text/css`, `text/javascript`) → **forces `Content-Type: text/plain;charset=UTF-8`** (so the browser never renders HTML/JS), no `Content-Disposition`.
   - Else if in `PASSTHROUGH_MEDIA_TYPES` (`application/json`, `application/xml`) → passes that type through, no `Content-Disposition` (still guarded by CSP + nosniff).
   - Else → keeps `application/octet-stream` + sets `Content-Disposition: attachment` (forces download). Cache control applied.
5. **Always** sets `Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox` (`CSP_PREVENT_ALL`).
6. Adds a filename `sanitize(...)` for the `Content-Disposition` filename (strips path separators, header-injection chars, control chars; truncates to 255).

Overloads: `createFileResponseHeaders(Path)`, `createFileResponseHeaders(Path, String)`, `createJsonFileResponseHeaders()`.

### Removed — `server/src/main/java/org/eclipse/openvsx/storage/StorageUtil.java`

The vulnerable `getFileType(String fileName)` (extension-based `URLConnection.guessContentTypeFromName` → `text/html` for `.html`) was **deleted**. `StorageUtil` now only retains `getCacheControl` (changed from 30 days to 1 day + `mustRevalidate`).

### Consumers rewired to the new helper (v1.0.2)

| File | Method | Before (v1.0.1) | After (v1.0.2) |
|------|--------|-----------------|----------------|
| `StorageUtilService` | `getFileResponse(Path)` | manual headers via `StorageUtil.getFileType` | `HttpHeadersUtil.createFileResponseHeaders(path)` |
| `StorageUtilService` | `getFileResponse(ArrayNode)` | manual `APPLICATION_JSON` | `createJsonFileResponseHeaders()` |
| `LocalStorageService` | `getFile(FileResource)` | `getFileResponseHeaders(name)` → `getFileType` | `createFileResponseHeaders(path)` |
| `LocalStorageService` | `getNamespaceLogo(Namespace)` | `getFileResponseHeaders(logoName)` → `getFileType` | `createFileResponseHeaders(path)` |
| `AwsStorageService` | `uploadFile(...)` | `metadata Content-Type = getFileType` | Content-Type/Disposition/Cache-Control from `createFileResponseHeaders` |
| `AzureBlobStorageService` | `uploadFile(...)` | `setContentType(getFileType)` | headers from `createFileResponseHeaders` |
| `GoogleCloudStorageService` | `uploadFile(...)` | `setContentType(getFileType)` | headers from `createFileResponseHeaders` |
| `UpstreamVSCodeService` | `streamResponse(...)` | proxied upstream headers pass-through | downloads to temp file, re-applies `createFileResponseHeaders` |
| `UpstreamVSCodeService` | JSON error/response (l.174) | manual | `createJsonFileResponseHeaders()` |

### File-serving entry points (controllers) and which sink they reach

| Endpoint | Handler | Sink (v1.0.2) | Patched? |
|----------|---------|---------------|----------|
| `GET /vscode/unpkg/{ns}/{ext}/{ver}/{path}` | `VSCodeAPI`→`LocalVSCodeService.browse` | `getFileResponse(Path)` | ✅ |
| `GET /vscode/asset/{ns}/{ext}/{ver}/{type}/{path}` | `VSCodeAPI`→`LocalVSCodeService.getAsset` | `getFileResponse(Path)` / `getFileResponse(FileResource)` | ✅ |
| `GET /api/{ns}/{ext}/{ver}/file/**` | `RegistryAPI.getFile`→`LocalRegistryService.getFile` | `getFileResponse(FileResource)`→`localStorage.getFile` | ✅ |
| `GET /api/{ns}/{ext}/{tp}/{ver}/file/**` | same | same | ✅ |
| `GET /api/{ns}/logo/{fileName}` | `RegistryAPI.getNamespaceLogo`→`LocalRegistryService.getNamespaceLogo` | `getNamespaceLogo`→`localStorage.getNamespaceLogo` | ✅ (also restricted to png/jpg at upload) |

## Fix assumptions

1. **Centralization assumption**: every file-serving path now goes through `createFileResponseHeaders`. The fix assumes no other code path sets `Content-Type`/serves user files with manual headers.
2. **Content-based detection**: Tika reads the actual bytes (not just the extension) to decide text-viewable vs. download. This defeats extension tricks (e.g. a `.html` file, or an extensionless HTML file).
3. **CSP as a safety net**: even if a `Content-Type` slipped through, the strict CSP blocks script/ network/ framing.
4. **Cloud storage headers**: for AWS/Azure/GCS, protective headers are baked into the object **at upload time** (the registry later 302-redirects to the object URL).

## What the fix does NOT cover / gaps analyzed

### Gap A — Cloud-provider response headers are partial (AWS S3)
`AwsStorageService.uploadFile` only persists `Content-Type`, `Content-Disposition`, `Cache-Control` as S3 object metadata. The `Content-Security-Policy`, `X-Content-Type-Options`, and `X-Frame-Options` set by `createFileResponseHeaders` are **not** propagated to S3 (S3 does not treat them as system metadata when placed in the `metadata` map). **However**, because the fix forces `Content-Type` to `text/plain` for HTML and `application/octet-stream` + `Content-Disposition: attachment` for everything else, S3-served objects cannot be rendered as inline HTML. The Content-Type alone is sufficient mitigation; the missing CSP/nosniff is defense-in-depth that is lost on S3. Also the S3 bucket is a **different origin** from `open-vsx.org`, so the session-cookie exfiltration angle of the original CVE does not apply. → Not a bypass of the in-origin issue.

### Gap B — `application/xml` passthrough has no `Content-Disposition`
`PASSTHROUGH_MEDIA_TYPES` (`application/json`, `application/xml`) are served inline (no attachment) with their native type. `application/xml` is not executed as script by modern browsers, and on local storage the strict CSP (`default-src 'none'; sandbox`) plus `nosniff` neutralize any XSLT/script attempt. → Not exploitable for in-origin JS execution.

### Gap C — The fix did NOT touch the publish/extraction validation
`ExtensionProcessor.java` is **unchanged** between v1.0.1 and v1.0.2. In particular `getIcon()` reads whatever file `package.json`'s `icon` field points at and stores it as the `ICON` `FileResource` **with no MIME/extension validation**. So an attacker can still **smuggle an HTML file as the extension icon** in both versions. The difference is purely in serving: v1.0.1 serves it as `text/html` (inline, no CSP); v1.0.2 forces `text/plain` + CSP. → This is the basis of the variant tested here (an **alternate entry point**), and the fix **does** cover it at the serving layer, so it is **not a bypass**.

### Gap D — Namespace logo upload is pre-restricted
`UserService.updateNamespaceDetailsLogo` validates the logo via Tika to `image/png` or `image/jpeg` only and derives the stored filename from the detected type. So an HTML/SVG logo is rejected at upload. → Not a vector (confirmed negative).

## Behavior before vs. after

| File served | v1.0.1 response | v1.0.2 response |
|-------------|-----------------|------------------|
| `payload.html` via `/vscode/unpkg/` | `Content-Type: text/html`, no CSP, no `Content-Disposition` (renders inline, JS executes) | `Content-Type: text/plain;charset=utf-8`, strict CSP, `nosniff`, `X-Frame-Options: DENY` (plain text) |
| HTML smuggled as icon via `/api/{ns}/{ext}/{ver}/file/{iconName}.html` | `Content-Type: text/html`, no CSP (renders inline) | `Content-Type: text/plain;charset=utf-8`, strict CSP (plain text) |
| `package.json` / manifest | `application/json` / `text/xml`, no CSP | `application/json` / `application/xml` passthrough + CSP + nosniff |
| `.svg` (scripted) | `image/svg+xml`, no CSP (renders inline, script executes) | `application/octet-stream` + `Content-Disposition: attachment` + CSP (forced download) |
| `.vsix` download | `application/octet-stream` + attachment | `application/octet-stream` + attachment + CSP |

## Completeness assessment

The fix is **complete for the in-origin inline-rendering issue** described by the CVE. It centralizes header generation, switches from extension-based to Tika content-based MIME detection, forces `text/plain` for all text-viewable types, forces `Content-Disposition: attachment` for non-text, and always sets a strict CSP + `nosniff` + `X-Frame-Options`. All five file-serving endpoints and all four storage backends (local/AWS/Azure/GCS) plus the upstream proxy were rewired.

**No bypass found.** The materially-distinct alternate entry point discovered (HTML smuggled as the extension **icon**, served via `RegistryAPI.getFile` → `LocalRegistryService.getFile` → `LocalStorageService.getFile(FileResource)`) reaches the same vulnerable sink in v1.0.1 but is **also mitigated** in v1.0.2 because `LocalStorageService.getFile(FileResource)` now uses `createFileResponseHeaders(path)` and Tika forces the HTML content to `text/plain`. It is therefore an **alternate trigger on the vulnerable version**, not a bypass of the fix.

### Recommendation to close residual gaps (defense in depth)

1. **Add icon-type validation at publish** (`ExtensionProcessor.getIcon` / publish flow): reject icons whose detected MIME type is not an image (`image/png`, `image/jpeg`, `image/svg+xml`). This would prevent HTML smuggling into the ICON slot entirely and aligns with the namespace-logo validation already present.
2. **Propagate CSP/nosniff/X-Frame-Options to cloud objects**: for AWS S3 use `PutObjectRequest` response-header overrides (or a CloudFront response-headers policy) so the safety-net CSP is present even on the cloud origin; do the same for Azure/GCS.
3. **Add `Content-Disposition: attachment` to the passthrough types too** (json/xml) so they are never rendered inline, removing the only remaining inline-serving branch.
4. **Add integration tests** asserting safe headers for `.html`, `.svg`, `.js`, `.css`, smuggled-icon HTML, and namespace-logo SVG across `/vscode/unpkg/`, `/vscode/asset/`, and `/api/.../file/`.
