Skip to content

Sanitize plugin-supplied HTML and validate icon names - #107

Merged
an0nn30 merged 2 commits into
mainfrom
fix/plugin-widget-html-xss
Sep 3, 2026
Merged

Sanitize plugin-supplied HTML and validate icon names#107
an0nn30 merged 2 commits into
mainfrom
fix/plugin-widget-html-xss

Conversation

@an0nn30

@an0nn30 an0nn30 commented Sep 2, 2026

Copy link
Copy Markdown
Owner

What

Two injection vulnerabilities in the plugin widget renderer, both letting a plugin escape its declared capabilities.

1. The html widget was a sandbox escape. renderHtmlWidget assigned plugin content straight to innerHTML inside a shadow root, and the app sets "csp": null. Shadow DOM isolates styles, not scripts — so while innerHTML won't run inline <script>, an event-handler attribute like <img src=x onerror="..."> executes in the main window's realm, where window.__TAURI__.invoke lives. A plugin declaring only ui.panel could call any Tauri command without ever passing through check_capability.

2. iconHtml had the same shape. A plugin-supplied icon name was interpolated into an img src with no escaping, so a name like x.png" onerror="… broke out of the attribute. Reachable from ui.panel_icon_label, ui.panel_button{icon}, tree nodes, and table cells.

Both contradict docs/plugin-security-model.md, which promises least privilege and denied-by-default gates.

How

Untrusted markup is parsed into an inert document via DOMParser and rebuilt node by node against an allowlist, rather than scrubbed in place. Only text and vetted attributes cross over, and nothing is re-serialized — which is what makes mutation-XSS round-trips possible in string-based sanitizers.

Inline SVG is supported but namespace-aware: elements are created with createElementNS, and attributes keep their original casing so viewBox survives. script, foreignObject, use, and the animation elements are excluded.

Icon names are validated as bare filenames rather than escaped — a name that isn't a bare filename can't address the bundled icon set anyway.

Notes

  • The allowlist was widened after finding that a first pass would have gutted lua-tmux-manager.lua, which builds its whole UI from <button> and inline <svg>. There's a regression test built from that plugin's real markup.
  • The css argument is deliberately not sanitized; CSS cannot execute script.
  • "csp": null is unchanged. Adding a CSP would be worthwhile defense-in-depth but is a riskier change that didn't belong in this branch.
  • Adds vitest + jsdom — the first JS test coverage in this repo. 57 tests pass.
  • docs/plugin-sdk.md documents exactly what survives sanitization.

Test plan

npm install && npm test

Verified at the unit level; I did not launch the app to confirm the tmux panel renders visually.

🤖 Generated with Claude Code

The html widget assigned plugin content straight to innerHTML inside a
shadow root. Shadow DOM isolates styles, not scripts, and the app sets no
CSP, so an inline event handler in plugin markup ran in the host realm
with access to the Tauri invoke bridge — letting a plugin that declared
only ui.panel reach every capability the app has.

iconHtml had the same shape: a plugin-supplied icon name was interpolated
into an img src with no escaping, so a name could close the attribute and
add a handler.

Add an allowlist sanitizer that parses untrusted markup into an inert
document and rebuilds it node by node, keeping only vetted elements,
attributes, and URL schemes. Inline SVG is namespace-aware so icons still
render and case-sensitive names like viewBox survive. Icon names are
validated as bare filenames rather than escaped.

Extract both into their own modules and add vitest + jsdom, the first JS
test coverage in the repo. 57 tests, including a regression case built
from the tmux manager example's real markup.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4726b938e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +115 to +117
dest.setAttribute(attribute.name, value);
// A link that opens elsewhere must not hand the opener to the target.
if (name === 'target') dest.setAttribute('rel', 'noopener noreferrer');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce noopener after all link attributes

When a plugin supplies a link with target before rel, such as <a target="_blank" rel="opener" ...>, this sets the safe rel value while processing target, then the later rel attribute overwrites it. The opened page can consequently retain window.opener and tabnab the application; enforce or merge noopener noreferrer after the attribute loop (or reject plugin-provided rel for targeted links).

Useful? React with 👍 / 👎.

Brings in the CI pipeline fixes and the markdown preview work. The only
conflict was .gitignore, where main added the markdown vendor bundle and
this branch added the root node_modules for the vitest harness — both
entries kept.

main's markdown preview sanitizes through a script-less sandboxed iframe,
a different surface and mechanism from this branch's shadow-DOM plugin
widget sanitizer, so the two do not overlap.
@an0nn30
an0nn30 merged commit e83e8a6 into main Sep 3, 2026
5 checks passed
an0nn30 added a commit that referenced this pull request Sep 3, 2026
Brings in the plugin HTML sanitizer from #107. No conflicts: that branch's
docs/plugin-sdk.md edits cover the html widget's sanitization allowlist,
while this branch's cover payload conversion behavior for app.publish,
app.query_plugin, app.register_settings_section, ui.form,
ui.open_docked_view, session.current and session.exec_active. Different
sections, no contradiction.

Full workspace suite green after the merge, including the 93
termlab_plugin tests covering the new bridge.
an0nn30 added a commit that referenced this pull request Sep 3, 2026
Step 1 of the core is merged, so the spec now records it as done and the
plan is marked executed rather than reading as outstanding work.

Three corrections to the spec, all found while implementing it:

- It named three hand-rolled converters; there were four. The fourth,
  set_lua_table_from_json_map in session.rs, copied only scalars, so nested
  objects and arrays were silently dropped from session.current() and
  session.exec_active().
- The vitest dependency note described fix/plugin-widget-html-xss as
  pending; it merged in #107.
- handle_query is recorded as the last hand-rolled JSON edge still open:
  it hands on_query a JSON string and discards a malformed reply with
  .ok(). Verified still present on main; belongs with step 4.
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.

1 participant