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:
+1
-1
Submodule nobilis updated: 4d8f17b6d2...f9726110de
@@ -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()
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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">>implying</span>'.replace('>', '>'))
|
||||
expect(html).toContain('<span style="color:#72ff72">>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('<a & b>')
|
||||
expect(escapeHtml('<a & "b">')).toBe('<a & "b">')
|
||||
})
|
||||
})
|
||||
|
||||
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('<')
|
||||
}
|
||||
})
|
||||
|
||||
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 & b < c')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ export function emojiPreview(text: string, smilies: SmilieEntry[] = []): EmojiPr
|
||||
}
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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"> ${escapeHtml(code)} </span>`)
|
||||
// Already escaped with the rest of the text.
|
||||
stow(`<span class="inline-code"> ${code} </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(/<(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=":${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(/<#(\d+)>/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 `<https://x>`.
|
||||
out = out.replace(/((?:https?|ircs?):\/\/(?:(?!>|<|")[^\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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user