Skip to content

Security

David Benson edited this page Oct 7, 2026 · 1 revision

This page describes the threat model of draw.io and the rules that code touching diagram content must follow. It is for anyone who forks the editor, writes plugins or custom shapes, embeds the editor or the viewer, or hosts draw.io. To report a vulnerability, see Reporting a vulnerability.

Threat model

A diagram is untrusted input. Anyone can craft a .drawio file, a PNG or SVG with an embedded diagram, a URL that opens a diagram, or a message to an embedded editor. The same diagram is rendered in several places, and some of them run inside someone else's page:

Where a diagram is rendered Runs in the origin of Code
The editor (app.diagrams.net, self-hosted, desktop) the editor js/grapheditor/, js/diagramly/
The viewer (viewer.min.js, <div class="mxgraph">) the host page GraphViewer.js
The lightbox (lightbox=1) the host that serves it (viewer.diagrams.net for lightbox links made by the editor) GraphViewer.js, App.js
Embed mode (embed=1 in an iframe) the editor, driven by the host page EditorUi.prototype.installMessageHandler in diagramly/EditorUi.js
Exported SVG and HTML wherever the file is opened or inlined Graph.prototype.getSvg, EditorUi export code

A malicious diagram is therefore a realistic attack: a crafted label in a diagram shown on a wiki page through the viewer is stored cross-site scripting for every reader of that page. Everything in a diagram is attacker-controlled: cell values and labels, custom properties (they become tooltips and placeholders), style strings, links, embedded images and fonts, page names, and the content of imported files (Visio, Lucidchart, Gliffy, Mermaid, CSV and others).

Labels are stored raw and sanitised when rendered

The model keeps labels exactly as written. HTML labels (style html=1) are sanitised only when they are rendered, in the cellRenderer.getLabelValue override that the Graph constructor installs in Graph.js:

  • Labels without html=1 are escaped with mxUtils.htmlEntities and shown as text.
  • Labels with html=1 go through Graph.sanitizeHtml, which calls Graph.domPurify and so DOMPurify (js/sanitizer/purify.min.js) with window.DOM_PURIFY_CONFIG (defined in grapheditor/Init.js). Hooks registered with DOMPurify.addHook in Graph.js add draw.io's own checks, for example on CSS (see Styles and resources).
  • <style> elements in labels are scoped with Graph.scopeStyleElements so that they cannot restyle the page around the diagram.

The editor, the viewer and the exports all render labels through this path, so the plain rendering is safe. The rule is: any code that reads a label itself and puts it into the DOM as HTML must sanitise it itself. That includes graph.getLabel(cell), graph.convertValueToString(cell), cell.value and cell.value.getAttribute('label').

Graph.prototype.getIndexableText is a good example of reading labels correctly:

if (this.isHtmlLabel(cell))
{
	tmp.innerHTML = Graph.sanitizeHtml(this.getLabel(cell));
	label = mxUtils.extractTextWithWhitespace([tmp]);
}

The same applies to custom shapes. A shape that paints a label on its own with c.text(..., format, ...) and format set to 'html' ends up in mxSvgCanvas2D.prototype.createDiv, which assigns the string to innerHTML. Sanitise it first, as Shapes.js does where a shape paints a copy of its label:

var format = mxUtils.getValue(this.style, 'html', '0') == '1' ? 'html' : '';

if (format == 'html')
{
	label = Graph.scopeHtmlStyles(Graph.sanitizeHtml(label));
}

c.text(x, y, w, h, label, align, valign, wrap, format, null, false, 0, null);

Plain text painted with an empty format is escaped by the canvas and needs nothing extra.

Do not weaken window.DOM_PURIFY_CONFIG. It is defined as window.DOM_PURIFY_CONFIG || {...}, so a host page or PreConfig.js can replace it, but every allowed tag or attribute added there applies to every label of every diagram.

HTML sinks and safe helpers

Treat these as injection sinks whenever any part of the string comes from a diagram, a file, a URL, a message or a storage provider:

Sink Use instead
innerHTML, outerHTML, insertAdjacentHTML document.createElement + mxUtils.write(elt, text) or textContent; Graph.sanitizeHtml(html) if markup is really needed
document.write DOM APIs
setAttribute('href' / 'src', ...), window.open Graph.sanitizeLink and graph.openLink (see Links)
setAttribute('on...', ...), eval, new Function, setTimeout with a string Never with external input
<script> elements, mxscript(url) Only with URLs of your own deployment
Canvas text(..., 'html', ...) Graph.sanitizeHtml first
HTML strings for tooltips, dialogs, status messages mxUtils.htmlEntities(value) for every value

When you build an HTML string, escape each value. Graph.prototype.getTooltipForCell builds the tooltip of a cell from its custom properties like this:

tip += ((temp[i].name != 'link') ? '<b>' + mxUtils.htmlEntities(temp[i].name) +
	':</b> ' : '') + mxUtils.htmlEntities(value) + '\n';

