Skip to content

Commit 89916b0

Browse files
fix(electron): validate IPC sender frame for token-cache and OAuth handlers (#9167)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b37ba0d commit 89916b0

8 files changed

Lines changed: 142 additions & 11 deletions

File tree

.changeset/fine-results-crash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/electron': patch
3+
---
4+
5+
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 `<webview>`s that share the Clerk preload from reading the persisted client JWT or driving the OAuth transport.

packages/electron/src/main/__tests__/create-clerk-bridge.test.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ vi.mock('@clerk/electron-passkeys', () => ({
3434
},
3535
}));
3636

37+
const mainFrame = {};
38+
const windowSender = { mainFrame, getType: () => 'window' };
39+
const mainFrameEvent = { sender: windowSender, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent;
40+
const subframeEvent = { sender: windowSender, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent;
41+
3742
describe('createClerkBridge', () => {
3843
const missingStorage = {} as Parameters<typeof createClerkBridge>[0];
3944
const storage: TokenStorage = {
@@ -264,7 +269,52 @@ describe('createClerkBridge', () => {
264269
return channel === OAUTH_TRANSPORT_CHANNELS.getRedirectUrl;
265270
})?.[1];
266271

267-
expect(getRedirectUrlHandler?.({} as Electron.IpcMainInvokeEvent)).toBe('my-app://renderer/');
272+
expect(getRedirectUrlHandler?.(mainFrameEvent)).toBe('my-app://renderer/');
273+
});
274+
275+
it('rejects OAuth transport requests that do not originate from the main frame', async () => {
276+
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
277+
createClerkBridge({
278+
storage,
279+
renderer: {
280+
host: 'renderer',
281+
scheme: 'my-app',
282+
},
283+
});
284+
285+
const findHandler = (channel: string) =>
286+
vi.mocked(ipcMain.handle).mock.calls.find(([registered]) => registered === channel)?.[1];
287+
288+
expect(() => findHandler(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl)?.(subframeEvent)).toThrow('main frame');
289+
await expect(
290+
findHandler(OAUTH_TRANSPORT_CHANNELS.open)?.(subframeEvent, 'https://accounts.example.com/oauth'),
291+
).rejects.toThrow('main frame');
292+
expect(shell.openExternal).not.toHaveBeenCalled();
293+
});
294+
295+
it('rejects OAuth transport requests from a <webview> whose top frame mimics the main frame', async () => {
296+
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
297+
createClerkBridge({
298+
storage,
299+
renderer: {
300+
host: 'renderer',
301+
scheme: 'my-app',
302+
},
303+
});
304+
305+
const findHandler = (channel: string) =>
306+
vi.mocked(ipcMain.handle).mock.calls.find(([registered]) => registered === channel)?.[1];
307+
308+
const webviewEvent = {
309+
sender: { mainFrame, getType: () => 'webview' },
310+
senderFrame: mainFrame,
311+
} as unknown as Electron.IpcMainInvokeEvent;
312+
313+
expect(() => findHandler(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl)?.(webviewEvent)).toThrow('main frame');
314+
await expect(
315+
findHandler(OAUTH_TRANSPORT_CHANNELS.open)?.(webviewEvent, 'https://accounts.example.com/oauth'),
316+
).rejects.toThrow('main frame');
317+
expect(shell.openExternal).not.toHaveBeenCalled();
268318
});
269319

270320
it('opens OAuth URLs externally and resolves with the matching deep-link callback URL', async () => {
@@ -280,7 +330,7 @@ describe('createClerkBridge', () => {
280330
const openHandler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => {
281331
return channel === OAUTH_TRANSPORT_CHANNELS.open;
282332
})?.[1];
283-
const openPromise = openHandler?.({} as Electron.IpcMainInvokeEvent, 'https://accounts.example.com/oauth');
333+
const openPromise = openHandler?.(mainFrameEvent, 'https://accounts.example.com/oauth');
284334
const openUrlListener = vi.mocked(app.on).mock.calls.find(([event]) => event === 'open-url')?.[1] as (
285335
event: Electron.Event,
286336
url: string,

packages/electron/src/main/__tests__/ipc-handlers.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ import { TOKEN_CACHE_CHANNELS } from '../../shared/ipc';
55
import type { TokenStorage } from '../../shared/types';
66
import { setupTokenCacheIpcHandlers } from '../ipc-handlers';
77

8-
const ipcEvent = {} as Electron.IpcMainInvokeEvent;
8+
const mainFrame = {};
9+
const windowSender = { mainFrame, getType: () => 'window' };
10+
const ipcEvent = { sender: windowSender, senderFrame: mainFrame } as unknown as Electron.IpcMainInvokeEvent;
11+
const subframeEvent = { sender: windowSender, senderFrame: {} } as unknown as Electron.IpcMainInvokeEvent;
12+
// A <webview> is a separate WebContents whose top document satisfies senderFrame === mainFrame.
13+
const webviewEvent = {
14+
sender: { mainFrame, getType: () => 'webview' },
15+
senderFrame: mainFrame,
16+
} as unknown as Electron.IpcMainInvokeEvent;
917

1018
vi.mock('electron', () => ({
1119
ipcMain: {
@@ -51,6 +59,38 @@ describe('setupTokenCacheIpcHandlers', () => {
5159
expect(storage.removeItem).toHaveBeenCalledWith('token-key');
5260
});
5361

62+
it('rejects token operations that do not originate from the main frame', () => {
63+
setupTokenCacheIpcHandlers(storage);
64+
65+
const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1];
66+
const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1];
67+
const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1];
68+
69+
expect(() => getTokenHandler(subframeEvent, 'token-key')).toThrow('main frame');
70+
expect(() => saveTokenHandler(subframeEvent, 'token-key', 'jwt')).toThrow('main frame');
71+
expect(() => clearTokenHandler(subframeEvent, 'token-key')).toThrow('main frame');
72+
73+
expect(storage.getItem).not.toHaveBeenCalled();
74+
expect(storage.setItem).not.toHaveBeenCalled();
75+
expect(storage.removeItem).not.toHaveBeenCalled();
76+
});
77+
78+
it('rejects token operations from a <webview> whose top frame mimics the main frame', () => {
79+
setupTokenCacheIpcHandlers(storage);
80+
81+
const getTokenHandler = vi.mocked(ipcMain.handle).mock.calls[0][1];
82+
const saveTokenHandler = vi.mocked(ipcMain.handle).mock.calls[1][1];
83+
const clearTokenHandler = vi.mocked(ipcMain.handle).mock.calls[2][1];
84+
85+
expect(() => getTokenHandler(webviewEvent, 'token-key')).toThrow('main frame');
86+
expect(() => saveTokenHandler(webviewEvent, 'token-key', 'jwt')).toThrow('main frame');
87+
expect(() => clearTokenHandler(webviewEvent, 'token-key')).toThrow('main frame');
88+
89+
expect(storage.getItem).not.toHaveBeenCalled();
90+
expect(storage.setItem).not.toHaveBeenCalled();
91+
expect(storage.removeItem).not.toHaveBeenCalled();
92+
});
93+
5494
it('removes registered handlers on cleanup', () => {
5595
const cleanup = setupTokenCacheIpcHandlers(storage);
5696

packages/electron/src/main/__tests__/passkey-handlers.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ const getHandler = (channel: string): Handler => {
3636

3737
const windowHandle = Buffer.from([1, 2, 3, 4]);
3838
const mainFrame = {};
39-
const event = { sender: { mainFrame }, senderFrame: mainFrame } as unknown as IpcMainInvokeEvent;
39+
const event = {
40+
sender: { mainFrame, getType: () => 'window' },
41+
senderFrame: mainFrame,
42+
} as unknown as IpcMainInvokeEvent;
4043

4144
const creationOptions = { challenge: 'abc', rp: { id: 'example.com', name: 'Example' } };
4245
const registrationJSON = { id: 'cred', rawId: 'cred', type: 'public-key', response: {} };
@@ -98,7 +101,10 @@ describe('setupPasskeysMain', () => {
98101
it('rejects requests that do not originate from the main frame', async () => {
99102
setupPasskeysMain();
100103

101-
const subframeEvent = { sender: { mainFrame }, senderFrame: {} } as unknown as IpcMainInvokeEvent;
104+
const subframeEvent = {
105+
sender: { mainFrame, getType: () => 'window' },
106+
senderFrame: {},
107+
} as unknown as IpcMainInvokeEvent;
102108
const result = await getHandler(PASSKEY_CHANNELS.create)(subframeEvent, creationOptions);
103109

104110
expect(result).toMatchObject({ ok: false, error: { code: 'unknown' } });

packages/electron/src/main/ipc-handlers.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
1+
import type { IpcMainInvokeEvent } from 'electron';
12
import { ipcMain } from 'electron';
23

34
import { TOKEN_CACHE_CHANNELS } from '../shared/ipc';
45
import type { TokenStorage } from '../shared/types';
6+
import { isMainFrameEvent } from './validate-sender';
7+
8+
function assertMainFrameSender(event: IpcMainInvokeEvent): void {
9+
if (!isMainFrameEvent(event)) {
10+
throw new Error("Clerk: token-cache request did not originate from a window's main frame.");
11+
}
12+
}
513

614
export function setupTokenCacheIpcHandlers(storage: TokenStorage): () => void {
7-
ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (_event, key: string) => {
15+
ipcMain.handle(TOKEN_CACHE_CHANNELS.getToken, (event, key: string) => {
16+
assertMainFrameSender(event);
817
return storage.getItem(key);
918
});
1019

11-
ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (_event, key: string, value: string) => {
20+
ipcMain.handle(TOKEN_CACHE_CHANNELS.saveToken, (event, key: string, value: string) => {
21+
assertMainFrameSender(event);
1222
return storage.setItem(key, value);
1323
});
1424

15-
ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (_event, key: string) => {
25+
ipcMain.handle(TOKEN_CACHE_CHANNELS.clearToken, (event, key: string) => {
26+
assertMainFrameSender(event);
1627
return storage.removeItem(key);
1728
});
1829

packages/electron/src/main/oauth-transport.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { app, ipcMain, shell } from 'electron';
22

33
import { OAUTH_TRANSPORT_CHANNELS } from '../shared/ipc';
44
import type { RendererSchemeOptions } from '../shared/types';
5+
import { isMainFrameEvent } from './validate-sender';
56

67
const CALLBACK_TIMEOUT_MS = 3 * 60 * 1000;
78

@@ -91,11 +92,18 @@ export function setupOAuthTransportIpcHandlers(options: OAuthTransportOptions):
9192
app.on('open-url', openUrlListener);
9293
app.on('second-instance', secondInstanceListener);
9394

94-
ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, () => {
95+
ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.getRedirectUrl, event => {
96+
if (!isMainFrameEvent(event)) {
97+
throw new Error("Clerk: OAuth request did not originate from a window's main frame.");
98+
}
9599
return redirectUrl;
96100
});
97101

98-
ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.open, async (_event, url: string) => {
102+
ipcMain.handle(OAUTH_TRANSPORT_CHANNELS.open, async (event, url: string) => {
103+
if (!isMainFrameEvent(event)) {
104+
throw new Error("Clerk: OAuth request did not originate from a window's main frame.");
105+
}
106+
99107
if (pendingOAuthFlow) {
100108
throw new Error('Clerk: an OAuth flow is already pending.');
101109
}

packages/electron/src/main/passkey-handlers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
SerializedPublicKeyCredentialRequestOptions,
1313
SetupPasskeysMainReturn,
1414
} from '../shared/types';
15+
import { isMainFrameEvent } from './validate-sender';
1516

1617
/**
1718
* Optional native module. Ceremony failures resolve as JSON envelopes so error
@@ -77,7 +78,7 @@ async function invokeNative<T>(
7778

7879
// Subframes and webviews can host third-party content that must not be able
7980
// to run credential ceremonies for the app's RP ID.
80-
if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) {
81+
if (!isMainFrameEvent(event)) {
8182
return {
8283
ok: false,
8384
error: { code: 'unknown', message: "The passkey request did not originate from a window's main frame." },
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { IpcMainInvokeEvent } from 'electron';
2+
3+
// Restricts privileged IPC to a top-level BrowserWindow's main frame. The frame check alone
4+
// only proves the sender is the top document of *its own* WebContents, so a <webview> (a separate
5+
// WebContents) sharing the Clerk preload would still pass; requiring getType() === 'window' rejects
6+
// <webview> guests and BrowserViews, and the frame check rejects in-page iframes.
7+
export function isMainFrameEvent(event: IpcMainInvokeEvent): boolean {
8+
const { sender, senderFrame } = event;
9+
return sender.getType() === 'window' && Boolean(senderFrame) && senderFrame === sender.mainFrame;
10+
}

0 commit comments

Comments
 (0)