Skip to content

fix: escape shinychat's raw-HTML element names in markdown content - #287

Open
cpsievert wants to merge 11 commits into
mainfrom
security/markdown-reserved-elements
Open

fix: escape shinychat's raw-HTML element names in markdown content#287
cpsievert wants to merge 11 commits into
mainfrom
security/markdown-reserved-elements

Conversation

@cpsievert

@cpsievert cpsievert commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Assistant markdown that happens to contain the literal text <shinychat-raw-html>, <shiny-tool-request>, or <shiny-tool-result> was rendered as those live custom elements, which write straight to innerHTML/dangerouslySetInnerHTML. No bookmark, no forged input, no feature flag — just model output (e.g. a RAG document, a tool result fetching an untrusted page) repeating one of those names verbatim, which is the standard indirect-prompt-injection shape. Those names are now escaped whenever content renders through the markdown branch, so they display as literal text instead of instantiating a live element.
  • Those three names are only ever supposed to appear in content the server built and labeled content_type = "html" — never in markdown. Making that boundary a hard one surfaced a family of R/Python bugs where content that is HTML (htmltools tags, TagLists, HTML() strings, htmlwidgets) was getting labeled "markdown" instead — in chat_ui(messages=), chat_set_greeting()/chat_ui()'s greeting, output_markdown_stream(), and chat_append_message(). Previously this was harmless: the mislabeled wrapper still parsed fine either way, so it just rendered correctly by accident. With the escaping fix in place, the same mislabeling would instead make widgets in initial messages, greetings, and streamed/appended content silently stop rendering. All four are fixed to always label such content "html" regardless of what the caller passed or defaulted to.
  • One of those spots (R's htmltools::HTML() strings) had a deeper bug once inspected: it was being labeled "html" but rendered through a fast path that skipped the wrapper entirely, so Shiny bindings inside a hand-authored HTML() string never actually bound. Routed through the same path as Tag/TagList content so behavior matches the label.

Test plan

  • npx vitest run (js) — 710 passing
  • devtools::test() (R) — 639 passing
  • uv run pytest (py) — 142 passing

The markdown pipeline's safety argument is that content becomes React
elements via toJsxRuntime rather than innerHTML, so raw HTML in model
output is inert. Three entries in the tag-to-component map break that:
`shinychat-raw-html` reaches `el.innerHTML`, and the two tool elements
carry `icon`, `footer`, `tool-name`, and `value` attributes that reach
`dangerouslySetInnerHTML`.

Those elements are only ever built by `split_html_islands()` and the
tool-card tagifier, which run when an app passes htmltools UI rather
than a string -- and that content is always labelled content_type
"html". So an assistant *markdown* message naming them was never
legitimate, and rendering it as markup let model output execute script
in the app's origin. That is reachable without any forged input or
shared bookmark: an app whose model sees untrusted data (retrieval,
web-fetching tools) can be steered into emitting the tag.

Escape the three names on the markdown-parsed branch so they render as
literal text. This has to happen on the client: the server streams
deltas, so a tag name split across two chunks passes any per-chunk
filter and is reassembled live in the browser. The client escapes the
accumulated block, after reassembly.

Content the server built as HTML is untouched, so islands, tool cards,
and Shiny bindings render exactly as before.

Two existing tests fed tool elements through markdown-typed segments, a
payload neither server produces; they now use the html content type
that R and Python actually send.

Does not address hostile content arriving *as* content_type "html"
through a forged `_messages` snapshot -- that route is tracked
separately.
chat_ui(messages =) renders tag content into a <shinychat-raw-html>
island but never labelled it, so the client defaulted to markdown and
(since f0b97e0) escaped the island name into visible text. htmlwidgets
in that position also never got bound, because RawHTML's bindAll() only
runs on the html branch.
ChatMessage already computes content_type='html' for non-str content;
the chat_ui() tag just never forwarded it, so the client defaulted to
markdown and escaped the <shinychat-raw-html> island into visible text.
Also folds the chat_ui() news entries in alongside the XSS fix, and keeps
the UNRELEASED changelog to a single "Bug fixes" section.

This comment was marked as resolved.

…lied

output_markdown_stream() (R and Python) let content_type default to
"markdown" even when content was actual UI (pre_process_ui()/
split_html_islands() output), which wraps it in a <shinychat-raw-html>
island. That mislabeling was silently masked before this branch: the
markdown pipeline's raw-HTML passthrough still parsed and rendered the
island correctly despite the wrong content_type. Once markdown-rendered
content escapes shinychat's reserved element names, the same mislabeling
turns into real breakage -- caught by CI on this branch
(test_stream_shiny_ui.py), the same way chat_ui(messages=) was.

Same fix as chat_ui(): force content_type to "html" whenever content isn't
a plain string, regardless of what the caller passed or defaulted to.
…arkdown

is.character() alone matches htmltools::HTML() (class c("html", "character")),
so an HTML()-marked message string was being treated as plain markdown --
mislabeled content_type, same class of bug as the chat_ui() Tag/TagList
fix earlier on this branch. Mirrors chat_set_greeting()'s existing three-way
branch (plain string / HTML() string / Tag-rendered) instead of introducing a
second, inconsistent test for the same thing.

Caught by Copilot's review on #287.
cpsievert added a commit that referenced this pull request Aug 5, 2026
…arkdown

is.character() alone matches htmltools::HTML() (class c("html", "character")),
so an HTML()-marked message string was being treated as plain markdown --
mislabeled content_type, same class of bug as the chat_ui() Tag/TagList
fix earlier on this branch. Mirrors chat_set_greeting()'s existing three-way
branch (plain string / HTML() string / Tag-rendered) instead of introducing a
second, inconsistent test for the same thing.

Caught by Copilot's review on #287.
@cpsievert cpsievert changed the title security: escape shinychat's raw-HTML element names in markdown content fix: escape shinychat's raw-HTML element names in markdown content Aug 6, 2026
cpsievert and others added 4 commits August 6, 2026 10:39
Rewrite the three bug-fix bullets so they read as a single, plain
description of the user-visible behavior change, rather than exposing the
vulnerability language and the incidental history of a regression that was
introduced and fixed within this same branch.
… Tag/TagList

chat_ui()'s message loop, chat_ui()'s greeting handling, chat_set_greeting(),
and output_markdown_stream() each special-cased a raw HTML() string as a
"fast path" that skipped pre_process_ui() and rendered it unwrapped. That
matched Tag/TagList content on the label (content_type = "html") but not on
behavior: only content wrapped in <shinychat-raw-html> reaches RawHTML's
literal innerHTML assignment and its Shiny bindAll() call, so Shiny bindings
inside a hand-authored HTML() string were silently never bound.

Route HTML()-classed content through the same pre_process_ui() branch as
Tag/TagList everywhere it was special-cased, collapsing what had become a
3-way is.character()/inherits("html") branch back down to the 2-way split
already used (correctly) by chat_append() and the greeting stream chunk
loop. content_type is now only a caller's choice for a genuine plain
string; HTML()-classed content is always "html", same as Tag/TagList.

output_markdown_stream()'s content_type parameter default changes from
"markdown" to NULL so a caller's explicit choice (for a plain string) can
be told apart from not having specified one.
…e as chat_ui()/chat_set_greeting()

chat_append_message() classified content with a forward allowlist of
specific classes (shiny.tag, shiny.tag.list, html, htmlwidget,
shinychat_tool_card). Content that pre_process_ui() still resolves and
wraps in a <shinychat-raw-html> island via as.tags() -- e.g. a bare list
of tag children -- but that doesn't match one of those classes fell
through to content_type "markdown", so the client's reserved-element
escaping turned the island's own wrapper into visible text.

Switch to the same inverse-exclusion check used everywhere else on this
branch: is.character(content) && !inherits(content, "html").
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.

2 participants