Dialog and status APIs that accept HTML do the same with their input, for example EditorUi.prototype.handleError escapes the error message with mxUtils.htmlEntities before it calls showError, and the dialog action of the embed protocol escapes message. ErrorDialog additionally runs the message through Graph.sanitizeHtml. When you add such an API, document whether it takes text or HTML and escape at the caller.

// Unsafe: page names come from the file
div.innerHTML = '<b>' + mxResources.get('pages') + ':</b> ' + page.getName();

// Safe
var b = document.createElement('b');
mxUtils.write(b, mxResources.get('pages') + ':');
div.appendChild(b);
mxUtils.write(div, ' ' + page.getName());

Links

Links come from the link attribute of a cell, from <a href> in HTML labels and from custom link actions. Do not build href values or window.open calls by hand; use the helpers in Graph.js:

Helper What it does
Graph.sanitizeLink(href) Returns the link if DOMPurify keeps it as the href of an anchor, otherwise null. DOM_PURIFY_CONFIG.ALLOWED_URI_REGEXP rejects any URI that contains javascript:.
graph.getLinkForCell(cell) Returns the link attribute with any leading javascript: removed by mxUtils.removeJavascriptProtocol. Only pass allowUnsafe = true when the value is not used as a link.
graph.openLink(href, target, allowOpener) Sanitises the link, then opens it. New windows get noopener,noreferrer unless allowOpener is true.
Graph.isSafeLinkTarget(target) Accepts only _blank, _self, _top and _parent as targets of label links.
graph.isCustomLink(href) True for data: links, which the app handles itself and never navigates to.

Graph.prototype.labelLinkClicked shows the pattern: a label link that does not survive Graph.sanitizeLink is sanitised in place, custom links are left to the app, and everything else goes through openLink.

Custom links are data:page/id,... (go to a page, Graph.isPageLink) and data:action/json,{...} (Graph.prototype.handleCustomLink in diagramly/Editor.js). The JSON is parsed and run by Graph.prototype.executeCustomActions, which only knows a fixed set of declarative actions (for example show, hide, toggle, style, select, highlight, scroll, viewbox, page, open, wait and the animation effects). None of them evaluates code, and the open action uses openLink. If you add an action, keep it declarative and route every URL through the same helpers. The action format is documented at Animation actions JSON.

Styles and resources

