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.
CVE-2026-43899: DeepChat Gated openExternal in the Preload. The Tab Window Imported shell.
patterns
cve
The patch is three imports
The 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.
Three 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:'.
The fourth call site was in src/main/presenter/tabPresenter.ts:
// 处理外部链接
webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})The comment translates to "handle external links."
The tab window is the renderer's window
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.
In 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.
This 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.
The renderer never calls api.openExternal for these
The 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.
The 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.
The AI is the content channel
DeepChat 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.
A markdown link reads [innocuous text](url). The renderer turns it into <a href="url" target="_blank">innocuous text</a>. The user reads "Click here for documentation" and clicks. The handler in tabPresenter fires. shell.openExternal runs with whatever string was inside the parentheses.
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\<scheme> 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.
A 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.
Same vendor, four opinions about the same boundary
This is the part the CVE description does not say. The pre-patch state of shell.openExternal across DeepChat:
preload/index.ts'sapi.openExternalIPC: allowlist of five protocols.tabPresenter.ts'ssetupWebContentsListeners: no check.windowPresenter/index.ts'ssetupManagedWindowOpenHandler: denylist of four protocols (about:,blob:,data:,javascript:).windowPresenter/index.ts's settings-window handler: allowlist of two protocols (http:,https:).
Four call sites. Three different policies. One with no policy at all.
The 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.
The fix is the function the preload already had
The April 25 commit, titled "Merge commit from fork," ships the consolidation in one diff:
+ // src/shared/externalUrl.ts (new)
+ export const ALLOWED_EXTERNAL_PROTOCOLS = Object.freeze([
+ 'http:', 'https:', 'mailto:', 'tel:', 'deepchat:'
+ ] as const)
+
+ export function normalizeExternalUrl(url: string): string | null { /* ... */ }
+ export function isValidExternalUrl(url: string): boolean {
+ return normalizeExternalUrl(url) !== null
+ } // src/main/presenter/tabPresenter.ts
- import { shell, ... } from 'electron'
+ import { openExternalUrl } from '@/lib/externalUrl'
webContents.setWindowOpenHandler(({ url }) => {
- shell.openExternal(url)
+ openExternalUrl(url, 'tab window')
return { action: 'deny' }
}) // src/main/presenter/windowPresenter/index.ts (managed window)
- private readonly blockedWindowOpenProtocols = new Set(['about:', 'blob:', 'data:', 'javascript:'])
- // 18 lines of inline denylist enforcement
+ openExternalUrl(url, 'managed window') // src/main/presenter/windowPresenter/index.ts (settings window)
- // 12 lines of inline allowlist enforcement, http and https only
+ openExternalUrl(url, 'settings window') // src/preload/index.ts
- const ALLOWED_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:', 'deepchat:']
- const isValidExternalUrl = (url: string): boolean => { /* ... */ }
+ import { normalizeExternalUrl } from '@shared/externalUrl'The 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.
Why does this exist
Electron 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.
The 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 <a target="_blank">. 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.
DeepChat 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.
This is the 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.
PoC: not yet public. Code: ThinkInAIXYZ/deepchat.
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.