An onion nobody can open, opened

A link to the forum's hidden service is ordinary in that chat - people copy
them out of the browser they are reading in - and unopenable for anybody not
already in Tor Browser. The two addresses are one site, so the address shown
and the one a click hands to the browser are now the clearnet one.

Rewritten as the message is drawn rather than as it is stored, which is what
makes it hold for a line edited after it was sent, for backlog fetched
before this existed, and for a quote of either: drawing happens again every
time, storing does not. A mention with no scheme in front of it gains one,
since the result is a real address and the linkifier only knows a link by
its scheme; everything after the host is left alone, that being the part
naming the thread being pointed at.

Also at the click, in the main process, because not every link is something
a message said: a picture fetched from the site carries an address of its
own that never passes through a message body, and "open in browser" on one
of those handed the system a name it cannot resolve.

The daemon is untouched and goes on speaking to the onion through its own
Tor client, which is the point of having one. This is about what the machine
holding the browser can reach.

Closes #174.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 17:35:13 -04:00
co-authored by Claude Opus 5
parent 78266c9e8d
commit 03f9353fa1
4 changed files with 58 additions and 2 deletions
+8 -1
View File
@@ -26,6 +26,7 @@ import { Notifier } from './notifications'
import { browserLogin, LOGIN_FLOWS } from './browser-login'
import { solveCaptcha } from './captcha'
import { IPC, POPOUT_FLAG, type PopoutState } from '../shared/ipc'
import { clearnetLinks } from '../shared/clearnet'
import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink'
import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
import { defaultDownloadDir, saveMedia } from './downloads'
@@ -542,7 +543,13 @@ function wireIpc(): void {
})
ipcMain.handle(IPC.popoutList, () => popoutState())
ipcMain.handle(IPC.openExternal, (_e, url: string) => {
ipcMain.handle(IPC.openExternal, (_e, raw: string) => {
// The forum's onion address, sent to the browser as the clearnet one it is
// the same site as. Here as well as in the window because a link is not
// always something a message said: a picture fetched from the site carries
// an address of its own, and "open in browser" on one of those would hand
// the system a name it cannot resolve.
const url = clearnetLinks(raw)
// Only ever hand the OS a real web/mail link - a message body is fully
// attacker-controlled, and shell.openExternal will happily launch things
// like `file://` or a custom app scheme otherwise.
+7 -1
View File
@@ -26,6 +26,7 @@ import {
extractQuoteBlocks,
formatMessage,
normalizeBBCode,
clearnetLinks,
youtubeId,
stripCodeBlocks,
stripEmbeddedUrls,
@@ -361,7 +362,12 @@ function MessageRowBody({
// matching how the original structured this - one do-everything function
// would have to interleave four unrelated concerns.
const parts = useMemo(() => {
const normalized = normalizeBBCode(message.body || '')
// Before anything reads the body: the forum's onion address, rewritten to
// the same site on the open internet. Here rather than in the daemon
// because it is about what this reader's browser can reach, and it has to
// hold for an edited line and for backlog alike - both of which arrive at
// this function again and neither of which is re-stored.
const normalized = normalizeBBCode(clearnetLinks(message.body || ''))
const media = extractMedia(normalized, {
contentSniffing,
sniffed,
+2
View File
@@ -1,4 +1,6 @@
import type { SneedchatSmilie } from '../../../shared/wire'
// Shared with the main process, which is what actually opens a clicked link.
export { clearnetLinks } from '../../../shared/clearnet'
/**
* Message body formatting, ported from the QML frontend's MessageList.
+41
View File
@@ -0,0 +1,41 @@
/**
* The forum's hidden service, and the address of the same site on the open
* internet.
*
* Links to the onion are ordinary in that chat - people copy them out of the
* browser they are reading in - and unopenable for anybody who is not already
* in Tor Browser. The two addresses are one site, so showing the reader the
* one their browser can actually reach costs nothing and saves an errand.
*
* Shared between the window and the main process because both ends of a link
* need it: the window draws the address, and the main process is what hands a
* click to the operating system. A picture fetched from the site arrives
* carrying an onion URL of its own, which never passes through the message
* body and would otherwise still open into nothing.
*
* Deliberately not in the daemon. It goes on speaking to the onion through its
* own Tor client, which is the point of having one; this is about what the
* machine holding the browser can reach.
*/
export const KIWIFARMS_ONION = 'kiwifarmsaaf4t2h7gc3dfc5ojhmqruw2nit3uejrpiagrxeuxiyxcyd.onion'
export const KIWIFARMS_CLEARNET = 'https://kiwifarms.st'
const ONION_RE = new RegExp(`(?:https?:\\/\\/)?(?:www\\.)?${KIWIFARMS_ONION}`, 'gi')
/**
* Rewrites the forum's onion address to its clearnet one, wherever it appears.
*
* Applied to a message on the way to being drawn rather than to the message as
* it is stored, so it holds for a line edited after it was sent, for backlog
* fetched before this existed, and for a quote of either - the rewrite is part
* of drawing the text, and drawing happens again every time.
*
* A mention with no scheme in front of it gains one, since the result is a
* real address and a linkifier only knows a link by its scheme. Everything
* after the host - the path, the query, the fragment - is left exactly as it
* was, because that is the part naming the thread being pointed at.
*/
export function clearnetLinks(text: string): string {
if (!text) return text
return text.replace(ONION_RE, KIWIFARMS_CLEARNET)
}