PoC — frontmatter-driven arbitrary JavaScript execution in Note Toolbar for Obsidian (GHSA-q8cw-3m8c-5pf2, CVE-2026-87002, CVSS 7.0).
{{prop_NAME}} → {{js:}} Variable ChainCVE status: requested, pending assignment. This finding is published as GHSA-q8cw-3m8c-5pf2. On CVE assignment this repository is renamed
CVE-YYYY-NNNNN-obsidian-note-toolbar-PoCand this banner is replaced with the CVE link.
| Researcher | Dostxodjayev Abdullox (@squeeze440) |
| Advisory | GHSA-q8cw-3m8c-5pf2 |
| CVSS 3.1 | 7.0 (High) |
| Weakness | CWE-94, CWE-1336 |
{{prop_NAME}} → {{js:}} Variable ChainImproper output neutralization in the variable-substitution engine of Note Toolbar (Obsidian plugin) allows an attacker who controls a note's YAML frontmatter (e.g. a collaborator on a synced/shared vault) to achieve arbitrary code execution on the victim's machine when the victim simply opens the note, by injecting a {{js: ...}} payload into a frontmatter property that a pre-existing, non-script toolbar item label/tooltip/link references via {{prop_NAME}}.
Note Toolbar (note-toolbar) — Obsidian community plugin
Repository: https://github.com/chrisgurney/obsidian-note-toolbar
v1.34.12 (commit 520271c3027eb6da38bab10a686b21e14c664c13, 2026-07-29), against Obsidian desktop 1.13.4 on Linux.
7.0 (High) — CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
AV:L — the vulnerable component (the plugin's JS evaluator, running inside Obsidian's Electron renderer) is not network-facing; the payload arrives as local vault/file content synced in by whatever means the victim uses (Git, Syncthing, Obsidian Sync, shared folder), consistent with prior findings in this family (e.g. obsidian-syncthing-integration, obsidian-codescript-toolkit).AC:H — exploitation depends on conditions outside the attacker's control that must already exist on the victim's install: (1) the plugin's "Scripting" setting must already be enabled (off by default), and (2) the victim must already have configured a toolbar item whose label, tooltip, or link is a bare {{prop_NAME}} reference to some frontmatter key. Both are realistic, documented usage patterns, but neither is the default.PR:N — the attacker needs no access to the victim's system, only the ability to get a note into the victim's vault with attacker-controlled frontmatter.UI:R — the victim must open/view the specific note; no click on the toolbar item is required, the toolbar renders automatically.S:U, C:H/I:H/A:H — code executes with full Node.js access (require('child_process'), filesystem, etc.) as the local user account running Obsidian.Note Toolbar supports a documented {{prop_NAME}} variable that substitutes a note's frontmatter value into a toolbar item's label, tooltip, or link (skills/note-toolbar-variables/SKILL.md, wiki Variables.md). It also supports a {{js: <expr>}} variable that evaluates the expression as live JavaScript when the "Scripting" plugin setting is enabled.
The bug is in the order of operations in src/Toolbar/VariableResolver.ts::replaceVars():
{{prop_KEY}} placeholders are substituted with the raw value of frontmatter[KEY] from the current note — unconditionally, regardless of the scriptingEnabled setting, and regardless of the source of that frontmatter value.if (this.ntb.settings.scriptingEnabled)): the function then checks s.trim().startsWith('{{js:') on the already-substituted string s, and if true, strips the {{js:/}} wrapper and passes the remainder to JavaScriptAdapter.use() for evaluation.Because step 1 runs before step 2 and rewrites s in place, a frontmatter value that itself begins with {{js: and ends with }} gets promoted from "displayed text" to "executed script" — even though the toolbar item was never authored as a script item. The toolbar item's owner only ever typed {{prop_status}}; the executable payload comes entirely from the note's own frontmatter.
JavaScriptAdapter.evaluate() (src/Adapters/JavaScriptAdapter.ts:143-205) runs the expression with a real, unsandboxed AsyncFunction:
src/Adapters/Adapter.ts:11
protected static readonly AsyncFunction = (Object.getPrototypeOf(async function(){}) as { constructor: typeof Function }).constructor;
src/Adapters/JavaScriptAdapter.ts:164
const func = new JavaScriptAdapter.AsyncFunction("input", expression);
...
result = await Promise.resolve((func as (...args: unknown[]) => unknown)(args));
This is the JS engine's genuine AsyncFunction constructor, not an interpreter/sandbox — inside Obsidian's Electron renderer this has full require()/Node.js access, confirmed in the PoC below via require('child_process').execSync(...).
Critically, the JS adapter is built-in and requires no companion plugin (src/Adapters/AdapterManager.ts:59: adapter = this.js; // built-in, doesn't rely on plugin), unlike the Dataview/Templater/JS-Engine variable prefixes which need those plugins installed. Only the plugin's own "Scripting" toggle (scriptingEnabled, default false, src/Settings/NoteToolbarSettings.ts:300) gates it.
Finally, the render path that triggers substitution is automatic, not click-gated: ToolbarRenderer.ts::renderLItems() calls this.ntb.vars.resolveText(toolbar, file) (src/Toolbar/ToolbarRenderer.ts:418) every time a toolbar is rendered for a note — i.e. on note open/view — which resolves every item's label and tooltip via replaceVars(). No click on the affected toolbar item is required.
Toolbar-to-note assignment is itself frontmatter-driven (toolbarProp setting, default key notetoolbar, src/Settings/NoteToolbarSettings.ts:317), so a synced note can select which toolbar renders on it — but this is not required for exploitation if the victim already uses a default toolbar or folder mapping that includes the vulnerable item.
This is distinct from the risk the maintainer's own SECURITY.md documents ("User scripts... executes JavaScript provided by the user... intentional and by design") — that describes a user knowingly authoring a {{js:}} toolbar item, or knowingly importing a shared toolbar config containing one. Here, the toolbar item is not a script item at all from the configurer's perspective (it is a plain property-display label), and the executable payload arrives via ordinary note content/frontmatter — the same untrusted-vault-content threat model as other Obsidian sync/config code-execution findings (obsidian-syncthing-integration, obsidian-codescript-toolkit).
Dynamically verified against a real Obsidian 1.13.4 desktop instance (Xvfb + fluxbox), loading the actual built plugin (main.js built from the audited source), not a bare-function extraction.
PoCVault/ with Project Alpha.md:
---
status: "{{js: require('child_process').execSync('id > /tmp/ntb_poc_proof.txt; date >> /tmp/ntb_poc_proof.txt; whoami >> /tmp/ntb_poc_proof.txt'); return 'ok';}}"
notetoolbar: Toolbar
---
{{prop_status}} (a plain property-display label — never configured as a script item).Project Alpha.md — no click on the toolbar item — renders the toolbar showing ok (the JS expression's return value), and the injected shell commands execute for real: