From b465ec14635b5de85f95af97c8f3fafd12a8aaff Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 20 Feb 2026 21:36:23 +0800 Subject: [PATCH] fix: harden external protocol handling and unblock dev:electron (#4863) * fix(desktop): harden external protocol handling * fix(build): avoid optional peer resolution in dev --- apps/desktop/electron.vite.config.ts | 1 + apps/desktop/layer/main/src/manager/window.ts | 75 +++++++++++++++---- .../layer/main/src/shims/utf-8-validate.cjs | 16 ++++ apps/desktop/vite.config.ts | 2 +- packages/readability/src/index.ts | 2 +- 5 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/layer/main/src/shims/utf-8-validate.cjs diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 56a904d7a..1b861c787 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ "@pkg": resolve("./package.json"), "@locales": resolve("../../locales"), "~": resolve("./layer/main/src"), + "utf-8-validate": resolve("./layer/main/src/shims/utf-8-validate.cjs"), }, }, define: { diff --git a/apps/desktop/layer/main/src/manager/window.ts b/apps/desktop/layer/main/src/manager/window.ts index dc8b3284b..e8d33e0d6 100644 --- a/apps/desktop/layer/main/src/manager/window.ts +++ b/apps/desktop/layer/main/src/manager/window.ts @@ -82,19 +82,24 @@ class WindowManagerStatic { refreshBound(window, this.config.refreshBoundDelay) }) - window.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: "deny" } - }) - - const handleExternalProtocol = async (e: Event, url: string, window: BrowserWindow) => { - const { protocol } = new URL(url) - - if (this.config.ignoreProtocols.includes(protocol.slice(0, -1) as any)) { - return + const parseProtocol = (url: string) => { + try { + return new URL(url).protocol.slice(0, -1) + } catch { + logger.warn("Blocked external URL with invalid format", { url }) + return null } - e.preventDefault() + } + const isIgnoredProtocol = ( + protocol: string, + ): protocol is (typeof this.config.ignoreProtocols)[number] => { + return this.config.ignoreProtocols.includes( + protocol as (typeof this.config.ignoreProtocols)[number], + ) + } + + const confirmAndOpenExternalProtocol = async (url: string) => { const caller = callWindowExpose(window) const confirm = await caller.dialog.ask({ title: t("dialog.openExternalApp.title"), @@ -108,15 +113,57 @@ class WindowManagerStatic { if (!confirm) { return } - shell.openExternal(url) + void shell.openExternal(url) + } + + window.webContents.setWindowOpenHandler((details) => { + const protocol = parseProtocol(details.url) + if (!protocol) { + return { action: "deny" } + } + + if (protocol === "http" || protocol === "https") { + void shell.openExternal(details.url) + return { action: "deny" } + } + + if (isIgnoredProtocol(protocol)) { + logger.warn("Blocked window.open for ignored protocol", { + protocol, + url: details.url, + }) + return { action: "deny" } + } + + void confirmAndOpenExternalProtocol(details.url) + return { action: "deny" } + }) + + const handleExternalProtocol = async (e: Event, url: string) => { + const protocol = parseProtocol(url) + if (!protocol) { + e.preventDefault() + return + } + + if (isIgnoredProtocol(protocol)) { + return + } + e.preventDefault() + + await confirmAndOpenExternalProtocol(url) } // Handle main window external links - window.webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window)) + window.webContents.on("will-navigate", (e, url) => { + void handleExternalProtocol(e, url) + }) // Handle webview external links window.webContents.on("did-attach-webview", (_, webContents) => { - webContents.on("will-navigate", (e, url) => handleExternalProtocol(e, url, window)) + webContents.on("will-navigate", (e, url) => { + void handleExternalProtocol(e, url) + }) }) if (isWindows) { diff --git a/apps/desktop/layer/main/src/shims/utf-8-validate.cjs b/apps/desktop/layer/main/src/shims/utf-8-validate.cjs new file mode 100644 index 000000000..3e1570c92 --- /dev/null +++ b/apps/desktop/layer/main/src/shims/utf-8-validate.cjs @@ -0,0 +1,16 @@ +"use strict" + +const { isUtf8 } = require("node:buffer") + +module.exports = function isValidUTF8(buffer) { + if (typeof isUtf8 === "function") { + return isUtf8(buffer) + } + + try { + new TextDecoder("utf-8", { fatal: true }).decode(buffer) + return true + } catch { + return false + } +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index ea4b9f22d..43dbac9fa 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -5,7 +5,7 @@ import type { env as EnvType } from "@follow/shared/env.desktop" import legacy from "@vitejs/plugin-legacy" import { minify as htmlMinify } from "html-minifier-terser" import { cyan, dim, green } from "kolorist" -import { parseHTML } from "linkedom" +import { parseHTML } from "linkedom/worker" import { join, resolve } from "pathe" import type { PluginOption, ResolvedConfig, ViteDevServer } from "vite" import { defineConfig, loadEnv } from "vite" diff --git a/packages/readability/src/index.ts b/packages/readability/src/index.ts index db17a4ad1..126b46b1c 100644 --- a/packages/readability/src/index.ts +++ b/packages/readability/src/index.ts @@ -1,7 +1,7 @@ import { Readability } from "@mozilla/readability" import chardet from "chardet" import DOMPurify from "dompurify" -import { parseHTML } from "linkedom" +import { parseHTML } from "linkedom/worker" const isDev = process.env.NODE_ENV === "development"