-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The patch is three imports\n\nThe April 25, 2026 fix for CVE-2026-43899 ships a new file: `src/shared/externalUrl.ts`. It exports two functions, `normalizeExternalUrl` and `isValidExternalUrl`, whose implementations are byte-equivalent to the function the preload defined ten weeks earlier, lifted into a `@shared/` path that both the preload and main processes can import. The patch updates four call sites to import from there.\n\nThree of the four call sites already had a gate of their own. The preload had `isValidExternalUrl` since February. The managed-window handler in `windowPresenter/index.ts` had a Set named `blockedWindowOpenProtocols` containing `about:`, `blob:`, `data:`, `javascript:`. The settings-window handler in the same file checked `parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'`.\n\nThe fourth call site was in `src/main/presenter/tabPresenter.ts`:\n\n```ts\n// 处理外部链接\nwebContents.setWindowOpenHandler(({ url }) => {\n shell.openExternal(url)\n return { action: 'deny' }\n})\n```\n\nThe comment translates to \"handle external links.\"\n\n## The tab window is the renderer's window\n\n`webContents.setWindowOpenHandler` fires when a renderer process attempts to open a new window. The trigger surface includes `window.open(url, '_blank')` called from renderer JavaScript, form submissions targeted at `_blank`, and clicks on anchor elements with `target=\"_blank\"`. The handler receives the requested URL and decides what to do with it.\n\nIn DeepChat, every tab the user sees runs through `TabPresenter`. The chat tab where the model's responses are rendered is one of these tabs. The model's output is Markdown. Markdown rendering produces anchor elements. Anchor elements pointing to a different origin receive `target=\"_blank\"` so the click does not navigate the chat away from the conversation. The click fires the handler. The handler in `tabPresenter.ts`, before this patch, called `shell.openExternal(url)` directly, with no validation of any kind.\n\nThis is the path the CVE description names. The description reads, in part: \"a target=\\\"_blank\\\" native window interception in tabPresenter.ts, which forwards the malicious URL directly to shell.openExternal(url) and completely bypasses the isValidExternalUrl security boundary.\" The `isValidExternalUrl` it names is the function in the preload. The \"bypass\" is not a bypass in the cryptographic sense. The handler simply did not call the function.\n\n## The renderer never calls api.openExternal for these\n\nThe preload's `api.openExternal` is reachable from renderer code by calling `window.api.openExternal(url)`. It exists for renderer JavaScript that wants to programmatically open a URL: a settings page's \"Open in browser\" button, a deeplink callback, an explicit share affordance. The preload gates that call. Every place the renderer JavaScript chooses to invoke `openExternal` is run through the allowlist.\n\nThe user clicking a markdown link does not go through `api.openExternal`. The user clicking a markdown link goes through Electron's native handling for anchor elements with `target=\"_blank\"`. That path lives in the main process, not the renderer, and the gate the preload added does not run there. The gate was at the layer the renderer's JavaScript could call. The boundary that needed gating was the layer Electron's framework code calls.\n\n## The AI is the content channel\n\nDeepChat is an AI agent platform. The chat tab renders model responses. The model is not always your own; DeepChat connects to multiple providers, accepts MCP servers, executes tool calls, and routes the model's output back into the conversation as Markdown. Any of those upstream sources can put a Markdown link into the model's response.\n\nA markdown link reads `[innocuous text](url)`. The renderer turns it into `innocuous text`. The user reads \"Click here for documentation\" and clicks. The handler in `tabPresenter` fires. `shell.openExternal` runs with whatever string was inside the parentheses.\n\n`shell.openExternal` does not validate the protocol. It hands the URL to the operating system, which dispatches to whatever handler is registered for that scheme. On macOS that includes every app whose `Info.plist` declares a `CFBundleURLSchemes` entry. On Windows it includes every registered `HKEY_CLASSES_ROOT\\` entry. On Linux it includes every desktop file's URL-scheme registration. It also includes `file://`. It also includes whatever protocol handlers the user's installed software registered without auditing.\n\nA compromised model, a poisoned tool result, a prompt-injected document loaded into the model's context: all of these become content channels for a Markdown link the user will click because the AI's UI is the trusted surface in the application. This is content-is-command staged across two interpreters with the agent runtime in between. The Markdown renderer is the first interpreter; the OS protocol-handler dispatcher is the second. `shell.openExternal` is the bridge.\n\n## Same vendor, four opinions about the same boundary\n\nThis is the part the CVE description does not say. The pre-patch state of `shell.openExternal` across DeepChat:\n\n- `preload/index.ts`'s `api.openExternal` IPC: allowlist of five protocols.\n- `tabPresenter.ts`'s `setupWebContentsListeners`: no check.\n- `windowPresenter/index.ts`'s `setupManagedWindowOpenHandler`: denylist of four protocols (`about:`, `blob:`, `data:`, `javascript:`).\n- `windowPresenter/index.ts`'s settings-window handler: allowlist of two protocols (`http:`, `https:`).\n\nFour call sites. Three different policies. One with no policy at all.\n\nThe denylist of four protocols does not block `calculator://`, `file://`, `smb://`, or any OS-installed protocol handler. The strictest allowlist (settings window) blocks `mailto:` and `tel:`, which the preload's allowlist permits. The contracts disagreed about what \"external URL\" was allowed to mean and nobody picked one. The Feb 14 patch added a fifth opinion. The fix for that CVE was scoped to the file the original report named. Three other call sites had been waiting for someone to look at them.\n\n## The fix is the function the preload already had\n\nThe April 25 commit, titled \"Merge commit from fork,\" ships the consolidation in one diff:\n\n```diff\n+ // src/shared/externalUrl.ts (new)\n+ export const ALLOWED_EXTERNAL_PROTOCOLS = Object.freeze([\n+ 'http:', 'https:', 'mailto:', 'tel:', 'deepchat:'\n+ ] as const)\n+\n+ export function normalizeExternalUrl(url: string): string | null { /* ... */ }\n+ export function isValidExternalUrl(url: string): boolean {\n+ return normalizeExternalUrl(url) !== null\n+ }\n```\n\n```diff\n // src/main/presenter/tabPresenter.ts\n- import { shell, ... } from 'electron'\n+ import { openExternalUrl } from '@/lib/externalUrl'\n\n webContents.setWindowOpenHandler(({ url }) => {\n- shell.openExternal(url)\n+ openExternalUrl(url, 'tab window')\n return { action: 'deny' }\n })\n```\n\n```diff\n // src/main/presenter/windowPresenter/index.ts (managed window)\n- private readonly blockedWindowOpenProtocols = new Set(['about:', 'blob:', 'data:', 'javascript:'])\n- // 18 lines of inline denylist enforcement\n+ openExternalUrl(url, 'managed window')\n```\n\n```diff\n // src/main/presenter/windowPresenter/index.ts (settings window)\n- // 12 lines of inline allowlist enforcement, http and https only\n+ openExternalUrl(url, 'settings window')\n```\n\n```diff\n // src/preload/index.ts\n- const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:', 'deepchat:']\n- const isValidExternalUrl = (url: string): boolean => { /* ... */ }\n+ import { normalizeExternalUrl } from '@shared/externalUrl'\n```\n\nThe patch is not a new control. The patch is the function the preload defined on February 14, 2026, lifted into a shared module and applied at the three call sites that were missing it.\n\n## Why does this exist\n\nElectron applications have two trust boundaries at the IPC layer. The renderer is untrusted; the main process is trusted; the preload sits between them and is where the application defines what the renderer is allowed to do. The CVE-2025-55733 patch was scoped exactly to that boundary. The preload's `api.openExternal` was the renderer's documented way to open URLs. Adding `isValidExternalUrl` to it closed the documented path.\n\nThe undocumented paths are the framework's. `setWindowOpenHandler` is Electron's handler for what the renderer does without asking. The renderer does not call an IPC method. The renderer renders HTML. The HTML contains an ``. The user clicks. The handler the application registered fires, in the main process, on a URL the application did not type and the renderer did not validate.\n\nDeepChat registers four such call sites. Each was authored by a separate concern (settings window, managed window, tab window, the API method) at a separate time. Each author made an independent decision about what URLs the framework should be allowed to forward. No utility module held the contract. The contract was \"the implementations should agree,\" held in nobody's editor.\n\nThis is the [parallel-implementation-gap](/patterns/parallel-implementation-gap) shape. The canonical implementation is the preload IPC's allowlist, which was hardened first. The three divergent implementations are the framework-handler call sites, which were authored separately and which the preload's commit did not update. The fix is a shared utility three of the four call sites now import.\n\nPoC: not yet public. Code: [ThinkInAIXYZ/deepchat](https://github.com/ThinkInAIXYZ/deepchat).","closing_line":"`shell.openExternal` was the boundary. The gate lived at four call sites, three of them had their own opinion, and the worst of them was the path the AI's markdown links flowed through.","hook_md":"DeepChat is an Electron-based AI agent platform that renders model responses as Markdown. On February 14, 2026, commit `89600d8b` added a function named `isValidExternalUrl` to `src/preload/index.ts`. It is a six-line allowlist over five protocols: `http:`, `https:`, `mailto:`, `tel:`, `deepchat:`. The function fixed CVE-2025-55733. It gates exactly one call site: the renderer's `api.openExternal` IPC method. Three other places in the main process call `shell.openExternal` on attacker-influenceable URLs. None of them imported `isValidExternalUrl`. One of them had no check at all.","post_id":333,"slug":"deepchat-cve-2026-43899-tab-window-imported-shell","title":"CVE-2026-43899: DeepChat Gated openExternal in the Preload. The Tab Window Imported shell.","type":"initial","unreadable_sentence":"Four call sites. Three different policies. One with no policy at all."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCainK3AAKCRDeZjl4jgkQ JhZUAQC72PCKF5QNpFKgWoZOEmzYdScVlJFv4KDN+ciQ9MPYgAEA37AWdRfw/TVU r2GMkTPz1a6Ys5mu/di/GQjnVQ1n7AQ= =mZP0 -----END PGP SIGNATURE-----