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:
committed by
brooklyn!
parent
e0eb93a168
commit
723c50233b
56
apps/desktop/electron/link-title-url.test.ts
Normal file
56
apps/desktop/electron/link-title-url.test.ts
Normal 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')
|
||||
)
|
||||
})
|
||||
})
|
||||
50
apps/desktop/electron/link-title-url.ts
Normal file
50
apps/desktop/electron/link-title-url.ts
Normal 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 ''
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user