Skip to content

security: validate reported html against a ledger of what the server sent - #288

Draft
cpsievert wants to merge 5 commits into
mainfrom
security/reported-html-trust
Draft

security: validate reported html against a ledger of what the server sent#288
cpsievert wants to merge 5 commits into
mainfrom
security/reported-html-trust

Conversation

@cpsievert

Copy link
Copy Markdown
Collaborator

Summary

  • The browser reports its own message snapshot back to the server (input$<id>_messages / input[f"{id}_messages"]), and that report is currently trusted for content_type: "html" segments and htmlDeps objects. That report is persisted verbatim into the already-shipped chat-history store, and is the input a future bookmark-restore feature would persist into shareable server-bookmark state. A forged report is a stored-script vector against whoever later opens that history or bookmark — the raw-HTML sinks it reaches (innerHTML, and the tool cards' icon/footer/value attributes via dangerouslySetInnerHTML) don't distinguish content the server actually sent from content the client merely claims it sent.
  • Closes that gap with a server-side ledger of what was actually sent, built by mirroring the client's own chunk-merging (the browser only ever reports a settled message, never an in-flight one, so there's exactly one string per segment to recognize). The browser's report is validated against the ledger at the input-handler boundary — before session$input$<id>_messages is ever read by anything downstream — so history saves, and any future consumer of the same input, get the protection automatically. Untrusted html degrades to inert markdown; untrusted dependencies are dropped and trusted ones are swapped for the server's own copy.
  • Same boundary added in Python (_html_trust.py), which had the identical gap in messages_input_value().

Test plan

  • devtools::test() (R) — 684 passing, including an adversarial pair that forges a _messages report, sanitizes it, and asserts the replay primitive shared by history-switch and bookmark-restore never re-emits "html" or a dependency the server didn't send
  • uv run pytest (py) — 372 passing, including an end-to-end case that drives a real Chat.append_message(HTML(...)) through the actual send path and feeds the wire content back through the input handler the way the browser would
  • pyright, ruff check — clean

cpsievert and others added 4 commits August 5, 2026 15:40
The browser's `<id>_messages` report was carried into saved history and
bookmark state verbatim, dependency objects included, and replayed with
`send_chat_action(html_deps =)` on restore -- which reaches
`Shiny.renderDependenciesAsync()` and injects the dependency's
script/head content into the page.

Because a server bookmark is shareable via its `_state_id_` URL, an
attacker could forge a `_messages` value in their own session, let
`chat_restore()` mint the bookmark, and share the URL to load
attacker-chosen scripts into the recipient's session.

`send_chat_action()` now records every dependency it sends, keyed by
name+version, and the `_messages` input handler treats the client's
report as nothing more than a set of identities: each reported
dependency is replaced by the server's own copy of it, and anything the
server never sent is dropped. Sanitizing at the input boundary covers
the history store as well as bookmark state.

Hostile `html` segment content is a separate, unaddressed vector with
the same reach; tracked separately.
A reported content_type: 'html' segment now has to match something the
server actually sent this session; a miss degrades that one segment to
markdown, where the client escapes shinychat's raw-HTML element names.
Closes the last reach of a forged _messages report: bookmark state is
shareable via its _state_id_ URL, and RawHTML assigns html content
straight to innerHTML.
…sent

Replaces the prefix-hash registry with a ledger that mirrors the client's
chunk merging. The old version recorded a hash for every prefix of a
streaming html run, because it couldn't predict where the client would close
a segment. It didn't have to: buildMessagesSnapshot() drops any message still
streaming, so the browser only ever reports the *settled* segment. Recording
that one string -- merged the same way the client merges it -- is the whole
job, and it drops the O(n^2) prefix hashing along with the per-chat run state.

Also ports the boundary to Python, which had the same exposure: its
messages_input_value() took content_type and htmlDeps from the client
verbatim into StoredMessage and on into the history store.

This comment was marked as resolved.

is_trusted_html() and dep_key() both assumed their input matched the
client's normal shape (a str, a dict), but both receive values straight
from a client-controlled JSON report -- a forged segment or htmlDeps entry
of the wrong type would raise out of hash_content()/dep.get() instead of
just failing the trust check, turning a would-be no-op into an unhandled
exception in the request path.

Caught by Copilot's review on #288.
@cpsievert
cpsievert marked this pull request as draft August 7, 2026 20:55
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