Skip to content

Escape git-installed plugin metadata in the Plugin Manager installed list - #31

Merged
cchacs merged 1 commit into
mainfrom
pullfrog/29-escape-git-installed-plugin-metadata
Oct 6, 2026
Merged

cchacs merged 1 commit into
mainfrom
pullfrog/29-escape-git-installed-plugin-metadata

Conversation

@pullfrog

@pullfrog pullfrog Bot commented Oct 6, 2026

Copy link
Copy Markdown

Closes #29.

The problem

refreshList() in src/renderer/plugin-manager/screen.js built its rows with innerHTML and interpolated git-installed plugin metadata straight into the template:

  • manifest.name, manifest.description, plugin.version
  • plugin.name into a data-name="${…}" attribute, where a " would truncate it
  • the listInstalled error string, unescaped

Those values come from the plugin.json of whatever repository the user cloned, and the renderer windows run with webSecurity: false (src/main/main.ts:413) — no CSP, no same-origin fallback. A repository shipping "description": "<img src=x onerror=…>" executed in the app the moment the list was drawn.

The fix

The rows are built from nodes instead of a template: metadata goes in through textContent and the button target through dataset, so there is nothing to escape and no way to get the escaping wrong. installedRow()/installedAction() hold the shape the old template produced (same classes, same pm-update/pm-remove hooks, so the existing bindings still match). The listInstalled failure is rendered as text for the same reason, and refreshCatalog's catch picked up the e?.message || e idiom the rest of the file already uses at two other call sites.

Following the issue's suggestion to sweep the other renderers while the helper was fresh: the audio screen's two list rows read names off disk — a VST bundle is a directory name — so refreshChain's processor name and renderVSTList's name, manufacturer and format now go through escHtml, the helper the rest of that screen uses. I swept every remaining ${…} in src/renderer/screen.js and src/main/wizard.js; the unescaped remainder is fixed-map lookups (typeNames[slot.type]) and numeric counts, and wizard.js is already textContent-only.

slot.id is left interpolated bare into the chain row's inline onclick handlers. It comes from the native addon's slot counter rather than off disk, so it isn't in the attacker-controlled set the sweep targets; the trust boundary is our own compiled .node. Worth a separate look if you disagree.

Tests

New tests/renderer-html-escaping.test.js (7 tests, no dependencies beyond builtins). It runs the plugin manager screen whole against a stubbed document with a hostile plugin.json, and asserts:

  • the name and description survive as literal text, and no element in the row carries any markup string at all
  • the update/remove buttons carry the plugin name verbatim — including a " in it — and that name reaches the bridge intact when clicked
  • a listInstalled failure renders as text, thrown as an Error or as a bare string
  • both screens still parse whole (the duplicate-top-level-declaration guard)

For the audio screen it lifts refreshChain and renderVSTList out of the source and asserts the hostile string is present escaped, not dropped, so the assertions can't pass vacuously. Each guard was verified to fail when its regression is re-injected, including a targeted check that the "no markup" assertion bites on its own.

Verification

  • npm run typecheck — clean.
  • npm test — 244 tests, 237 pass, 6 skipped, 1 fail. The one failure is pre-existing and unrelated: tests/juce-exclusive-type-name.test.js reads JUCE/modules/juce_audio_devices/native/juce_WASAPI_windows.cpp, and the five git submodules are not populated in a clean checkout, so it fails with ENOENT.
  • node --check on both renderer screens.

Full npm run build:ts / npm run build:audio was not run — the latter needs the submodules and a native toolchain this environment doesn't have.

🤖 Generated with Pullfrog

Pullfrog  | View workflow run | via Pullfrog | Using big-pickle (free) | 𝕏

The Plugin Manager's installed list built each row with innerHTML and
interpolated the cloned repository's plugin.json name, description and version
into it, put plugin.name into a data-name attribute, and interpolated the
listInstalled error string the same way. Both renderer windows run with
webSecurity:false, so there is no CSP to make a stray < inert: a repository
whose plugin.json carries markup in its description executed in the app as soon
as the list was drawn.

The rows are now built from nodes — metadata via textContent, the button target
via dataset — so there is nothing left to escape. The listInstalled failure is
rendered as text for the same reason, and refreshCatalog's catch picks up the
same e?.message || e idiom the rest of the file already uses.

The same pattern turned up in the audio screen's two list rows, which read
names off disk (a VST bundle is a directory name): the signal chain row's
processor name and the VST browser row's name, manufacturer and format now go
through escHtml, the helper the rest of that screen uses.

tests/renderer-html-escaping.test.js runs the plugin manager screen against a
stubbed document with a hostile plugin.json and asserts the metadata survives as
text, that no part of the row is built from markup, and that a quote in the
plugin name still reaches the bridge intact; it lifts refreshChain and
renderVSTList for the audio screen. Each guard was verified to fail when its
regression is re-injected.
@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 2 critical · 1 high

Alerts:
⚠ 3 issues (≤ 0 issues of at least minor severity)

Results:
3 new issues

Category Results
Security 2 critical
1 high

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@kilo-code-bot

kilo-code-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files)
  • src/renderer/plugin-manager/screen.js - Fixed XSS in git-installed plugin list by replacing innerHTML with DOM node construction (textContent for metadata, dataset for button targets)
  • src/renderer/screen.js - Added escHtml() escaping for VST browser (name, manufacturer, format) and signal chain (processor name) rows
  • tests/renderer-html-escaping.test.js - New comprehensive test suite (7 tests) verifying hostile strings render as text, not markup, and that quotes in plugin names reach the bridge intact

Reviewed by free · Input: 216.5K · Output: 3.7K · Cached: 302.4K

@cchacs
cchacs merged commit 3643c84 into main Oct 6, 2026
6 of 7 checks passed
@cchacs
cchacs deleted the pullfrog/29-escape-git-installed-plugin-metadata branch October 9, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Escape git-installed plugin metadata in the Plugin Manager installed list

1 participant