A message may not fetch from a stranger on being read

Rendering a formatted body fetched every https image it named, the moment it
was drawn. Where this client wrote the markup that is the feature - a Discord
emoji is a URL built from an id. Where the sender wrote it, which on Matrix is
whenever they send a formatted_body, it is a way to be told that the message
was read, by whom, and from what address. Nothing in a chat message needs
that.

So RichText is told which kind of markup it has, and only ours may reach for
a remote picture. The prop is off by default, which is the safe way round:
markup arriving from a service is somebody else's until a call site says
otherwise.

A refused image keeps its alt text rather than vanishing - what a picture was
called is worth having, and a tracking pixel has no name to keep.

Measured in the running client, against a formatted body carrying
`<img src="https://cdn.example.com/tracker-probe.png" alt="[the picture]">`:

    rendered:  "a message with a picture the sender chose [the picture]"
    img tags:  0
    requests:  none

And the other way, on a Discord channel where the markup is ours: custom
emoji still draw and still fetch from the CDN.

Note for anyone reading this later: inline images do not render on Matrix at
all today, and did not before this - `mxc://` was already refused, so the safe
source was dropped while the dangerous one was honoured. Making mxc work means
resolving it through the daemon, which has the machinery
(`cached_media_path`); it is not built here because no message in this store
has ever carried one.

Closes #145

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-09 18:46:31 -04:00
co-authored by Claude Opus 5
parent adc83b8a0b
commit ede11cad27
2 changed files with 42 additions and 7 deletions
@@ -657,6 +657,10 @@ function MessageRowBody({
<span className="message-body selectable">
<RichText
html={parts.html}
// Ours unless the sender sent their own formatting, which
// is the one case where a picture it names is a stranger's
// server being told this message was read.
ownMarkup={!message.html}
onRevealSpoiler={(i) => setRevealed((r) => ({ ...r, [i]: true }))}
// Following a channel link moves the rail too: the channel
// is usually in the same guild, but a cross-guild link is
@@ -707,6 +711,7 @@ function MessageRowBody({
<div className="rich-embed-desc small">
<RichText
html={formatMessage(embed.description, { channels })}
ownMarkup
onOpenChannel={(id) => void store.selectBuffer(id, true)}
onOpenLink={(url) => store.followDeepLink(url)}
/>
+37 -7
View File
@@ -103,6 +103,16 @@ function safeSpanStyle(attr: string | null): React.CSSProperties | undefined {
export interface RichTextProps {
html: string
/**
* Whether this client wrote the markup.
*
* Off by default, which is the safe way round: markup arriving from a
* service is somebody else's, and the only thing that changes is whether a
* picture it names may be fetched from a remote server. Anything built here
* - a formatted body this client produced, an embed's description - says so
* and keeps its images.
*/
ownMarkup?: boolean
/** Called with the spoiler's index when a hidden run is clicked. */
onRevealSpoiler?: (index: number) => void
/** Called with a buffer id when a channel link is clicked. */
@@ -116,9 +126,17 @@ interface Handlers {
onRevealSpoiler?: RichTextProps['onRevealSpoiler']
onOpenChannel?: RichTextProps['onOpenChannel']
onOpenLink?: RichTextProps['onOpenLink']
/** Whether this markup is ours rather than the sender's - see safeImageSrc. */
ownMarkup?: boolean
}
export function RichText({ html, onRevealSpoiler, onOpenChannel, onOpenLink }: RichTextProps): JSX.Element {
export function RichText({
html,
ownMarkup,
onRevealSpoiler,
onOpenChannel,
onOpenLink
}: RichTextProps): JSX.Element {
// DOMParser builds an inert document: no scripts run, no images load, no
// network requests happen during parsing.
//
@@ -132,11 +150,11 @@ export function RichText({ html, onRevealSpoiler, onOpenChannel, onOpenLink }: R
// below passes any https image through, so a formatted body can name a URL
// its author controls and learn that the message was read - see #145.
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
return <>{walk(doc.body, { onRevealSpoiler, onOpenChannel, onOpenLink }, 0)}</>
return <>{walk(doc.body, { onRevealSpoiler, onOpenChannel, onOpenLink, ownMarkup }, 0)}</>
}
function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
const { onRevealSpoiler, onOpenChannel, onOpenLink } = handlers
const { onRevealSpoiler, onOpenChannel, onOpenLink, ownMarkup } = handlers
const out: ReactNode[] = []
// Depth guard: deeply nested markup in a hostile body shouldn't be able to
// blow the stack. Beyond this, render the remaining subtree as flat text.
@@ -168,9 +186,14 @@ function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
case 'IMG': {
const src = el.getAttribute('src') || ''
const resolved = safeImageSrc(src)
const resolved = safeImageSrc(src, !!ownMarkup)
const alt = el.getAttribute('alt') || ''
if (!resolved) break
if (!resolved) {
// Refused rather than broken: what it was called is worth keeping
// where the sender gave it a name, and a tracking pixel has none.
if (alt) out.push(alt)
break
}
// A custom emoji that has since been deleted from its guild leaves a
// token in every message that used it, and a broken-image box is a
// worse answer than the ":name:" the sender typed.
@@ -373,8 +396,15 @@ function classNameOf(el: Element): string | undefined {
* both go through the guarded moho-media scheme. Remote https images are
* allowed too (Discord CDN avatars and the like). Everything else is dropped.
*/
function safeImageSrc(src: string): string | null {
if (/^https:\/\//i.test(src)) return src
function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
// A remote picture is a request to somebody else's server, made the moment
// the message is drawn. Where this client wrote the markup that is fine and
// wanted - a Discord emoji is a URL we built from an id. Where the *sender*
// wrote it, it is a way to be told that their message was read, by whom and
// from what address, and no chat message needs that. So the caller says
// which kind of markup this is, and the untrusted kind gets no remote
// images at all.
if (/^https:\/\//i.test(src)) return remoteAllowed ? src : null
// Already routed (a smilie url built by the store) - passing it through
// resolveMediaUrl again would double-wrap it.
if (src.startsWith(`${MEDIA_SCHEME}://`)) return src