fix(desktop): link-title pipeline refuses non-http(s) input before loadURL (#93893)

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 <beplee@example.com>
This commit is contained in:
Brooklyn Nicholson
2026-09-24 16:48:55 -05:00
committed by brooklyn!
parent e0eb93a168
commit 723c50233b
3 changed files with 119 additions and 18 deletions

View File

@@ -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')
)
})
})

View File

@@ -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 ''
}
}

View File

@@ -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<string> {
// 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) {