A message can learn that it was read, through an image it names #145

Closed
opened 2026-09-09 18:30:59 -04:00 by Salastil · 1 comment
Owner

Found while tracing #141; unrelated to it, and not fixed here.

What is real

RichText renders a formatted body's <img> directly, and safeImageSrc
passes any https:// URL through untouched - which is what makes custom
emoji and inline images work, so it is a feature rather than an oversight.

It also means a message can name a URL its author controls. Rendering it
fetches it, which tells that author the message was read, when, and from what
IP address. Matrix's formatted_body is the obvious way in - a sender writes
the HTML - and moho draws it as sent.

Nothing in the store does this today: of every message currently held, none
has an <img> with an https source in its formatted body. So this is a
capability, not an incident.

What is not real

The suspicion in #142 - that sanitising a body fetches the things it names -
is disproven. A body carrying <link rel="stylesheet">, <style>@font-face,
<img src="https://...">, a CSS background: url() and a <script src> was
parsed through the same DOMParser path with the network watched:

network requests for probe assets: none
CSP violations: []

The parse is inert. The exposure is at render, and only for <img>.

What to do about it

A decision rather than a fix, which is why this is its own issue:

  1. Leave it. Inline images and custom emoji keep working. A sender who
    wants a read receipt can have one.
  2. Route remote images through the daemon, as Sneedchat's already are -
    the author learns the daemon fetched it, not when a person read it, and
    never their address. Costs a fetch and a cache; the daemon already has
    both.
  3. A preference, defaulting to off for services where a sender writes the
    HTML. Honest, and one more thing to explain.

Option 2 is the one that keeps the feature and removes the tell.

Found while tracing #141; unrelated to it, and not fixed here. ## What is real `RichText` renders a formatted body's `<img>` directly, and `safeImageSrc` passes **any** `https://` URL through untouched - which is what makes custom emoji and inline images work, so it is a feature rather than an oversight. It also means a message can name a URL its author controls. Rendering it fetches it, which tells that author the message was read, when, and from what IP address. Matrix's `formatted_body` is the obvious way in - a sender writes the HTML - and moho draws it as sent. Nothing in the store does this today: of every message currently held, none has an `<img>` with an `https` source in its formatted body. So this is a capability, not an incident. ## What is not real The suspicion in #142 - that sanitising a body *fetches* the things it names - is disproven. A body carrying `<link rel="stylesheet">`, `<style>@font-face`, `<img src="https://...">`, a CSS `background: url()` and a `<script src>` was parsed through the same `DOMParser` path with the network watched: ``` network requests for probe assets: none CSP violations: [] ``` The parse is inert. The exposure is at render, and only for `<img>`. ## What to do about it A decision rather than a fix, which is why this is its own issue: 1. **Leave it.** Inline images and custom emoji keep working. A sender who wants a read receipt can have one. 2. **Route remote images through the daemon**, as Sneedchat's already are - the author learns the daemon fetched it, not when a person read it, and never their address. Costs a fetch and a cache; the daemon already has both. 3. **A preference**, defaulting to off for services where a sender writes the HTML. Honest, and one more thing to explain. Option 2 is the one that keeps the feature and removes the tell.
Salastil added the buglow priority labels 2026-09-09 18:30:59 -04:00
Author
Owner

Finished - https://git.salastil.com/Salastil/moho/commit/ede11ca

Option 2 was written up here as the one that keeps the feature and removes the
tell. It turned out not to be needed: there was no feature to keep.

RichText now takes ownMarkup, off by default, and only markup this
client wrote may fetch a remote picture. A formatted body from a sender gets
none. The two call sites say which they are: a message body is ours unless the
service sent formatting with it, and an embed description is always ours.

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

Measured

A formatted body carrying a tracker was put through the running client - a row
written straight into the scrollback store, so nothing was sent anywhere:

<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, so the fix is not simply "no images": on a Discord channel,
where the markup is ours, custom emoji still draw and still fetch from the CDN.

Why not the daemon proxy

Because there is nothing to proxy. Inline images do not render on Matrix today
and did not before this: safeImageSrc already refused mxc://, so the safe
source was being dropped while the dangerous one was honoured. Of every message
in this store, not one has an <img> in its formatted body - mxc or https.

Building the proxy would have been machinery for a case with no users, and it
brings its own question (a daemon that fetches URLs named by strangers needs to
be told about loopback and private addresses first). If inline Matrix images
are ever wanted, the route is cached_media_path in backend/matrix/media.rs,
which already resolves mxc:// with the account's token and caches under a
size cap - the work is an RPC and a small renderer cache, not a new fetcher.

The trade-off worth stating: if a bridge ever sends an https image inside a
formatted body, it now shows its alt text instead of the picture. That is the
same rule applied evenly, and the fix for it is the mxc route above rather
than trusting the sender's URL.

Finished - https://git.salastil.com/Salastil/moho/commit/ede11ca Option 2 was written up here as the one that keeps the feature and removes the tell. It turned out not to be needed: there was no feature to keep. `RichText` now takes `ownMarkup`, **off by default**, and only markup this client wrote may fetch a remote picture. A formatted body from a sender gets none. The two call sites say which they are: a message body is ours unless the service sent formatting with it, and an embed description is always ours. A refused image keeps its `alt` text rather than vanishing. What a picture was called is worth having; a tracking pixel has no name to keep. ## Measured A formatted body carrying a tracker was put through the running client - a row written straight into the scrollback store, so nothing was sent anywhere: ``` <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, so the fix is not simply "no images": on a Discord channel, where the markup is ours, custom emoji still draw and still fetch from the CDN. ## Why not the daemon proxy Because there is nothing to proxy. Inline images do not render on Matrix today and did not before this: `safeImageSrc` already refused `mxc://`, so the safe source was being dropped while the dangerous one was honoured. Of every message in this store, not one has an `<img>` in its formatted body - mxc or https. Building the proxy would have been machinery for a case with no users, and it brings its own question (a daemon that fetches URLs named by strangers needs to be told about loopback and private addresses first). If inline Matrix images are ever wanted, the route is `cached_media_path` in `backend/matrix/media.rs`, which already resolves `mxc://` with the account's token and caches under a size cap - the work is an RPC and a small renderer cache, not a new fetcher. The trade-off worth stating: if a bridge ever sends an `https` image inside a formatted body, it now shows its alt text instead of the picture. That is the same rule applied evenly, and the fix for it is the mxc route above rather than trusting the sender's URL.
Salastil added the CVE label 2026-10-02 19:00:11 -04:00
Salastil added this to the v1.0 milestone 2026-10-02 19:16:47 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Salastil/moho#145