Style strings are untrusted too. Values that make the browser load something need checks:

  • CSS in HTML labels. The DOMPurify hooks in Graph.js pass every style attribute and every <style> element in a label to Graph.isStyleAllowed. It rejects @import and any reference that is not relative or a data: URL, whichever CSS construct carries it. A declaration that fails is removed. Values with characters that would be dropped when the markup is serialised are removed as well, and attribute values that start with markup are dropped.
  • Fonts. fontSource URLs are checked by Graph.isValidFontUrl (only http:, https:, data: and relative URLs), and font names and URLs are escaped with Graph.escapeCssString before they are written into @font-face rules.
  • SVG <use> in labels may only reference fragments (#id) of the same document.

If you add a style that loads a resource or ends up in CSS, validate the URL with an allowlist of schemes and escape the value for its context, as Graph.createFontElement does.

The graph library has several switches that would evaluate strings taken from a document. They are off by default and must stay off for untrusted diagrams: mxObjectCodec.allowEval, mxObjectCodec.allowInclude, mxStylesheetCodec.allowEval, mxStencil.allowEval and mxGraphView.prototype.allowEval.

Parsing untrusted XML and JSON

mxUtils.parseXml parses with the browser's DOMParser, which does not load external entities or DTDs. If you read .drawio files outside the browser (Node.js, Java, Python), configure your XML parser to refuse DTDs and external entities; the format never needs them. The structure of the format is described in File format.

Keys from documents and messages must never reach Object.prototype:

  • mxCodec keeps its ID lookups in Object.create(null) maps, so a cell ID such as __proto__ is an ordinary key.
  • mxObjectCodec.isPollutionKey(name) rejects __proto__, constructor and prototype as field names during decoding.
  • EditorUi.isPrototypePollutionKey(key) in DiffSync.js does the same for patches that arrive through collaboration and the embed protocol.

Follow the same pattern in new code: use Map or Object.create(null) for lookups keyed by IDs or names from a file, and skip the three dangerous keys when copying properties from parsed JSON or style strings, as Graph.js does when it reads styles:

if (key != '__proto__' && key != 'constructor' && key != 'prototype')
{
	result[key] = value;
}

Plugins

A plugin is an ordinary script in the editor page. It has full access to the page, the diagram, the user's signed-in storage and everything the user can do, so only load plugins you have reviewed like your own code. Plugins that show diagram content must follow the rules on this page. Writing plugins is covered in Plugins.

Loading is restricted in App.main (App.js):

Source What is loaded
p=id1;id2 URL parameter Only IDs listed in App.pluginRegistry (files in plugins/ of the deployment)
Plugins the user added in the Plugins dialog (stored in the settings) Only URLs on the same origin as the editor (App.isSameDomain), and only the built-in plugins unless window.ALLOW_CUSTOM_PLUGINS is true
plugins=0 or offline=1 Nothing

Blocked entries are logged to the console as Blocked plugin: or Unknown plugin:. Leave ALLOW_CUSTOM_PLUGINS at its default false unless you control every script on your origin; with it set, the Plugins dialog lets users add any same-origin script.

Embedding

When you embed the editor (embed=1), the editor only accepts messages whose evt.source is its parent or opener window, and it posts its replies, including the diagram XML, to that window. Your page must check both the origin and the source of every message it receives, and send its own messages to the editor's origin only:

var editorOrigin = 'https://embed.diagrams.net';
var iframe = document.createElement('iframe');
iframe.src = editorOrigin + '/?embed=1&proto=json&spin=1';
document.body.appendChild(iframe);

window.addEventListener('message', function(evt)
{
	if (evt.origin != editorOrigin || evt.source != iframe.contentWindow)
	{
		return;
	}

	var msg = JSON.parse(evt.data);

	if (msg.event == 'init')
	{
		iframe.contentWindow.postMessage(JSON.stringify(
			{action: 'load', xml: xml}), editorOrigin);
	}
	else if (msg.event == 'save')
	{
		// msg.xml is user content: store it, do not insert it as HTML
	}
});

The full protocol is documented at Embed mode and in Embedding.

Treat the XML, SVG and HTML you get back as user content:

  • Display stored diagrams with the viewer or the lightbox, which sanitise labels, rather than with your own rendering of the XML.
  • An exported SVG shown with <img src> cannot run scripts. Inlining SVG markup into your page puts it in your origin; if users can supply the file, sanitise it.
  • viewer.min.js renders into your page and runs in your origin. If you show diagrams from sources you do not trust, consider the lightbox or the viewer in an iframe on a separate origin, so that the diagram is isolated from your application.

Self-hosting

  • Keep up to date. Fixes are released in new versions only; there are no backports to older releases (see SECURITY.md). Track the releases and the advisories.
  • Configuration is public. PreConfig.js, PostConfig.js, DRAWIO_CONFIG and the editor configuration are delivered to every browser that loads the editor. Never put secrets such as API keys there unless every user may see them.
  • /proxy is a server-side request surface. ProxyServlet (source) fetches URLs on behalf of the browser (remote images, fonts and diagrams). It answers 404 unless the environment variable ENABLE_DRAWIO_PROXY=1 is set. When enabled, Utils.validatedAddress only allows http and https on default ports (more via DRAWIO_PROXY_ALLOWED_PORTS), refuses loopback, private, link-local and other reserved addresses (ALLOW_INTERNAL_10_NET=1 opens 10.0.0.0/8), connects to the address it checked, re-checks each redirect and limits the response size. Enable it only if you need it, do not widen the defaults without a reason, and restrict the server's outbound network access as for any service that makes requests for its users.
  • /export (ExportProxyServlet) forwards export requests to the service named by the EXPORT_URL environment variable. Point it only at a service you run.
  • Expose only what you use. The servlets of the Java back end are mapped in WEB-INF/web.xml. If your users only need the editor, serve src/main/webapp as static files or block the endpoints you do not need in your reverse proxy.
  • Lockdown. The lockdown URL parameter and the lockdown configuration key stop data from leaving the browser except to the chosen storage; see Data governance and lockdown.
  • Content-Security-Policy. index.html has no inline scripts, so a strict script-src works. The policies used for app.diagrams.net and the viewer are written in Devel.js between CSP-POLICIES-BEGIN and CSP-POLICIES-END; development mode applies the development variant as a <meta> tag, and ?dev=1&print-csp=1 prints the production variants to the console. Use them as a starting point and remove the hosts of the storage backends you do not offer. Add frame-ancestors to control which sites may frame your editor. The Docker image injects its own policy, which DRAWIO_CSP_HEADER overrides.

More on deployment in Self-hosting and Configuration.

Reporting a vulnerability

Report vulnerabilities privately through GitHub: Security tab > Report a vulnerability. Do not open a public issue, pull request or discussion for a security problem. The policy is in SECURITY.md.

  • In scope: the latest version in this repository, or the version deployed at app.diagrams.net if it differs.
  • Out of scope: the PlantUML integration and the www.drawio.com web site.
  • Include: a description and the impact, the version or URL, steps to reproduce (ideally a minimal proof of concept or sample diagram), and logs or screenshots.

Reports are acknowledged and investigated privately. Please allow reasonable time for a fix before public disclosure; reporters are credited in the advisory unless they prefer to stay anonymous.

Published advisories

Fixed vulnerabilities are published as GitHub security advisories at https://github.com/jgraph/drawio/security/advisories. Check them when you maintain a fork or run an older version: each advisory lists the affected versions.

See also

Clone this wiki locally