Third-party content cannot make moho load local files (#247)

The media scheme, which every local load goes through, now 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. Gone: the daemon's config directory (accounts.toml, scrollback,
crypto store) and the whole system temp directory. Checked live: every
cached picture on all five services still loads (285, none refused),
while accounts.toml, scrollback.db, moho.log, /etc/passwd, an SSH key, a
PNG in /tmp and a ../ path out of the cache are all refused.

In the window: formatMessage escapes the sender's text before anything
else, so a typed tag is shown, never drawn - it used to be drawn as the
client's own markup, which also let any message on IRC, Discord, Kick or
Sneedchat show a remote tracking image (#145) and leaked raw markup into
exports. file:// is neither linked nor embedded from text. RichText
takes no local or moho-media image from markup the sender wrote, and
links only http(s).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-02 18:55:20 -04:00
co-authored by Claude Opus 5.5
parent 53a6f92ae4
commit 7138201c16
7 changed files with 258 additions and 60 deletions
+1 -1
Submodule nobilis updated: 4d8f17b6d2...f9726110de
+5
View File
@@ -783,6 +783,9 @@ function wireIpc(): void {
const ext = type.slice('image/'.length).split(/[;+]/)[0] || 'png'
const file = path.join(dir, `paste-${Date.now()}.${ext}`)
fs.writeFileSync(file, Buffer.from(await blob.arrayBuffer()))
// Drawn in the composer before it is sent - and allowed by name, since
// the temp directory as a whole is not something the window may read.
allowPickedFile(file)
return file
}
return null
@@ -959,6 +962,8 @@ app.whenReady().then(() => {
// nobilis's cached media, so the renderer needs no file access of its own.
allowRoot(resourcePath())
allowRoot(smiliesPath())
// Rail icons somebody chose, copied here after being checked as images.
allowRoot(path.join(app.getPath('userData'), 'group-icons'))
installMediaHandler()
prefs = new Prefs()
+69 -9
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -7,25 +8,36 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ net: {}, protocol: {} }))
vi.mock('./log', () => ({ log: { warn: () => {}, info: () => {} } }))
const { isAllowed } = await import('./media-protocol')
const { isAllowed, isMediaBytes } = await import('./media-protocol')
describe('the media scheme boundary', () => {
let home: string
let cache: string
let outside: string
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])
let stickers: string
beforeAll(() => {
// Not under the system temp directory, which the scheme also serves
// (the daemon writes a few things there) and which would make every
// "outside" path here an inside one.
// Not under the system temp directory - the scheme used to serve all of
// it, and a fixture there would hide exactly the failure being tested.
const scratch = path.join(process.cwd(), 'node_modules', '.cache')
fs.mkdirSync(scratch, { recursive: true })
home = fs.mkdtempSync(path.join(scratch, 'moho-media-'))
cache = path.join(home, 'cache', 'nobilis', 'matrix-media')
stickers = path.join(home, 'cache', 'nobilis', 'discord-stickers')
fs.mkdirSync(cache, { recursive: true })
outside = path.join(home, 'secret.txt')
fs.writeFileSync(path.join(cache, 'a.png'), 'x')
fs.writeFileSync(outside, 'x')
fs.mkdirSync(stickers, { recursive: true })
fs.mkdirSync(path.join(cache, 'nested'))
fs.mkdirSync(path.join(home, 'config', 'nobilis'), { recursive: true })
outside = path.join(home, 'secret.png')
fs.writeFileSync(path.join(cache, 'a.png'), PNG)
fs.writeFileSync(path.join(cache, 'nested', 'b.png'), PNG)
fs.writeFileSync(path.join(cache, 'accounts.png'), '[account]\ntoken = "secret"\n')
fs.writeFileSync(path.join(home, 'config', 'nobilis', 'scrollback.png'), PNG)
fs.writeFileSync(path.join(stickers, '1.json'), '{"v":"5.7"}')
fs.writeFileSync(path.join(cache, '2.json'), '{"v":"5.7"}')
fs.writeFileSync(outside, PNG)
fs.symlinkSync(outside, path.join(cache, 'link.png'))
process.env.XDG_CACHE_HOME = path.join(home, 'cache')
process.env.XDG_CONFIG_HOME = path.join(home, 'config')
@@ -33,13 +45,13 @@ describe('the media scheme boundary', () => {
afterAll(() => fs.rmSync(home, { recursive: true, force: true }))
it('serves a file inside the daemon\'s cache', () => {
it("serves a picture inside the daemon's cache", () => {
expect(isAllowed(path.join(cache, 'a.png'))).toBe(true)
})
it('refuses anything outside it, however it is spelled', () => {
expect(isAllowed(outside)).toBe(false)
expect(isAllowed(path.join(cache, '..', '..', '..', 'secret.txt'))).toBe(false)
expect(isAllowed(path.join(cache, '..', '..', '..', 'secret.png'))).toBe(false)
expect(isAllowed('/etc/passwd')).toBe(false)
})
@@ -50,4 +62,52 @@ describe('the media scheme boundary', () => {
it('refuses a file that is not there', () => {
expect(isAllowed(path.join(cache, 'missing.png'))).toBe(false)
})
it('refuses what is not a picture, a video or audio, whatever it is called', () => {
expect(isAllowed(path.join(cache, 'accounts.png'))).toBe(false)
})
it("refuses the daemon's config directory, picture or not", () => {
expect(isAllowed(path.join(home, 'config', 'nobilis', 'scrollback.png'))).toBe(false)
})
it('refuses anything nested below a cache folder', () => {
expect(isAllowed(path.join(cache, 'nested', 'b.png'))).toBe(false)
})
it("serves a sticker's animation, and JSON nowhere else", () => {
expect(isAllowed(path.join(stickers, '1.json'))).toBe(true)
expect(isAllowed(path.join(cache, '2.json'))).toBe(false)
})
it('refuses the system temp directory', () => {
const loose = path.join(os.tmpdir(), `moho-media-loose-${process.pid}.png`)
fs.writeFileSync(loose, PNG)
try {
expect(isAllowed(loose)).toBe(false)
} finally {
fs.rmSync(loose, { force: true })
}
})
})
describe('what counts as media', () => {
const bytes = (...b: (number | string)[]): Uint8Array =>
Uint8Array.from(b.flatMap((x) => (typeof x === 'string' ? [...x].map((c) => c.charCodeAt(0)) : [x])))
it('knows pictures, video and audio by their first bytes', () => {
expect(isMediaBytes(bytes(0xff, 0xd8, 0xff, 0xe0))).toBe(true)
expect(isMediaBytes(bytes('GIF89a'))).toBe(true)
expect(isMediaBytes(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))).toBe(true)
expect(isMediaBytes(bytes(0, 0, 0, 0x20, 'ftypisom'))).toBe(true)
expect(isMediaBytes(bytes('OggS'))).toBe(true)
})
it('refuses text, keys and databases', () => {
expect(isMediaBytes(bytes('[account]'))).toBe(false)
expect(isMediaBytes(bytes('SQLite format 3'))).toBe(false)
expect(isMediaBytes(bytes('-----BEGIN'))).toBe(false)
expect(isMediaBytes(bytes('{"a":1}'))).toBe(false)
expect(isMediaBytes(bytes('{"a":1}'), true)).toBe(true)
})
})
+111 -29
View File
@@ -8,14 +8,22 @@ import { log } from './log'
/**
* nobilis hands back local `file://` paths for media it has already fetched on
* the client's behalf - Tor-routed Sneedchat avatars and attachments, Matrix
* media, the Discord login QR (see each nobilis backend's own cache dir).
* The renderer has no route to any of those origins itself, which is why they
* arrive as local files in the first place.
* media, a sign-in QR code. The renderer has no route to any of those origins
* itself, which is why they arrive as local files in the first place.
*
* Serving them through a dedicated scheme rather than turning off webSecurity
* keeps the renderer unable to read arbitrary files: this handler resolves
* only inside the known cache roots and refuses everything else, so a hostile
* message body cannot smuggle `moho-media:///etc/passwd` past it.
* keeps the renderer unable to read arbitrary files - and this is the layer
* that holds when every other one has been got past (#247). It serves:
*
* - files directly inside the daemon's named media caches, and only if their
* first bytes say they are a picture, a video or audio (a sticker's JSON in
* the sticker cache alone) - never its config directory, never the system
* temp directory, never anything nested or linked out;
* - the app's own files: its bundled resources, rail icons somebody chose;
* - single files the person picked or pasted this session.
*
* So a message that names a path - `moho-media://file/?p=/etc/passwd`, or the
* account file - gets nothing, whatever the window was persuaded to ask for.
*/
export const SCHEME = 'moho-media'
@@ -95,27 +103,90 @@ function daemonDirs(): { cache: string; config: string }[] {
}
/**
* These are the *daemon's* directories, not this app's: every local path that
* arrives on the wire was written by nobilis (Tor-fetched Sneedchat media,
* Matrix media, the Discord login QR), so nobilis is what the roots have to
* track. They moved with the rename - pointing them at moho's own dirs
* silently refuses every image the daemon fetches.
* The daemon's caches the window may draw from: the list in nobilis's
* media_cache.rs, and `transient`, where it writes what is drawn once and
* thrown away - a sign-in QR code, a voice message before it is sent.
*
* Named folders rather than the daemon's whole cache, and nothing from its
* config directory: that is where `accounts.toml`, the scrollback and the
* Matrix crypto store live, and no picture is ever drawn from there. And not
* the system temp directory, which used to be served whole - every file any
* program put there.
*/
const DAEMON_MEDIA_DIRS = [
'matrix-media',
'sneedchat-attachments',
'sneedchat-avatars',
'discord-thumbnails',
'discord-icons',
'kick-emotes',
'discord-stickers',
'discord-sounds',
'transient'
]
/**
* The same caches under the daemon's pre-rename directory. Its migration only
* moves these when the destination does not already exist, so a client that
* ran under both names has stored messages still naming the old one.
*/
const LEGACY_MEDIA_DIRS = ['matrix-media', 'sneedchat-attachments', 'sneedchat-avatars']
/** Where a Lottie sticker's animation is - the one place JSON is served. */
const STICKER_DIR = 'discord-stickers'
function allowedRoots(): string[] {
return daemonDirs().flatMap(({ cache, config }) => [
path.join(cache, 'nobilis'),
path.join(config, 'nobilis'),
// The daemon's pre-rename directories. Its migration only moves these
// when the destination does not already exist, so a client that ran under
// both names ends up with the two side by side and years of stored
// messages still naming the old one. Those files are the user's own cache
// either way; refusing them only blanks the avatars on old scrollback.
path.join(cache, 'moho'),
path.join(config, 'moho')
])
.concat(os.tmpdir())
return daemonDirs()
.flatMap(({ cache }) => [
...DAEMON_MEDIA_DIRS.map((dir) => path.join(cache, 'nobilis', dir)),
...LEGACY_MEDIA_DIRS.map((dir) => path.join(cache, 'moho', dir))
])
.map((p) => path.resolve(p) + path.sep)
.concat(extraRoots)
}
/**
* Whether these bytes are something the window draws or plays: a picture, a
* video, or audio. Read from the file rather than taken from its name, because
* a name says nothing about what is in a file - and the point is that nothing
* else is served, whatever it is called.
*
* Exported for tests.
*/
export function isMediaBytes(head: Uint8Array, allowJson = false): boolean {
const at = (i: number, ...bytes: number[]): boolean => bytes.every((b, k) => head[i + k] === b)
const ascii = (i: number, text: string): boolean => at(i, ...[...text].map((c) => c.charCodeAt(0)))
if (at(0, 0x89, 0x50, 0x4e, 0x47)) return true // PNG
if (at(0, 0xff, 0xd8, 0xff)) return true // JPEG
if (ascii(0, 'GIF8')) return true
if (ascii(0, 'RIFF') && (ascii(8, 'WEBP') || ascii(8, 'WAVE') || ascii(8, 'AVI '))) return true
if (ascii(0, 'BM')) return true
if (at(0, 0x00, 0x00, 0x01, 0x00)) return true // ICO
if (ascii(4, 'ftyp')) return true // MP4, MOV, AVIF, HEIC, M4A
if (at(0, 0x1a, 0x45, 0xdf, 0xa3)) return true // WebM, Matroska
if (ascii(0, 'OggS')) return true // Ogg: Opus, Vorbis
if (ascii(0, 'fLaC')) return true
if (ascii(0, 'ID3')) return true // MP3 with a tag
if (head[0] === 0xff && (head[1] & 0xe0) === 0xe0) return true // MP3 frame
if (allowJson) {
const first = [...head].find((b) => b !== 0x20 && b !== 0x0a && b !== 0x0d && b !== 0x09)
if (first === 0x7b) return true // {
}
return false
}
/** The first bytes of a file, or nothing if it cannot be read. */
function headOf(filePath: string): Uint8Array | null {
let fd: number | null = null
try {
fd = fs.openSync(filePath, 'r')
const head = new Uint8Array(32)
const n = fs.readSync(fd, head, 0, head.length, 0)
return head.subarray(0, n)
} catch {
return null
} finally {
if (fd !== null) fs.closeSync(fd)
}
}
/**
@@ -154,10 +225,21 @@ export function isAllowed(target: string): boolean {
return false
}
if (pickedFiles.has(comparable(resolved)) || pickedFiles.has(comparable(real))) return true
if (real !== resolved && !allowedRoots().some((root) => underRoot(real + path.sep, root))) {
return false
}
return allowedRoots().some((root) => underRoot(resolved + path.sep, root))
// The app's own files - its bundled resources, a rail icon somebody chose -
// are whatever they are; they never came from anybody else.
const own = (p: string): boolean => extraRoots.some((root) => underRoot(p + path.sep, root))
if (own(resolved) && own(real)) return true
const roots = allowedRoots()
const inside = (p: string): boolean => roots.some((root) => underRoot(p + path.sep, root))
// A link planted inside that points out is judged by where it points.
if (!inside(resolved) || !inside(real)) return false
// Directly inside a cache folder, never deeper: nothing the daemon writes
// is nested, and a subfolder is not something it made.
if (!roots.some((root) => comparable(path.dirname(real) + path.sep) === comparable(root))) return false
const head = headOf(real)
if (!head) return false
return isMediaBytes(head, path.basename(path.dirname(real)) === STICKER_DIR)
}
/** Must run before app.whenReady(). */
@@ -191,7 +273,7 @@ export function installMediaHandler(): void {
// sender ever posted, and logging each one buries anything else.
if (!refused.has(filePath)) {
refused.add(filePath)
log.warn('[media] refused out-of-root request:', filePath)
log.warn('[media] refused request:', filePath)
}
return new Response('forbidden', { status: 403 })
}
+34 -2
View File
@@ -96,7 +96,7 @@ describe('BBCode', () => {
it('formats greentext colour and strips tags it does not know', () => {
const html = formatMessage('[color=#72ff72]>implying[/color] [blink]x[/blink]', { isSneedchat: true })
expect(html).toContain('<span style="color:#72ff72">&gt;implying</span>'.replace('&gt;', '>'))
expect(html).toContain('<span style="color:#72ff72">&gt;implying</span>')
expect(html).not.toContain('[blink]')
})
})
@@ -134,7 +134,39 @@ describe('formatMessage', () => {
})
it('escapes what it is asked to', () => {
expect(escapeHtml('<a & b>')).toBe('&lt;a &amp; b&gt;')
expect(escapeHtml('<a & "b">')).toBe('&lt;a &amp; &quot;b&quot;&gt;')
})
})
describe('third-party text is text (#247)', () => {
it('shows a typed tag instead of drawing it', () => {
for (const typed of [
'<img src="file:///etc/passwd">',
'<img src=/home/me/.config/nobilis/accounts.toml>',
'<img src="moho-media://file/?p=/etc/passwd">',
'<a href="file:///etc/passwd">click</a>',
'<script>alert(1)</script>'
]) {
const html = formatMessage(typed)
expect(html, typed).not.toMatch(/<(img|a|script)\b/)
expect(html, typed).toContain('&lt;')
}
})
it('does not link or embed a file:// URL from a message', () => {
expect(formatMessage('see file:///etc/passwd')).not.toContain('<a')
expect(extractMedia('file:///home/me/x.png file:///etc/passwd.jpg')).toEqual([])
})
it('keeps a URL inside its own attribute', () => {
const html = formatMessage('https://x.com/"onmouseover=alert(1)')
expect(html).not.toContain('"onmouseover')
})
it('still draws what it built itself', () => {
expect(formatMessage('<:ok:1>')).toContain('<img src="https://cdn.discordapp.com/emojis/1.webp')
expect(formatMessage('<#5>', { channels: { '5': { bufferId: 'b', name: 'c' } } })).toContain('class="channel-mention"')
expect(formatMessage('a & b < c')).toBe('a &amp; b &lt; c')
})
})
+31 -18
View File
@@ -104,7 +104,9 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
}
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Quotes as well as brackets: the link passes write a URL from the text
// into an attribute, and a quote in it would end the attribute early.
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
/**
@@ -204,8 +206,11 @@ export interface SmilieEntry extends SneedchatSmilie {
*/
export function buildSmilieIndex(smilies: SmilieEntry[]): SmilieIndex {
const byAlias: Record<string, SmilieEntry> = {}
// Keyed as the text will read when they are looked for: formatMessage
// escapes a message before it reads it, so an alias with a < or & in it
// has to be matched in its escaped form.
for (const s of smilies) {
for (const alias of s.aliases || []) byAlias[alias] = s
for (const alias of s.aliases || []) byAlias[escapeHtml(alias)] = s
}
const aliases = Object.keys(byAlias)
if (aliases.length === 0) return { byAlias, regex: null }
@@ -369,6 +374,13 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
// otherwise break the pass that reads it. Stripped rather than rendered for
// every protocol that does not use them, where a control character is
// something somebody pasted rather than formatting.
// The sender's text is text. Escaped before anything else, so the only
// markup in what comes out is what this function builds - a raw tag typed
// into a message is shown, never drawn. Drawn, it was a way for anybody to
// put an image in front of the reader that loaded a file from the reader's
// own computer, or pinged a server of the sender's (#247, #145). Everything
// below matches the escaped forms of the tokens it looks for.
text = escapeHtml(text)
text = ircFormat(text, opts.ircFormatting === 'render' ? 'render' : 'strip')
// Stash already-formed markup so later passes don't re-touch its contents,
@@ -384,17 +396,12 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
let out = text
// Existing HTML anchors (case-insensitive - some channels send <A HREF=...>).
out = out.replace(
/<a\s+href=["']([^"']+)["']\s*>([\s\S]*?)<\/a>/gi,
(_m, href: string, label: string) => stow(`<a href="${href}">${label}</a>`)
)
// Inline `code` spans, stowed before anything else touches them so backticked
// content is never reinterpreted as markdown or links, and escaped since code
// commonly contains < > & that would corrupt the parse.
out = out.replace(/`([^`\n]+)`/g, (_m, code: string) =>
stow(`<span class="inline-code">&nbsp;${escapeHtml(code)}&nbsp;</span>`)
// Already escaped with the rest of the text.
stow(`<span class="inline-code">&nbsp;${code}&nbsp;</span>`)
)
// Spoilers: ||text||, hidden behind a solid bar until clicked, same as
@@ -403,8 +410,8 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
let spoilerIndex = 0
out = out.replace(/\|\|([\s\S]+?)\|\|/g, (_m, inner: string) => {
const idx = spoilerIndex++
if (revealed[idx]) return stow(`<span class="spoiler revealed">${escapeHtml(inner)}</span>`)
return stow(`<a href="spoiler:${idx}" class="spoiler">${escapeHtml(inner)}</a>`)
if (revealed[idx]) return stow(`<span class="spoiler revealed">${inner}</span>`)
return stow(`<a href="spoiler:${idx}" class="spoiler">${inner}</a>`)
})
// Discord custom emoji, which arrive in the body as `<:name:id>` (or
@@ -412,8 +419,8 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
// No lookup is needed - the id is in the token, and the picture is public -
// so this works for any guild's emoji, including ones from a server this
// account is not in.
out = out.replace(/<(a?):([A-Za-z0-9_~]{2,32}):(\d+)>/g, (_m, _anim: string, name: string, id: string) =>
stow(`<img src="${discordEmojiUrl(id, 48)}" class="custom-emoji" alt=":${escapeHtml(name)}:">`)
out = out.replace(/&lt;(a?):([A-Za-z0-9_~]{2,32}):(\d+)&gt;/g, (_m, _anim: string, name: string, id: string) =>
stow(`<img src="${discordEmojiUrl(id, 48)}" class="custom-emoji" alt=":${name}:">`)
)
// Kick emotes, which arrive as `[emote:1082364:xqcAM]`. Like Discord's
@@ -423,7 +430,7 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
// on what they have paid for would leave a newcomer reading a chat that is
// half raw tokens, and the tier gates sending, not looking.
out = out.replace(/\[emote:(\d+):([^\]]{0,64})\]/g, (_m, id: string, name: string) =>
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${escapeHtml(name)}:">`)
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${name}:">`)
)
// Discord channel links. What arrives is `<#1393001234568164748>` and
@@ -433,7 +440,7 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
// `channel:` href into a click that switches buffers rather than something
// the browser navigates. An id nothing here knows still beats showing the
// raw token, which reads as a bug.
out = out.replace(/<#(\d+)>/g, (_m, id: string) => {
out = out.replace(/&lt;#(\d+)&gt;/g, (_m, id: string) => {
const known = opts.channels?.[id]
if (!known) return stow('<span class="channel-mention unknown">#unknown-channel</span>')
// The "#" is drawn here, so a name that already carries one (IRC's do,
@@ -458,7 +465,7 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
if (!s || !s.url) return m
// No explicit width/height: most of these are small by design (24-64px),
// and forcing a fixed square distorts the wider banner-shaped ones.
return stow(`<img src="${s.url}" class="smilie" alt="${escapeHtml(m)}">`)
return stow(`<img src="${s.url}" class="smilie" alt="${m}">`)
})
}
@@ -504,7 +511,11 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
// Any bare URL not already wrapped by one of the formats above.
// ircs? alongside the web schemes: a link to a channel is a link, and one
// written in a message means exactly what the same link means in a browser.
out = out.replace(/((?:https?|file|ircs?):\/\/[^\s<]+)/g, '<a href="$1">$1</a>')
// Web and IRC links only: a file:// link in somebody's message is not
// something to offer the reader to open (#247). And not into an escaped
// bracket: `<https://x>` is how Discord suppresses an embed, and arrives
// here as `&lt;https://x&gt;`.
out = out.replace(/((?:https?|ircs?):\/\/(?:(?!&gt;|&lt;|&quot;)[^\s<])+)/g, '<a href="$1">$1</a>')
// Anything else BBCode-shaped that wasn't recognised (e.g. [USER=..]
// mentions, [sub]/[sup], a typo'd tag) - stripped rather than shown as
@@ -602,7 +613,9 @@ export function extractMedia(
// link into a local cached path once it's fetched through Tor. That only
// ever originates from nobilis's own substitution, never from a remote
// message's raw text.
const urls = text.match(/(?:https?|file):\/\/[^\s<[\]]+/g) || []
// Web links only. A local file reaches the window as an attachment the
// daemon filled in, never as text somebody typed (#247).
const urls = text.match(/https?:\/\/[^\s<[\]]+/g) || []
const result: MediaItem[] = []
// A message legitimately repeating the same link (quoted text plus the
// original) shouldn't render the same embed twice.
+7 -1
View File
@@ -274,7 +274,7 @@ function walk(node: Node, handlers: Handlers, depth: number): ReactNode[] {
// data:, a custom app scheme) renders as inert text - main re-checks
// the scheme before handing anything to the OS, so this is the first
// of two gates, not the only one.
if (/^(https?|file):/i.test(href)) {
if (/^https?:/i.test(href)) {
out.push(
<a
key={key}
@@ -405,6 +405,12 @@ function safeImageSrc(src: string, remoteAllowed: boolean): string | null {
// 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
// Nothing local in markup the sender wrote - not a file:// URL, not a bare
// path, and not a moho-media URL spelled out by hand, which would otherwise
// walk straight past every check before the main process's own (#247).
// Local pictures in a message are ones this client put there: an emote's
// cached copy, a bundled smilie.
if (!remoteAllowed) return 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