From 723c50233b90f91df3e3315732fcaede5cfd7295 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 24 Sep 2026 16:48:55 -0500 Subject: [PATCH] fix(desktop): link-title pipeline refuses non-http(s) input before loadURL (#93893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes:fetchLinkTitle IPC handed whatever string the renderer sent straight to the title tiers: canonicalTitleCacheKey's catch block returned the RAW value (so leaked @url: markup became a loadable-looking cache key), and the value then reached curl and the hidden title window's loadURL(), surfacing as repeating `Failed to load URL: @url:… ERR_NAME_NOT_RESOLVED` console noise on printf-style code snippets. Two layers, both in a new pure electron/link-title-url.ts module so the admission rule is testable without Electron: fetchLinkTitle bails on any input that does not parse as an absolute http(s) URL, and the cache-key builder collapses unparseable input to '' instead of the raw string. Co-authored-by: beplee --- apps/desktop/electron/link-title-url.test.ts | 56 ++++++++++++++++++++ apps/desktop/electron/link-title-url.ts | 50 +++++++++++++++++ apps/desktop/electron/main.ts | 31 +++++------ 3 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/electron/link-title-url.test.ts create mode 100644 apps/desktop/electron/link-title-url.ts diff --git a/apps/desktop/electron/link-title-url.test.ts b/apps/desktop/electron/link-title-url.test.ts new file mode 100644 index 0000000000..58b3449475 --- /dev/null +++ b/apps/desktop/electron/link-title-url.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' + +import { describe, test } from 'vitest' + +import { canonicalTitleCacheKey, isFetchableHttpUrl } from './link-title-url' + +// #93893: the renderer can send ANY href-shaped string to the +// hermes:fetchLinkTitle IPC; before these guards, an unparseable string +// became its own cache key (canonicalTitleCacheKey returned the raw value) +// and flowed to the hidden title window's loadURL(), producing repeating +// `Failed to load URL: … ERR_NAME_NOT_RESOLVED` console noise. + +describe('isFetchableHttpUrl', () => { + test('admits absolute http and https URLs', () => { + assert.equal(isFetchableHttpUrl('https://example.com/docs'), true) + assert.equal(isFetchableHttpUrl('http://example.com'), true) + assert.equal(isFetchableHttpUrl('https://example.com/a/b?x=1'), true) + }) + + test('rejects leaked directive markup before it can reach loadURL', () => { + assert.equal(isFetchableHttpUrl('@url:`https://oauth2:%s@example.internal.host`'), false) + assert.equal(isFetchableHttpUrl('@url:https://example.com'), false) + }) + + test('rejects non-http schemes, placeholders, and garbage', () => { + assert.equal(isFetchableHttpUrl('file:///etc/passwd'), false) + assert.equal(isFetchableHttpUrl('mailto:user@example.com'), false) + assert.equal(isFetchableHttpUrl('javascript:alert(1)'), false) + assert.equal(isFetchableHttpUrl('https://example.com'), true) // control + assert.equal(isFetchableHttpUrl('not a url'), false) + assert.equal(isFetchableHttpUrl('printf("hello %s")'), false) + assert.equal(isFetchableHttpUrl(''), false) + }) +}) + +describe('canonicalTitleCacheKey', () => { + test('never returns the raw string for unparseable input', () => { + // The passthrough hole: an unparseable value used to become a + // loadable-looking cache key. It must collapse to '' instead. + const junk = '@url:`https://oauth2:%s@example.internal.host`' + assert.equal(canonicalTitleCacheKey(junk), '') + assert.equal(canonicalTitleCacheKey('not a url'), '') + assert.equal(canonicalTitleCacheKey(''), '') + }) + + test('builds a host+path+search key, normalizing www and trailing slashes', () => { + assert.equal(canonicalTitleCacheKey('https://www.example.com/docs/'), 'example.com/docs') + assert.equal(canonicalTitleCacheKey('https://example.com'), 'example.com/') + assert.equal(canonicalTitleCacheKey('https://example.com/search?q=hi'), 'example.com/search?q=hi') + // Same page, same key (that is the point of a cache key). + assert.equal( + canonicalTitleCacheKey('http://www.example.com/docs///'), + canonicalTitleCacheKey('https://example.com/docs') + ) + }) +}) diff --git a/apps/desktop/electron/link-title-url.ts b/apps/desktop/electron/link-title-url.ts new file mode 100644 index 0000000000..37eb0d2b44 --- /dev/null +++ b/apps/desktop/electron/link-title-url.ts @@ -0,0 +1,50 @@ +/** + * URL admission for the link-title pipeline. + * + * The renderer's title-fetch path can send ANY href-shaped string to the + * ``hermes:fetchLinkTitle`` IPC; the main process must re-validate before + * anything reaches curl or the hidden title window's ``loadURL()``. A leaked + * directive-shaped string (``@url:`https://…```, #93893) navigates Chromium to + * a non-URL and surfaces as repeating ``ERR_NAME_NOT_RESOLVED`` console noise. + * + * Pure and dependency-free so the admission rule is testable without + * Electron; ``main.ts`` imports both helpers. + */ + +/** True only for strings that parse as absolute http(s) URLs. */ +export function isFetchableHttpUrl(raw: string): boolean { + let url: URL + + try { + url = new URL(raw) + } catch { + return false + } + + return url.protocol === 'http:' || url.protocol === 'https:' +} + +/** + * Cache key for a fetched title: host + normalized path + search, or '' when + * the input is not a parseable URL (never the raw string — an unparseable + * value must not become a loadable-looking key). + */ +export function canonicalTitleCacheKey(rawUrl: string): string { + const value = String(rawUrl || '').trim() + + if (!value) { + return '' + } + + try { + const url = new URL(value) + const host = url.hostname.replace(/^www\./i, '').toLowerCase() + const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' + + return `${host}${pathname}${url.search || ''}` + } catch { + // Not a parseable URL (e.g. leaked @url: markup): an empty key makes every + // consumer bail out instead of feeding the string downstream (#93893). + return '' + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c61d0a7688..cc8935ceb2 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -307,6 +307,7 @@ import type { InstallStamp } from './install-stamp' import { createIntroRevealWindowController } from './intro-reveal-window' import { applyLaunchProfileOverride } from './launch-profile' import { CURL_TITLE_WRITE_OUT, parseCurlTitleResponse } from './link-title-curl' +import { canonicalTitleCacheKey, isFetchableHttpUrl } from './link-title-url' import { isAuthWall, resolveLinkTitle } from './link-title-wall' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { CHROMIUM_LOG_FILENAME, enableLinuxCrashDiagnostics, linuxCrashDiagnostics } from './linux-crash-diagnostics' @@ -5467,24 +5468,6 @@ let oauthSession = null let renderTitleInFlight = 0 const renderTitleQueue = [] -function canonicalTitleCacheKey(rawUrl) { - const value = String(rawUrl || '').trim() - - if (!value) { - return '' - } - - try { - const url = new URL(value) - const host = url.hostname.replace(/^www\./i, '').toLowerCase() - const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' - - return `${host}${pathname}${url.search || ''}` - } catch { - return value - } -} - function cacheTitle(key, title) { if (titleCache.size >= TITLE_CACHE_LIMIT) { titleCache.delete(titleCache.keys().next().value) @@ -5701,6 +5684,18 @@ function fetchHtmlTitleWithRenderer(rawUrl: string): Promise { // electron/link-title-wall.ts; main.ts only supplies the two tiers' I/O. function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() + + // Scheme gate (#93893): only absolute http(s) URLs enter the title + // pipeline. Anything else — leaked `@url:` markup, placeholders, garbage — + // must never reach curl or the hidden title window's loadURL(), where it + // surfaces as a repeating `Failed to load URL: … ERR_NAME_NOT_RESOLVED` + // loop. canonicalTitleCacheKey's '' return is the second layer of the same + // guard; this check keeps non-URLs out even when a parseable-but-wrong + // scheme (file:, mailto:) would still build a cache key. + if (!isFetchableHttpUrl(url)) { + return Promise.resolve('') + } + const key = canonicalTitleCacheKey(url) if (!key) {