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:
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user