cc-haha/desktop/electron/services/navigationGuards.ts
程序员阿江(Relakkes) 56b6b45bef fix(security): harden telegram and desktop guards
Tested: bun test adapters/telegram/__tests__/commands.test.ts
Tested: cd desktop && bun test electron/services/navigationGuards.test.ts
Tested: bun run check:adapters
Tested: bun run check:desktop
Not-tested: bun run verify (scoped local hardening, not PR-ready validation)
Confidence: high
Scope-risk: narrow
2026-07-08 16:57:32 +08:00

91 lines
2.9 KiB
TypeScript

export type WindowOpenHandlerResult = { action: 'deny' } | { action: 'allow' }
export type NavigationGuardWebContents = {
setWindowOpenHandler(handler: (details: { url: string }) => WindowOpenHandlerResult): void
on(
event: 'will-navigate',
handler: (event: { preventDefault: () => void }, url: string) => void,
): unknown
}
export type NavigationGuardOptions = {
openExternal: (url: string) => void
}
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname
.trim()
.replace(/^\[/, '')
.replace(/\]$/, '')
.toLowerCase()
if (normalized === 'localhost' || normalized === '::1') return true
const parts = normalized.split('.')
return parts.length === 4 &&
parts[0] === '127' &&
parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255)
}
export function isHttpUrl(url: string): boolean {
try {
const { protocol } = new URL(url)
return protocol === 'http:' || protocol === 'https:'
} catch {
return false
}
}
export function isAllowedMainWindowNavigationUrl(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol === 'file:') return true
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return isLoopbackHostname(parsed.hostname)
}
return false
} catch {
return false
}
}
/**
* Main app window guard. The renderer is a single-page app loaded from a fixed
* entry; it should never spawn an uncontrolled child window. Any window.open /
* target=_blank with an http(s) URL is routed to the system browser and the
* Electron popup is denied. Top-level navigation is restricted to local
* renderer entries so a remote page cannot inherit the privileged preload.
*/
export function installMainWindowNavigationGuards(
webContents: NavigationGuardWebContents,
{ openExternal }: NavigationGuardOptions,
): void {
webContents.setWindowOpenHandler(({ url }) => {
if (isHttpUrl(url)) openExternal(url)
return { action: 'deny' }
})
webContents.on('will-navigate', (event, url) => {
if (isAllowedMainWindowNavigationUrl(url)) return
event.preventDefault()
if (isHttpUrl(url)) openExternal(url)
})
}
/**
* Preview (WebContentsView) guard. The preview renders untrusted remote pages,
* so it must keep working as a browser: in-page http(s) navigation is allowed.
* Popups are denied (http(s) ones handed to the system browser), and navigation
* to any non-http(s) scheme (file:, custom schemes) is blocked outright.
*/
export function installPreviewNavigationGuards(
webContents: NavigationGuardWebContents,
{ openExternal }: NavigationGuardOptions,
): void {
webContents.setWindowOpenHandler(({ url }) => {
if (isHttpUrl(url)) openExternal(url)
return { action: 'deny' }
})
webContents.on('will-navigate', (event, url) => {
if (!isHttpUrl(url)) event.preventDefault()
})
}