| Version | Supported |
|---|---|
| 9.1.x | Yes |
| < 9.1 | No |
(Table corrected 2026-09-20 — it previously still said 7.13.x.)
Development continues as an ongoing side project — see docs/ROADMAP.md (the "Security & platform hardening" section tracks every open item from the 2026-09-20 audit).
Only the latest release receives security updates. Kanvaz is a solo-maintained open-source project — backporting fixes to older versions is not feasible.
Development reopened after v5.3.0 for the v6.x arc (see README.md/CHANGELOG.md/ docs/ROADMAP.md) — this does not change how a real, confirmed security vulnerability is handled. Report it the same way described below; it will still be fixed and released.
Do not open a public issue for security vulnerabilities.
Email atharva.patil.cg@gmail.com with:
- A description of the vulnerability
- Steps to reproduce
- The impact (what an attacker could do)
- Your Kanvaz version and OS
You'll receive an acknowledgment within 48 hours. Fixes for confirmed vulnerabilities will be released as soon as possible and credited in the changelog (unless you prefer to remain anonymous).
Kanvaz is a 100% offline desktop application. Key points:
- No network calls except a small, fixed set of user-initiated ones, never
automatic, never anything else:
- "Check for updates" (About screen) — two requests to
api.github.com. - "Browse Official Plugins" (Settings → Plugins, added 4.4.0) — one request
to
raw.githubusercontent.comfor a small, static plugin-catalog JSON file, plus one more per plugin you actually choose to install (agithub.com/.../releases/download/...zip; the main process refuses anydownloadUrlthat isn'thttps://ongithub.com). - URL card "Fetch preview" button (added 5.0.0) — only fires when you
click it on a specific URL card, never on paste/type/load. Requests the
page's own HTML (capped at 512KB) to read its
<title>/og:titleandog:image, then one more capped request (2MB) for the image itself if present. The fetched title/image are embedded into the card and saved with the board — reopening a board with an existing preview never re-fetches anything. - Every network call is made by the main process, never a raw
fetch()from the renderer or a plugin's own script — see the CSP note below.
- "Check for updates" (About screen) — two requests to
- MCP Bridge (added 4.4.0, off by default) is local-only, never a network
feature. When enabled, Kanvaz listens on a named pipe (Windows) or a Unix
domain socket (macOS/Linux) for an already-running MCP client on the same
machine — never a TCP port, never reachable from another computer, and nothing
it does makes an outbound connection either. See its own README
(
official-plugins/mcp-bridge/README.md) for exactly what it can do once enabled: every change it makes goes through the same functions the UI itself uses, so it's undo-reversible like any manual edit. - No telemetry, analytics, or tracking of any kind.
- No accounts or authentication — there's nothing to log into.
- No remote code execution — as of 4.1.0, a
.kanvazfile is a zip container (board.jsonplus one file per embedded asset, each with a SHA-256 integrity hash) instead of one giant base64-encoded JSON blob.board.jsonitself is parsed withJSON.parse(), nevereval(). Files saved by 4.0.1 and earlier (plain JSON with base64 media inline) still open exactly as before — Kanvaz detects the format automatically and only ever writes the new container going forward. - Content Security Policy is enforced via Electron's CSP header, blocking
inline scripts and restricting network access. As of 4.2.0,
script-srcalso allowsfile:— narrowly, only to load a user-installed plugin's own entry script (see the Plugin System section below). This did not addunsafe-inlineorunsafe-eval; those remain fully blocked.connect-srcis still scoped to exactly'self' https://api.github.com— the 4.4.0 catalog fetch deliberately does NOT addraw.githubusercontent.comthere; it's fetched by the main process (which isn't CSP-constrained) specifically so no renderer/plugin script gains a new fetchable host as a side effect of this feature existing. - All data stays local — your
.kanvazfiles never leave your machine. - 3D model parsing (added 7.4.0, extended v8.x) — a
.glb/.gltf/.obj/.fbx/.stl/.ply/.vox/.usd/.usda/.usdc/.usdzdropped onto a board is parsed entirely in the renderer by a vendored Three.js (src/vendor/three/, pinned at a specific version, zero native dependencies) — the same trust model already accepted for pdf.js parsing untrusted PDFs (7.2.0) and Chromium's own image/video/audio decoders parsing untrusted media: a malicious file could in principle exploit a bug in the parser itself, but there is no elevated privilege gained by doing so — the renderer already has zero direct filesystem/OS access (contextIsolation: true,nodeIntegration: false), so a compromised parse is contained to whatever a compromised renderer could already do. The.usdzpath additionally decompresses a zip archive client-side via a vendoredfflate(Three.js's own bundled dependency, not this app's own code) before parsing its contents — the same "parser bug has no elevated privilege" reasoning applies to the decompression step too. Themodel-loadIPC handler enforces its own 150MB size cap and a strict extension allowlist (all eleven formats above) before reading any bytes, same pattern asmedia-load. .blendsupport via an optional external tool (added v8.x) — a genuinely new capability, not just another parsed format. Unlike every format above,.blendhas no in-app parser at all: if a locally installed Blender is found, themodel-convert-externalIPC handler shells out to it in headless mode (child_process.spawn, a real argument array — never a shell string, so there is no command-injection surface even though the input path is renderer-supplied) to convert to.glb, which is then parsed by the same trusted path as any other.glb. This is the first place this app spawns an external process, and per the disclosure above (any installed plugin can call anyKanvazBridgemethod, including this one, unscoped) — a plugin can already trigger a Blender conversion the same way it can already read or write an arbitrary file. This doesn't hand a plugin anything it couldn't already reach some other way (arbitrary file read/write is already disclosed above), but it's worth naming explicitly: it can now also cause an installed copy of Blender to run and produce output on disk, not just read/write files directly. The output path is always generated by this app itself (a fresh, unpredictable temp filename), never renderer-supplied, closing off the "trick it into overwriting an arbitrary file via the output path" variant of this risk.
Kanvaz supports third-party plugins (Settings → Plugins → Add a Plugin…). Read this section before installing one from anywhere other than Kanvaz's own official-plugins releases.
- Plugins are never auto-discovered or auto-run. A plugin only loads
after you review it in Settings and approve it in a native OS dialog
(
dialog.showMessageBox, not a web page element) — something no script running inside Kanvaz can script, click, or forge on your behalf. - The sandbox model is convention-based, not process-isolated. This is a deliberate design choice, the same trust model browser extensions and VS Code extensions use — not an oversight. A plugin's entry script runs in the same renderer page context as the rest of Kanvaz, not in a separate sandboxed process, iframe, or worker. Full per-plugin process isolation was evaluated for the 4.4.0 stretch and explicitly declined (multi-week rearchitect, not worth it against a two-version budget and a two-plugin official ecosystem) — tracked as deliberate future work, not a gap anyone missed.
- As of 4.4.0, permission-gated capabilities are genuinely absent from an
unapproved plugin's own view of
KanvazPluginAPI— not just undocumented, and not just at the object-property level. Concretely:KanvazPluginAPI. mcpBridge(the only gated namespace that exists today, unlocked by theserverpermission) is a real object on the API view a plugin's own script sees ONLY if its manifest declaresserverand the user approved it. Closes the specific honesty gap the 4.2.0 release first disclosed below, verified by an automated browser test (test/plugin-scope-test.js) that loads two plugins side by side, one with the permission and one without, and asserts the one without it truly cannot reach it — including the specific bypass an early draft of this actually shipped with and the test now guards against: the scope-builder function itself used to be copied into every plugin's own scoped object, so ANY plugin could call it on itself with a forged permission list and synthesize full access. Caught and fixed before release, not after; the test asserts that path is closed. - What this does NOT change, and where the real remaining exposure is: a
plugin's script still shares the renderer's page context, and the
KanvazPluginAPIscoping above is enforced only at the JS-object level — by which object the barewindow.KanvazPluginAPIidentifier happens to resolve to at the moment a plugin's own top-level code runs synchronously, not by any process, memory, or IPC-transport boundary.window.KanvazBridgeitself — the underlying preload-exposed bridgeKanvazPluginAPI.mcpBridgeis a thin wrapper over — is not scoped per plugin; it's the one flat object every loaded script shares. Concretely, this means:- Any plugin, even one declaring zero permissions, can call
window.KanvazBridge.on('mcp-invoke', ...)directly and receive every request meant for the approved MCP Bridge plugin, or callwindow.KanvazBridge.startMcpBridge()directly and succeed once MCP Bridge has ever been approved+enabled — bypassingKanvazPluginAPI. mcpBridge's gating entirely, because Electron gives the main process no way to tell WHICH script in a shared page context made a given IPC call. This is genuinely new risk, not the pre-existing "shared page context" trade-off restated: before 4.4.0 there was no channel to an external, off-machine process at all. If you enable MCP Bridge, only install OTHER plugins you trust just as much — not only the plugin declaringserver. Reducing this further requires the same real per-process isolation declined below; nothing short of that fully closes it, thoughKanvazPluginAPI.mcpBridge.onInvoke()replacing (not stacking) the previous listener at least means only one script's handler is ever live at a time, not an open-ended broadcast to N simultaneous listeners. cardTypes/commands/network/filesystemin the manifest remain informational-only in the consent dialog text, exactly as before —serveris the only namespace with real object-level gating, because it's the only capability dangerous enough (a local listener another process can connect to and drive your board through) to be worth building that for in this pass. A plugin declaring zero permissions is still, for everything except reachingmcpBridgeat load time, not meaningfully more restricted at the code level than one declaring several.- A malicious zero-permission plugin loaded BEFORE a privileged one could
install a property trap on
window.KanvazPluginAPI(e.g.Object.defineProperty(window, 'KanvazPluginAPI', {set: ...})) that fires when the loader later assigns a scoped API object for a different, privileged plugin — capturing that object even without still being "active" at the moment of injection. This is a real extension of the same disclosed root cause above (a single shared, plainly-reassignable global, not a per-plugin channel), evaluated during a 7.x-line audit and intentionally left undefended for now: a real fix (freezing the property descriptor, or moving off a bare global entirely) is exactly the kind of narrow-vs-structural trade-off this section already argues shouldn't be patched piecemeal — it needs the same real per-process isolation decision as everything else in this section, not a defineProperty arms race against whatever trap technique comes next. (A separate, earlier theory from that same audit — thatregisterTheme()'sdocument.dispatchEvent('kanvaz-theme- registered', ...)could leak a privileged API object to an eavesdropping listener — does not hold up: that event'sdetailonly ever carries a theme's{id, name}, never an API reference. Recorded here so a future session doesn't re-spend time chasing it.) - Once MCP Bridge is running, the pipe/socket itself has no per-connection
authentication beyond "you're a process on this machine" — any local
process running as the same OS user can connect and issue tool calls,
not only the intended
server.jsshim. This is what "local IPC only" protects against network exposure, not against another local program.
- Any plugin, even one declaring zero permissions, can call
- The practical guidance: only approve plugins from developers you trust, the same way you'd vet a browser extension before installing it. Kanvaz's own official plugins (published as separate, independently-versioned release assets — never bundled into the base installer) are the safest starting point.
- This is disclosed, not hidden, because pretending otherwise would be worse than the limitation itself. Real per-plugin isolation (e.g. one sandboxed process/context per plugin) remains a larger architecture change tracked as possible future work, not implemented as of 4.4.0.
official-plugins/mcp-bridge is the first official plugin to request the
server permission — worth calling out on its own given what that grants.
- Off by default. Three separate steps to ever turn it on the FIRST time (install, approve the consent dialog, flip the Settings → Plugins → MCP Bridge toggle) — none of it auto-starts on its own. After that first time, it remembers your own choice and reopens the listener automatically on every subsequent launch, the same way autosave-interval or any other persisted setting does — it does not re-ask on every single launch. If you want it off again, you have to explicitly disable it once; it then stays off until you re-enable it.
- Main-process re-verification, not just the consent dialog. Every start
request is re-checked against the plugin's actual on-disk approval state
(
main.js'smcp-bridge-starthandler) before anything opens — the renderer's own say-so is never trusted alone, same discipline as every other plugin IPC handler in this codebase. - Local IPC only. A named pipe (Windows) or Unix domain socket (macOS/Linux) — never a TCP port. Nothing outside this machine's kernel can reach it, full stop; there is no "bound to the wrong interface" misconfiguration possible the way there would be with a TCP listener.
- Per-start authentication token (added 2026-09-20). Every request must carry
a random 256-bit token that Kanvaz writes to
mcp-bridge.tokenin its data folder when the bridge starts and deletes when it stops. It stops other users and sandboxed apps that cannot read that folder, and any client that never read the file. It does not stop malware running as you (which can read the file too). - Card and connection edits land in undo history; board-level and settings
actions do not. The card/connection tool handlers call the exact same
KanvazCards/KanvazConnectionsfunctions the UI itself uses — an AI-driven edit isCtrl+Z-reversible exactly like a manual one, by construction, not by a separate safety net bolted on afterward.KanvazHistoryis per-board and is cleared on every board switch, so this guarantee does NOT extend todeleteBoard,renameBoard,switchBoard, orupdateSettings— there is no undo stack for those.deleteBoardspecifically requires two calls (once withoutconfirm, which only returns what would be deleted; once withconfirm:true, which actually deletes) as the safety net instead. - As of 4.5.0, the bridge exposes whole-app access — board management
(create/list/switch/rename/delete/save), undo/redo, zoom/map-view control,
and settings (
getSettings/updateSettings) — not just card/connection editing. The only carve-out is plugin management itself (installing, approving, or toggling plugins): that state lives in a main-process-onlyplugin-state.jsonthatKanvazPluginAPIhas no path to, so it's structurally unreachable from any plugin, not merely policy-excluded.updateSettingsis whitelisted to the sameSETTINGS_DEFAULTSkeys the Settings UI itself exposes — it cannot write arbitrary keys. - A card's embedded media is never sent over the bridge.
dataUrlis stripped to a booleanhasMediaflag before anything crosses the pipe — seeofficial-plugins/mcp-bridge/main.js'ssanitizeCard(). - A card's local file path IS sent, on every
listCards/getCard/searchcall that touches a file-reference card — not only when you explicitly add one.sanitizeCard()redactsdataUrland dropspluginData, but does NOT redactcard.path(an absolute OS path — on Windows this reveals your username via the home-directory prefix, plus whatever folder structure the path implies). This is by design —addReference/createCard type:"file"round-trip a path on purpose — but it's worth stating plainly next to the media-stripping bullet above rather than only being implied by the tool descriptions inofficial-plugins/mcp-bridge/README.md.
An earlier version of this section said npm audit reported "6 high" issues
and that they were all build-time-only. That was out of date: on 2026-09-20 the
real count was 13 findings (1 critical, 12 high), including Electron 22 itself
(end-of-life, Chromium 108, shipped inside every installer).
Now: Electron 44.4.3 (Chromium 152) and electron-builder 26.15.3, npm audit
reports 0 vulnerabilities for the app, and 0 for official-plugins/mcp-bridge.
CI runs npm audit --audit-level=high as a blocking step, so a new advisory
fails the build instead of going unnoticed. The upgrade was verified against the
packaged Windows build, not just a dev run.
Consequences of the upgrade, stated plainly:
- 32-bit Windows is no longer supported (Electron ships no ia32 build after v22-era). Windows 10 or later, 64-bit.
- macOS 12 or later (
minimumSystemVersionraised from 10.13). File.pathno longer exists in Electron 32+, so dropped files are resolved throughwebUtils.getPathForFile(with a fallback for older Electron).
Installers are currently unsigned (no Windows Authenticode certificate, no
Apple Developer ID / notarization). Windows SmartScreen and macOS Gatekeeper
will warn on first run, and the auto-updater cannot verify a publisher
signature; it relies on HTTPS to GitHub and the release checksums (SHA256SUMS-*.txt
attached to each release). Signing is a release blocker on the roadmap.
- Electron 22 to 44 and electron-builder 24 to 26 (see above).
- Renderer sandbox enabled (
sandbox: true; previouslyfalse). - File IPC trust boundary (
src/path-guard.js,test/path-guard-test.js):file-read/file-writeonly accept a.kanvazpath that main itself granted (native Open/Save dialog, OS double-click / Open With, or the recent list it wrote); the media, PDF, text, model,.purand Blender handlers refuse UNC / device / relative paths, which previously let a card path like\\host\share\x.pngmake Windows open an SMB connection (NTLM hash leak) just by rendering a shared board.shell-open-pathblocks a much wider set of launcher types (.hta,.chm,.msc,.cpl,.py, macro-enabled Office files,.url, and more), Windows trailing-dot/space tricks, and NTFS alternate data streams. It is a blocklist, not an allowlist, on purpose: professional reference formats are open-ended. - URL-card preview SSRF guard (
src/net-guard.js): loopback, private, link-local, CGNAT and reserved addresses are refused, including names that resolve to them (re-checked at connect time, so DNS rebinding does not bypass it) and every redirect hop. - MCP Bridge per-start token (
src/mcp-auth.js): every request must carry a 256-bit token that main generates each time the bridge starts. See the MCP section above for what this does and does not protect against. - Plugin approval bound to the exact code reviewed: consent now records the
version and a SHA-256 of the plugin's whole folder. Any changed, added or
removed file re-asks for consent; a plugin containing a symlink is refused.
Plugins approved before this change are asked once more. Plugin ids
__proto__,constructorandprototypeare rejected and plugin state is null-prototype. - Blender import runs with
--disable-autoexec --factory-startup(a hostile.blendcould otherwise run embedded Python if the user's Blender has Auto Run enabled; proven with a real test file), with a 3-minute timeout, drained output, and a cap on the converted model size. - Board files: a
.kanvazcontainer is checked against decompression limits before anything is inflated (zip-bomb defence); on load, embedded media must bedata:URLs, URL-preview imagesdata:image/, andobjectFita real CSS keyword. glTF/USD external URIs are rewritten to empty data URLs, so a hostile model cannot make the viewer fetch a remote orfile://resource. - Thumbnails are only stored if they are small raster
data:imageURLs. - Local crash log (
crash.login the data folder, rotating at 1 MB, home directory masked, never uploaded). - CI: read-only default token, write access only on the build job, actions
pinned by commit SHA, blocking
npm audit, an SBOM artifact, and SHA-256 checksums attached to releases. PRIVACY.mdcorrected to list every network request.
Still open (each is a roadmap item with a plan): code signing, plugin process
isolation (approved plugins still run in the renderer with full bridge access),
remaining unscoped IPC handlers (plugin storage, settings) and per-channel
schemas, the script-src file: allowance in the Content-Security-Policy
(needs a live-tested change), and SBOM/hash publication for vendored libraries.
A local connector for the Kanvaz Link Blender add-on. Design and limits:
- Local only. A named pipe (Windows) or Unix socket in a 0700 folder (macOS/Linux), never a TCP port.
- Four methods (
hello,deliver,status,ping), a 64 KB line cap, at most 4 connections and 8 queued deliveries. Every request carries a per-start 256-bit token; a wrong token closes the connection. A connection that never authenticates is dropped after a fixed timeout, so it cannot hold one of those few connection slots indefinitely. - The client can verify Kanvaz:
helloreturns an HMAC of the client's nonce under the token. - Nothing is accepted without consent. The first request from a program shows a native dialog (default
button: Don't allow). The answer is stored in
link-config.jsonkeyed by a Unicode-normalised, case-folded, control/bidi/zero-width-stripped version of the program's self-reported name — so a program named__proto__orconstructoris just an ordinary string key (consent is kept in null-prototype maps), and whitespace/case/ hidden-character variants of an already-denied name can't slip back in as "new." "This session only" is never written to disk. The remembered list is trimmed to a fixed cap while the app is running, not only when its file is loaded, so a flood of distinct client names can't grow it without bound. - Files, not bytes. A delivery names a file inside Kanvaz's private drop folder. Main reads it through
path-guard.readDropFile(refuses symlinks, hard links, alternate data streams, non-regular files, anything outside the folder, wrong size or checksum, and a file swapped during the read), deletes it, and sends the bytes to the renderer. The renderer is never given a path. - Honest limit: the token and consent stop other users, sandboxed apps and confused clients. Malware already running as you can read the discovery file and write to the drop folder, and no local mechanism can prevent that.
- The Windows pipe's default access rules are set explicitly (not readable or writable by other users) but that has not been verified with a second Windows account.
PSD/PSB/XD/InDesign files are parsed in the main process from files the user pointed a card at. The reader is
streaming and bounded (header size checked, rows clipped, output capped at 90 MB) and refuses truncated or
malformed files; see test/adobe-preview-test.js. Decoding runs on a worker thread (src/adobe-worker.js), not
the main process, so a pathological file can't block the UI while it's being parsed. No network access.
Kanvaz can be launched by the OS with a file path (double-click, Explorer/Finder/file-manager "Open with", a
second instance, macOS's open-file event). src/openable-types.js's filesFromArgv is the one filter every
one of those paths goes through:
- Only a fixed allowlist of extensions Kanvaz actually turns into a card; anything starting with
-(a flag), a UNC/device path, or a path naming an NTFS alternate data stream (photo.png:payload.exe— looks like a plain image by extension, resolves through the filesystem APIs to a hidden stream that could be anything) is refused before the path is ever touched. - Real, local, regular files only — resolved relative to the launch's own working directory, deduplicated case-insensitively on Windows/macOS, capped at 50 files per launch.
- The
.kanvaz-board-specific argv path (findKanvazArg, separate from the above since a board isn't in the media allowlist) has the same leading--guard, closing a gap where a launch flag that happened to end in.kanvaz(--something=x.kanvaz) could have been treated as a real board to open.
Installer-level fix (the significant one): electron-builder's NSIS target ignores the role/rank fields
entirely (its own FileAssociation.d.ts documents both as macOS-only) and unconditionally writes every listed
extension's Windows default-handler registry key. Every previewable type was in the shared fileAssociations
list before 9.1.0 — installing Kanvaz would have silently taken over the Windows default photo/video/audio/PDF
viewer from whatever the user already had, on every install, for every type. Only .kanvaz is in
fileAssociations now; every other type is registered per platform in a way that cannot set a default — macOS
via its own CFBundleDocumentTypes (LSHandlerRank: Alternate is a real guarantee there), Windows via a
generated NSIS include (build/installer.nsh, from tools/gen-nsis-associations.js) that only adds Kanvaz to
OpenWithProgids, never the extension's own default key. Linux's AppImage MimeType= list was already
independent and never set a default. test/openable-types-test.js fails if the committed installer.nsh drifts
from openable-types.js, or if the dangerous default-setting line ever reappears in it.