From 50524741a8e9952a32837c5debafa0ac2bc17363 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Wed, 15 Jul 2026 13:52:21 +0200 Subject: [PATCH 1/2] fix(electron): validate IPC sender frame for token-cache and OAuth handlers Token-cache and OAuth-transport IPC handlers accepted requests from any renderer frame. Untrusted content in a subframe or sharing the Clerk preload could read the persisted client JWT or drive the OAuth transport. Guard both against non-main-frame senders, mirroring the existing passkey handler check via a shared isMainFrameEvent helper. --- .changeset/fine-results-crash.md | 5 ++++ .../__tests__/create-clerk-bridge.test.ts | 28 +++++++++++++++++-- .../src/main/__tests__/ipc-handlers.test.ts | 20 ++++++++++++- packages/electron/src/main/ipc-handlers.ts | 17 +++++++++-- packages/electron/src/main/oauth-transport.ts | 12 ++++++-- .../electron/src/main/passkey-handlers.ts | 3 +- packages/electron/src/main/validate-sender.ts | 6 ++++ 7 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 .changeset/fine-results-crash.md create mode 100644 packages/electron/src/main/validate-sender.ts diff --git a/.changeset/fine-results-crash.md b/.changeset/fine-results-crash.md new file mode 100644 index 00000000000..ab445e8e569 --- /dev/null +++ b/.changeset/fine-results-crash.md @@ -0,0 +1,5 @@ +--- +'@clerk/electron': patch +--- + +Validate that token-cache and OAuth-transport IPC requests originate from a window's main frame. This prevents untrusted content in subframes or ``s that share the Clerk preload from reading the persisted client JWT or driving the OAuth transport. diff --git a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts index bf90b7fb745..a55a3fcc88e 100644 --- a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts +++ b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts @@ -34,6 +34,10 @@ vi.mock('@clerk/electron-passkeys', () => ({ }, })); +const mainFrame = {}; +const mainFrameEvent = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent; +const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent; + describe('createClerkBridge', () => { const missingStorage = {} as Parameters[0]; const storage: TokenStorage = { @@ -264,7 +268,27 @@ describe('createClerkBridge', () => { return channel === OAUTH_TRANSPORT_CHANNELS.getRedirectUrl; })?.[1]; - expect(getRedirectUrlHandler?.({} as Electron.IpcMainInvokeEvent)).toBe('my-app://renderer/'); + expect(getRedirectUrlHandler?.(mainFrameEvent)).toBe('my-app://renderer/'); + }); + + it('rejects OAuth transport requests that do not originate from the main frame', async () => { + vi.mocked(shell.openExternal).mockResolvedValue(undefined); + createClerkBridge({ + storage, + renderer: { + host: 'renderer', + scheme: 'my-app', + }, + }); + + const findHandler = (channel: string) => + vi.mocked(ipcMain.handle).mock.calls.find(([registered]) => registered === channel)?.[1]; + + expect(() => findHandler(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl)?.(subframeEvent)).toThrow('main frame'); + await expect( + findHandler(OAUTH_TRANSPORT_CHANNELS.open)?.(subframeEvent, 'https://accounts.example.com/oauth'), + ).rejects.toThrow('main frame'); + expect(shell.openExternal).not.toHaveBeenCalled(); }); it('opens OAuth URLs externally and resolves with the matching deep-link callback URL', async () => { @@ -280,7 +304,7 @@ describe('createClerkBridge', () => { const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => { return channel === OAUTH_TRANSPORT_CHANNELS.open; })?.[1]; - const openPromise = openHandler?.({} as Electron.IpcMainInvokeEvent, 'https://accounts.example.com/oauth'); + const openPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth'); const openUrlListener = vi.mocked(app.on).mock.calls.find(([event]) => event === 'open-url')?.[1] as ( event: Electron.Event, url: string, diff --git a/packages/electron/src/main/__tests__/ipc-handlers.test.ts b/packages/electron/src/main/__tests__/ipc-handlers.test.ts index c57c98220f4..ce132f78d91 100644 --- a/packages/electron/src/main/__tests__/ipc-handlers.test.ts +++ b/packages/electron/src/main/__tests__/ipc-handlers.test.ts @@ -5,7 +5,9 @@ import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc'; import type { TokenStorage } from '../../shared/types'; import { setupTokenCacheIpcHandlers } from '../ipc-handlers'; -const ipcEvent = {} as Electron.IpcMainInvokeEvent; +const mainFrame = {}; +const ipcEvent = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent; +const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent; vi.mock('electron', () => ({ ipcMain: { @@ -51,6 +53,22 @@ describe('setupTokenCacheIpcHandlers', () => { expect(storage.removeItem).toHaveBeenCalledWith('token-key'); }); + it('rejects token operations that do not originate from the main frame', () => { + setupTokenCacheIpcHandlers(storage); + + const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1]; + const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1]; + const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1]; + + expect(() => getTokenHandler(subframeEvent, 'token-key')).toThrow('main frame'); + expect(() => saveTokenHandler(subframeEvent, 'token-key', 'jwt')).toThrow('main frame'); + expect(() => clearTokenHandler(subframeEvent, 'token-key')).toThrow('main frame'); + + expect(storage.getItem).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); + }); + it('removes registered handlers on cleanup', () => { const cleanup = setupTokenCacheIpcHandlers(storage); diff --git a/packages/electron/src/main/ipc-handlers.ts b/packages/electron/src/main/ipc-handlers.ts index 5114729404b..2bd8026f902 100644 --- a/packages/electron/src/main/ipc-handlers.ts +++ b/packages/electron/src/main/ipc-handlers.ts @@ -1,18 +1,29 @@ +import type { IpcMainInvokeEvent } from 'electron'; import { ipcMain } from 'electron'; import { TOKEN_CACHE_CHANNELS } from '../shared/ipc'; import type { TokenStorage } from '../shared/types'; +import { isMainFrameEvent } from './validate-sender'; + +function assertMainFrameSender(event: IpcMainInvokeEvent): void { + if (!isMainFrameEvent(event)) { + throw new Error("Clerk: token-cache request did not originate from a window's main frame."); + } +} export function setupTokenCacheIpcHandlers(storage: TokenStorage): () => void { - ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (_event, key: string) => { + ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (event, key: string) => { + assertMainFrameSender(event); return storage.getItem(key); }); - ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (_event, key: string, value: string) => { + ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (event, key: string, value: string) => { + assertMainFrameSender(event); return storage.setItem(key, value); }); - ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (_event, key: string) => { + ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (event, key: string) => { + assertMainFrameSender(event); return storage.removeItem(key); }); diff --git a/packages/electron/src/main/oauth-transport.ts b/packages/electron/src/main/oauth-transport.ts index 7cfd61414dd..3686b38d4b7 100644 --- a/packages/electron/src/main/oauth-transport.ts +++ b/packages/electron/src/main/oauth-transport.ts @@ -2,6 +2,7 @@ import { app, ipcMain, shell } from 'electron'; import { OAUTH_TRANSPORT_CHANNELS } from '../shared/ipc'; import type { RendererSchemeOptions } from '../shared/types'; +import { isMainFrameEvent } from './validate-sender'; const CALLBACK_TIMEOUT_MS = 3 * 60 * 1000; @@ -91,11 +92,18 @@ export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions): app.on('open-url', openUrlListener); app.on('second-instance', secondInstanceListener); - ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, () => { + ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, event => { + if (!isMainFrameEvent(event)) { + throw new Error("Clerk: OAuth request did not originate from a window's main frame."); + } return redirectUrl; }); - ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.open, async (_event, url: string) => { + ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.open, async (event, url: string) => { + if (!isMainFrameEvent(event)) { + throw new Error("Clerk: OAuth request did not originate from a window's main frame."); + } + if (pendingOAuthFlow) { throw new Error('Clerk: an OAuth flow is already pending.'); } diff --git a/packages/electron/src/main/passkey-handlers.ts b/packages/electron/src/main/passkey-handlers.ts index 408dc50023e..78b86d0a90e 100644 --- a/packages/electron/src/main/passkey-handlers.ts +++ b/packages/electron/src/main/passkey-handlers.ts @@ -12,6 +12,7 @@ import type { SerializedPublicKeyCredentialRequestOptions, SetupPasskeysMainReturn, } from '../shared/types'; +import { isMainFrameEvent } from './validate-sender'; /** * Optional native module. Ceremony failures resolve as JSON envelopes so error @@ -77,7 +78,7 @@ async function invokeNative( // Subframes and webviews can host third-party content that must not be able // to run credential ceremonies for the app's RP ID. - if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) { + if (!isMainFrameEvent(event)) { return { ok: false, error: { code: 'unknown', message: "The passkey request did not originate from a window's main frame." }, diff --git a/packages/electron/src/main/validate-sender.ts b/packages/electron/src/main/validate-sender.ts new file mode 100644 index 00000000000..ca5b9c35bdf --- /dev/null +++ b/packages/electron/src/main/validate-sender.ts @@ -0,0 +1,6 @@ +import type { IpcMainInvokeEvent } from 'electron'; + +// Restricts privileged IPC to a window's top-level frame so untrusted subframes/webviews sharing the Clerk preload cannot reach it. +export function isMainFrameEvent(event: IpcMainInvokeEvent): boolean { + return Boolean(event.senderFrame) && event.senderFrame === event.sender.mainFrame; +} From 6610740c79fe53b89365414bcdefd9d9b9ad56c3 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Thu, 16 Jul 2026 09:56:50 +0200 Subject: [PATCH 2/2] fix(electron): reject senders in main-frame IPC guard isMainFrameEvent only proved the sender was the top document of its own WebContents, so a (a separate WebContents) sharing the Clerk preload satisfied senderFrame === mainFrame and passed the guard. Require getType() === 'window' to tie privileged IPC to a top-level BrowserWindow, rejecting guests and BrowserViews. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fine-results-crash.md | 2 +- .../__tests__/create-clerk-bridge.test.ts | 30 +++++++++++++++++-- .../src/main/__tests__/ipc-handlers.test.ts | 26 ++++++++++++++-- .../main/__tests__/passkey-handlers.test.ts | 10 +++++-- packages/electron/src/main/validate-sender.ts | 8 +++-- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/.changeset/fine-results-crash.md b/.changeset/fine-results-crash.md index ab445e8e569..603b86338fd 100644 --- a/.changeset/fine-results-crash.md +++ b/.changeset/fine-results-crash.md @@ -2,4 +2,4 @@ '@clerk/electron': patch --- -Validate that token-cache and OAuth-transport IPC requests originate from a window's main frame. This prevents untrusted content in subframes or ``s that share the Clerk preload from reading the persisted client JWT or driving the OAuth transport. +Validate that token-cache and OAuth-transport IPC requests originate from a top-level window's main frame. This prevents untrusted content in subframes or ``s that share the Clerk preload from reading the persisted client JWT or driving the OAuth transport. diff --git a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts index a55a3fcc88e..6974c2348a5 100644 --- a/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts +++ b/packages/electron/src/main/__tests__/create-clerk-bridge.test.ts @@ -35,8 +35,9 @@ vi.mock('@clerk/electron-passkeys', () => ({ })); const mainFrame = {}; -const mainFrameEvent = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent; -const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent; +const windowSender = { mainFrame, getType: () => 'window' }; +const mainFrameEvent = { sender: windowSender, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent; +const subframeEvent = { sender: windowSender, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent; describe('createClerkBridge', () => { const missingStorage = {} as Parameters[0]; @@ -291,6 +292,31 @@ describe('createClerkBridge', () => { expect(shell.openExternal).not.toHaveBeenCalled(); }); + it('rejects OAuth transport requests from a whose top frame mimics the main frame', async () => { + vi.mocked(shell.openExternal).mockResolvedValue(undefined); + createClerkBridge({ + storage, + renderer: { + host: 'renderer', + scheme: 'my-app', + }, + }); + + const findHandler = (channel: string) => + vi.mocked(ipcMain.handle).mock.calls.find(([registered]) => registered === channel)?.[1]; + + const webviewEvent = { + sender: { mainFrame, getType: () => 'webview' }, + senderFrame: mainFrame, + } as unknown as Electron.IpcMainInvokeEvent; + + expect(() => findHandler(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl)?.(webviewEvent)).toThrow('main frame'); + await expect( + findHandler(OAUTH_TRANSPORT_CHANNELS.open)?.(webviewEvent, 'https://accounts.example.com/oauth'), + ).rejects.toThrow('main frame'); + expect(shell.openExternal).not.toHaveBeenCalled(); + }); + it('opens OAuth URLs externally and resolves with the matching deep-link callback URL', async () => { vi.mocked(shell.openExternal).mockResolvedValue(undefined); createClerkBridge({ diff --git a/packages/electron/src/main/__tests__/ipc-handlers.test.ts b/packages/electron/src/main/__tests__/ipc-handlers.test.ts index ce132f78d91..e307638cfd8 100644 --- a/packages/electron/src/main/__tests__/ipc-handlers.test.ts +++ b/packages/electron/src/main/__tests__/ipc-handlers.test.ts @@ -6,8 +6,14 @@ import type { TokenStorage } from '../../shared/types'; import { setupTokenCacheIpcHandlers } from '../ipc-handlers'; const mainFrame = {}; -const ipcEvent = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent; -const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent; +const windowSender = { mainFrame, getType: () => 'window' }; +const ipcEvent = { sender: windowSender, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent; +const subframeEvent = { sender: windowSender, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent; +// A is a separate WebContents whose top document satisfies senderFrame === mainFrame. +const webviewEvent = { + sender: { mainFrame, getType: () => 'webview' }, + senderFrame: mainFrame, +} as unknown as Electron.IpcMainInvokeEvent; vi.mock('electron', () => ({ ipcMain: { @@ -69,6 +75,22 @@ describe('setupTokenCacheIpcHandlers', () => { expect(storage.removeItem).not.toHaveBeenCalled(); }); + it('rejects token operations from a whose top frame mimics the main frame', () => { + setupTokenCacheIpcHandlers(storage); + + const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1]; + const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1]; + const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1]; + + expect(() => getTokenHandler(webviewEvent, 'token-key')).toThrow('main frame'); + expect(() => saveTokenHandler(webviewEvent, 'token-key', 'jwt')).toThrow('main frame'); + expect(() => clearTokenHandler(webviewEvent, 'token-key')).toThrow('main frame'); + + expect(storage.getItem).not.toHaveBeenCalled(); + expect(storage.setItem).not.toHaveBeenCalled(); + expect(storage.removeItem).not.toHaveBeenCalled(); + }); + it('removes registered handlers on cleanup', () => { const cleanup = setupTokenCacheIpcHandlers(storage); diff --git a/packages/electron/src/main/__tests__/passkey-handlers.test.ts b/packages/electron/src/main/__tests__/passkey-handlers.test.ts index a4664c766f1..2852a05d8b2 100644 --- a/packages/electron/src/main/__tests__/passkey-handlers.test.ts +++ b/packages/electron/src/main/__tests__/passkey-handlers.test.ts @@ -36,7 +36,10 @@ const getHandler = (channel: string): Handler => { const windowHandle = Buffer.from([1, 2, 3, 4]); const mainFrame = {}; -const event = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as IpcMainInvokeEvent; +const event = { + sender: { mainFrame, getType: () => 'window' }, + senderFrame: mainFrame, +} as unknown as IpcMainInvokeEvent; const creationOptions = { challenge: 'abc', rp: { id: 'example.com', name: 'Example' } }; const registrationJSON = { id: 'cred', rawId: 'cred', type: 'public-key', response: {} }; @@ -98,7 +101,10 @@ describe('setupPasskeysMain', () => { it('rejects requests that do not originate from the main frame', async () => { setupPasskeysMain(); - const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as IpcMainInvokeEvent; + const subframeEvent = { + sender: { mainFrame, getType: () => 'window' }, + senderFrame: {}, + } as unknown as IpcMainInvokeEvent; const result = await getHandler(PASSKEY_CHANNELS.create)(subframeEvent, creationOptions); expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } }); diff --git a/packages/electron/src/main/validate-sender.ts b/packages/electron/src/main/validate-sender.ts index ca5b9c35bdf..b4e0473cf93 100644 --- a/packages/electron/src/main/validate-sender.ts +++ b/packages/electron/src/main/validate-sender.ts @@ -1,6 +1,10 @@ import type { IpcMainInvokeEvent } from 'electron'; -// Restricts privileged IPC to a window's top-level frame so untrusted subframes/webviews sharing the Clerk preload cannot reach it. +// Restricts privileged IPC to a top-level BrowserWindow's main frame. The frame check alone +// only proves the sender is the top document of *its own* WebContents, so a (a separate +// WebContents) sharing the Clerk preload would still pass; requiring getType() === 'window' rejects +// guests and BrowserViews, and the frame check rejects in-page iframes. export function isMainFrameEvent(event: IpcMainInvokeEvent): boolean { - return Boolean(event.senderFrame) && event.senderFrame === event.sender.mainFrame; + const { sender, senderFrame } = event; + return sender.getType() === 'window' && Boolean(senderFrame) && senderFrame === sender.mainFrame; }