Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Bug fixes

* Fixed a cross-site scripting vulnerability in bookmark and chat-history restoration. The browser's message snapshot was persisted verbatim and replayed through sinks that render raw HTML, so a forged snapshot minted into a shareable server bookmark could execute script against whoever opened that URL. HTML content in a reported snapshot is now accepted only when it matches content the server actually sent; anything else renders as literal text.

* HTML dependencies reported back by the browser in `input[f"{id}_messages"]` are now matched against the dependencies the server actually sent, and the server's own copy is used in their place. Previously the client's dependency objects were carried through verbatim into saved history and bookmark state, so a forged report could have been replayed as scripts into anyone opening the resulting bookmark URL.

* Fixed `MarkdownStream` permanently stopping following new content after the user scrolled back to the bottom. Pinning was decided only from `scroll` events, which browsers dispatch asynchronously; if a chunk grew the container first, the user's at-bottom position no longer read as at-bottom and auto-scroll silently disengaged for good. (#282)

## [0.6.0] - 2026-07-06
Expand Down
2 changes: 2 additions & 0 deletions pkg-py/src/shinychat/_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
)
from ._history import ChatHistory, HistoryOptions
from ._html_deps_py_shiny import shinychat_dependency
from ._html_trust import record_sent_action
from ._utils_types import DEPRECATED, DEPRECATED_TYPE, MISSING, MISSING_TYPE

if TYPE_CHECKING:
Expand Down Expand Up @@ -1971,6 +1972,7 @@ async def _send_action(
"id": self.id,
"action": action,
}
record_sent_action(self._session, self.id, action, html_deps)
if html_deps:
envelope["html_deps"] = html_deps
await self._session.send_custom_message("shinyChatMessage", envelope)
Expand Down
190 changes: 190 additions & 0 deletions pkg-py/src/shinychat/_html_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""The trust boundary for html the browser reports back in its message snapshot.

The client reports its settled-message snapshot as the ``{id}_messages`` input
(see ``_input_handler.py``). Those reports get persisted -- bookmark state and
the conversation history store -- and replayed into sinks that assign raw HTML:
``RawHTML`` writes to ``innerHTML``, and the tool cards' ``icon``/``footer``/
``value`` attributes reach ``dangerouslySetInnerHTML``. Since a server bookmark
is shareable via its ``_state_id_`` URL, a forged report would otherwise be a
stored-script vector against whoever opens that URL.

So the server keeps its own ledger of what it sent and treats the client's
report as nothing more than a set of things to look up:

* html *dependencies* are substituted wholesale -- a reported dependency
contributes only its ``name@version``, and the server's own copy is what gets
persisted.
* html *content* is validated by string equality against the ledger. A miss
degrades the segment to markdown, where the client escapes shinychat's
raw-HTML element names (see ``reservedElements.ts``), so forged content
renders as literal text rather than executing.

The browser only reports *settled* messages -- ``buildMessagesSnapshot()`` drops
anything still streaming -- so the single string we ever have to recognize is the
finished segment, with consecutive same-type chunks already concatenated by the
client. Rather than guessing where the client closes a segment, the ledger
performs the same merge and records the result when the message closes.
"""

from __future__ import annotations

import hashlib
from typing import TYPE_CHECKING
from weakref import WeakKeyDictionary

from ._chat_types import ChunkAction, MessagePayloadSegment, SerializedDep

if TYPE_CHECKING:
from shiny.session import Session

from ._chat_types import ChatAction


class SentHtml:
"""What one session's server has sent, as far as trust is concerned."""

def __init__(self) -> None:
# Hashes rather than the strings themselves: validation is string
# equality, so storing the content would only make the ledger grow with
# the size of every payload.
self.content_hashes: set[str] = set()
# Segments of the message currently streaming, per chat id, merged the
# way the client merges them.
self.open_segments: dict[str, list[MessagePayloadSegment]] = {}
# Keyed by `name@version`; the value is the server's own copy.
self.deps: dict[str, SerializedDep] = {}


# Session-wide rather than per chat: `messages_input_value()` has no chat id to
# key on, and content the server sent to one chat is still server-authored html.
# Dependencies are genuinely page-wide -- they render into `document.head`, so
# one already loaded for any chat is loaded for the page.
sent_by_session: "WeakKeyDictionary[Session, SentHtml]" = WeakKeyDictionary()


def record_sent_action(
session: "Session | None",
chat_id: str,
action: "ChatAction",
html_deps: list[SerializedDep] | None = None,
) -> None:
"""Record what an outgoing ``shinyChatMessage`` makes trustworthy."""
if session is None:
return
sent = sent_by_session.setdefault(session, SentHtml())

if html_deps:
for dep in html_deps:
key = dep_key(dep)
if key is not None:
sent.deps[key] = dep

if action["type"] == "message":
# A one-shot message is already what the browser will report back.
trust_segments(sent, action["message"]["segments"])
elif action["type"] == "chunk_start":
sent.open_segments[chat_id] = list(action["message"]["segments"])
elif action["type"] == "chunk":
# The client drops a chunk that isn't extending a streaming message, so a
# chunk we never saw a chunk_start for displays nothing to trust.
open_segments = sent.open_segments.get(chat_id)
if open_segments is not None:
sent.open_segments[chat_id] = merge_chunk(open_segments, action)
elif action["type"] == "chunk_end":
# The message has settled, so this is the report to expect.
trust_segments(sent, sent.open_segments.pop(chat_id, []))
# Every other action (greeting*, clear, update_input, ...) carries no message
# content. Leaving the in-flight segments untouched matters: dropping them
# would let an unrelated action sent mid-stream break the merge in flight.


