Critical: third-party content can make moho load local files #247

Closed
opened 2026-10-02 18:36:49 -04:00 by Salastil · 3 comments
Owner

The moho-media:// scheme is the one way the window reads local files, and isAllowed in src/main/media-protocol.ts decides what it serves - the file's own comment calls it "the whole security boundary". allowedRoots() includes os.tmpdir() as a whole, so any file anywhere under /tmp is served, not only what moho or nobilis put there.

That is reachable from a message. Message text is attacker-controlled, and two paths turn a file:// URL in it into a request on that scheme:

  • extractMedia (src/renderer/src/lib/format.ts) matches (?:https?|file):// - a file:///tmp/x.png in somebody's message is embedded as a picture;
  • richtext.tsx sends any file:// or absolute src through resolveMediaUrl.

So anyone in a channel can make every moho reading it load a file from the reader's own /tmp. The impact is limited - the picture is drawn on the reader's screen and nothing goes back to the sender - but a boundary that admits more than it means to is a bug, and it was found because the test for it (#212, src/main/media-protocol.test.ts) had to put its fixture outside /tmp to test anything at all.

Two parts:

  1. Narrow the roots. Find what is actually written under the temp directory that the window needs to load (the Discord login QR, voice notes, exports - grep nobilis for temp_dir, the app for os.tmpdir), move it under one dedicated directory (e.g. /tmp/nobilis-<uid>/), and allow only that. Keep the symlink-escape and missing-file handling.
  2. Local paths only from the daemon. A file:// URL should only ever come from a field the daemon filled (an attachment's path, an avatar, a body nobilis rewrote, like Sneedchat's cached attachments) - never from text a person typed. Stop extractMedia and the inline link pass from treating file:// in message text as media.

Add test cases: an arbitrary file in os.tmpdir() is refused, the dedicated subdirectory still works, and file:// in a message body is not extracted as media.

The `moho-media://` scheme is the one way the window reads local files, and `isAllowed` in `src/main/media-protocol.ts` decides what it serves - the file's own comment calls it "the whole security boundary". `allowedRoots()` includes **`os.tmpdir()` as a whole**, so any file anywhere under `/tmp` is served, not only what moho or nobilis put there. That is reachable from a message. Message text is attacker-controlled, and two paths turn a `file://` URL in it into a request on that scheme: - `extractMedia` (`src/renderer/src/lib/format.ts`) matches `(?:https?|file)://` - a `file:///tmp/x.png` in somebody's message is embedded as a picture; - `richtext.tsx` sends any `file://` or absolute `src` through `resolveMediaUrl`. So anyone in a channel can make every moho reading it load a file from the reader's own `/tmp`. The impact is limited - the picture is drawn on the reader's screen and nothing goes back to the sender - but a boundary that admits more than it means to is a bug, and it was found because the test for it (#212, `src/main/media-protocol.test.ts`) had to put its fixture outside `/tmp` to test anything at all. Two parts: 1. **Narrow the roots.** Find what is actually written under the temp directory that the window needs to load (the Discord login QR, voice notes, exports - grep nobilis for `temp_dir`, the app for `os.tmpdir`), move it under one dedicated directory (e.g. `/tmp/nobilis-<uid>/`), and allow only that. Keep the symlink-escape and missing-file handling. 2. **Local paths only from the daemon.** A `file://` URL should only ever come from a field the daemon filled (an attachment's path, an avatar, a body nobilis rewrote, like Sneedchat's cached attachments) - never from text a person typed. Stop `extractMedia` and the inline link pass from treating `file://` in message text as media. Add test cases: an arbitrary file in `os.tmpdir()` is refused, the dedicated subdirectory still works, and `file://` in a message body is not extracted as media.
Salastil added this to the v1.0 milestone 2026-10-02 18:36:49 -04:00
Salastil added the bug label 2026-10-02 18:36:49 -04:00
Salastil changed title from The media scheme serves all of /tmp, and message text can reach it to Critical: third-party content can make moho load local files 2026-10-02 18:40:03 -04:00
Salastil added the critical label 2026-10-02 18:40:04 -04:00
Author
Owner

Raised to critical. The goal is broader than /tmp: nothing a third party sends - message text, formatted HTML, an avatar URL, an embed, a link preview - may cause moho to load a local file of any kind, from anywhere. A file load reachable from remote content is a primitive that can be chained into something worse, whatever it shows on its own.

So the fix is layered, each layer enough on its own:

  1. The main process boundary (media-protocol.ts) serves only what it must: the daemon's cache, the app's own resources, and files the user picked - not the config directories (where accounts.toml and the scrollback live), not /tmp, and only media types, so even a path inside the roots that is not a picture, a video, audio or a sticker is refused.
  2. The daemon never passes a remote string through as something the window will treat as local: every local path it hands over is one it wrote, and remote URLs are only ever http(s) or mxc.
  3. The window never turns text a person typed into a local load: no file:// from message bodies, formatted HTML or embeds - local paths are taken only from the fields the daemon fills.
Raised to critical. The goal is broader than `/tmp`: **nothing a third party sends - message text, formatted HTML, an avatar URL, an embed, a link preview - may cause moho to load a local file of any kind, from anywhere.** A file load reachable from remote content is a primitive that can be chained into something worse, whatever it shows on its own. So the fix is layered, each layer enough on its own: 1. **The main process boundary** (`media-protocol.ts`) serves only what it must: the daemon's cache, the app's own resources, and files the user picked - not the config directories (where `accounts.toml` and the scrollback live), not `/tmp`, and only media types, so even a path inside the roots that is not a picture, a video, audio or a sticker is refused. 2. **The daemon** never passes a remote string through as something the window will treat as local: every local path it hands over is one it wrote, and remote URLs are only ever http(s) or mxc. 3. **The window** never turns text a person typed into a local load: no `file://` from message bodies, formatted HTML or embeds - local paths are taken only from the fields the daemon fills.
Author
Owner

Fixed on development; open until it has run on the real setup.

Main process boundary - the media scheme serves only files directly inside the daemon's named media caches whose first bytes are a picture, video or audio (sticker JSON in the sticker cache only), the app's own resources and rail icons, and files picked or pasted this session. Not the config directory, not /tmp, nothing nested or linked out. Checked live from a window on the real caches: 285 cached pictures across all five services load, none refused; accounts.toml, scrollback.db, moho.log, /etc/passwd, an SSH key, a PNG in /tmp and a ../ path are all refused (403).

Daemon - IRC METADATA avatars kept only as web URLs; Matrix invite avatars only as mxc; Sneedchat cached files are attachments rather than file:// in the text (old rows migrated at startup); QR codes and voice messages moved from /tmp into the cache.

Window - message text is escaped before formatting, so typed markup is shown rather than drawn (this also closes a tracking-image route, #145, and raw markup in exports); file:// is neither linked nor embedded from text; sender-written HTML gets no local or moho-media images.

Still to see on the real setup: the scrollback migration and new Sneedchat attachments drawing, after moho restarts on this build.

f9726110de
7138201c16

Fixed on development; open until it has run on the real setup. **Main process boundary** - the media scheme serves only files directly inside the daemon's named media caches whose first bytes are a picture, video or audio (sticker JSON in the sticker cache only), the app's own resources and rail icons, and files picked or pasted this session. Not the config directory, not /tmp, nothing nested or linked out. Checked live from a window on the real caches: 285 cached pictures across all five services load, none refused; accounts.toml, scrollback.db, moho.log, /etc/passwd, an SSH key, a PNG in /tmp and a ../ path are all refused (403). **Daemon** - IRC METADATA avatars kept only as web URLs; Matrix invite avatars only as mxc; Sneedchat cached files are attachments rather than file:// in the text (old rows migrated at startup); QR codes and voice messages moved from /tmp into the cache. **Window** - message text is escaped before formatting, so typed markup is shown rather than drawn (this also closes a tracking-image route, #145, and raw markup in exports); file:// is neither linked nor embedded from text; sender-written HTML gets no local or moho-media images. Still to see on the real setup: the scrollback migration and new Sneedchat attachments drawing, after moho restarts on this build. https://git.salastil.com/Salastil/nobilis/commit/f9726110de1969976638eaf2dc6bc5430ad570a0 https://git.salastil.com/Salastil/moho/commit/7138201c164e59bf57abfadc947b9cb08ef744f4
Salastil added the CVE label 2026-10-02 19:00:10 -04:00
Author
Owner

Finished. Confirmed on the real setup: Sneedchat pictures and attachments still show, including those cached before the fix.

Finished. Confirmed on the real setup: Sneedchat pictures and attachments still show, including those cached before the fix. - moho: https://git.salastil.com/Salastil/moho/commit/7138201 - nobilis: https://git.salastil.com/Salastil/nobilis/commit/f972611
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Salastil/moho#247