def is_trusted_html(session: "Session | None", content: object) -> bool:
"""Did this session's server send exactly this html string?"""
# `content` is client-reported, so it may not actually be a string --
# e.g. a forged report claiming content_type "html" for a non-string
# value. Treat that as untrusted rather than raising out of hash_content().
if session is None or not isinstance(content, str):
return False
sent = sent_by_session.get(session)
return sent is not None and hash_content(content) in sent.content_hashes


def trusted_html_deps(
session: "Session | None",
deps: list[SerializedDep] | None,
) -> list[SerializedDep] | None:
"""Swap reported dependencies for the server's own copies, dropping the rest."""
if session is None or not deps:
return None
sent = sent_by_session.get(session)
if sent is None or not sent.deps:
return None

out: list[SerializedDep] = []
seen: set[str] = set()
for dep in deps:
key = dep_key(dep)
if key is None or key in seen or key not in sent.deps:
continue
seen.add(key)
out.append(sent.deps[key])
return out or None


def merge_chunk(
segments: list[MessagePayloadSegment],
action: ChunkAction,
) -> list[MessagePayloadSegment]:
"""Mirror the client's ``chunk`` reducer.

A chunk extends the last segment when it shares its content type,
``operation="replace"`` restarts the accumulation, and an absent content type
inherits the type already in progress.
"""
last = segments[-1] if segments else None
content_type = action.get("content_type")
if content_type is None:
content_type = last["content_type"] if last is not None else "markdown"
content = action["content"]

if action.get("operation") == "replace":
return [
MessagePayloadSegment(content=content, content_type=content_type)
]
if last is not None and last["content_type"] == content_type:
return [
*segments[:-1],
MessagePayloadSegment(
content=last["content"] + content, content_type=content_type
),
]
return [
*segments,
MessagePayloadSegment(content=content, content_type=content_type),
]


def trust_segments(
sent: SentHtml,
segments: list[MessagePayloadSegment],
) -> None:
for seg in segments:
if seg["content_type"] == "html":
sent.content_hashes.add(hash_content(seg["content"]))


def dep_key(dep: object) -> str | None:
# `dep` is one entry of a client-reported list, so it may not actually be
# a dict -- e.g. a forged htmlDeps entry. Treat that as unidentifiable
# rather than raising out of .get().
if not isinstance(dep, dict):
return None
name = dep.get("name")
version = dep.get("version")
if not isinstance(name, str) or not isinstance(version, str):
return None
return f"{name}@{version}"


def hash_content(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
43 changes: 33 additions & 10 deletions pkg-py/src/shinychat/_input_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, Any

from shiny.input_handler import input_handlers
Expand All @@ -24,6 +25,7 @@
validate_attachments,
)
from ._chat_types import StoredMessage, StoredSegment
from ._html_trust import is_trusted_html, trusted_html_deps
from ._typing_extensions import TypedDict

if TYPE_CHECKING:
Expand Down Expand Up @@ -53,7 +55,10 @@ def _(value: Any, _name: "ResolvedId", _session: "Session") -> UserInputValue:
)


def messages_input_value(value: Any) -> list[StoredMessage]:
def messages_input_value(
value: Any,
session: "Session | None" = None,
) -> list[StoredMessage]:
# Shiny's websocket JSON decoding converts every JSON array to a Python
# tuple (see shiny._utils.lists_to_tuples), so a JSON array arrives here
# as a tuple, not a list.
Expand All @@ -62,16 +67,29 @@ def messages_input_value(value: Any) -> list[StoredMessage]:
f"Expected list or tuple from shinychat.messages, got {type(value)!r}"
)
messages: list[StoredMessage] = []
untrusted = 0
for m in value:
# This snapshot is the authoritative record for persistence, so a
# malformed message is a client/protocol bug we surface loudly rather
# than silently drop (which would be invisible data loss on save). The
# R handler (chat_history_types.R) takes the same posture.
segments = [
StoredSegment(content=s["content"], content_type=s["content_type"])
for s in m.get("segments", [])
]
html_deps = m.get("htmlDeps")
segments: list[StoredSegment] = []
for s in m.get("segments", []):
content = s["content"]
content_type = s["content_type"]
# Never carry the client's own html forward -- see _html_trust.py
# for why the report is only a lookup. A miss degrades to markdown,
# where the client escapes shinychat's raw-HTML element names, so
# forged content renders as literal text.
if content_type == "html" and not is_trusted_html(session, content):
untrusted += 1
content_type = "markdown"
segments.append(
StoredSegment(content=content, content_type=content_type)
)
# Never carry the client's own dependency objects forward either; the
# server's own copies are substituted in their place.
html_deps = trusted_html_deps(session, m.get("htmlDeps"))
if html_deps and segments:
segments[0].html_deps = html_deps
attachments = [
Expand All @@ -83,11 +101,16 @@ def messages_input_value(value: Any) -> list[StoredMessage]:
role=m["role"], segments=segments, attachments=attachments
)
)
if untrusted > 0:
warnings.warn(
f"Ignored the html content type on {untrusted} reported chat "
"segment(s) the server did not send; they will render as "
"literal text.",
stacklevel=2,
)
return messages


@input_handlers.add("shinychat.messages")
def _(
value: Any, _name: "ResolvedId", _session: "Session"
) -> list[StoredMessage]:
return messages_input_value(value)
def _(value: Any, _name: "ResolvedId", session: "Session") -> list[StoredMessage]:
return messages_input_value(value, session)
Loading
Loading