Skip to content

Commit 95ec1a6

Browse files
phelix001claude
andcommitted
fix: replace CDP debugger with preload-based WebAuthn injection
CDP debugger.attach() on every webContents triggered Google's automation detection, causing "Couldn't sign you in - This browser or app may not be secure" on all Google services. Replace the entire CDP approach with preload-based injection: - recipe.ts: inject page script at document-start via DOM <script> element, which runs in the main world before page scripts - New webauthn-popup-preload.ts: same pattern for popup windows that don't get the recipe preload - setWindowOpenHandler passes popup preload via overrideBrowserWindowOptions on Linux This removes ~190 lines of CDP code (debugger.attach, Page.addScriptToEvaluateOnNewDocument, Runtime.addBinding, the entire CDP bridge handler) and eliminates all automation signals that Google detects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 801305f commit 95ec1a6

3 files changed

Lines changed: 61 additions & 196 deletions

File tree

src/index.ts

Lines changed: 19 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,7 @@ import {
1313
} from 'electron';
1414

1515
import { initialize } from 'electron-react-titlebar/main';
16-
import {
17-
getAuthenticatorManager,
18-
setupWebAuthn,
19-
webauthnPageScript,
20-
} from 'electron-webauthn-linux';
16+
import { setupWebAuthn } from 'electron-webauthn-linux';
2117
import windowStateKeeper from 'electron-window-state';
2218
import { emptyDirSync, ensureFileSync } from 'fs-extra';
2319
import minimist from 'minimist';
@@ -261,194 +257,6 @@ const createWindow = () => {
261257
});
262258

263259
app.on('web-contents-created', (_e, contents) => {
264-
// Inject WebAuthn page script into ALL content types on Linux via CDP.
265-
// This covers both service webviews AND popup windows (e.g. Google passkey settings).
266-
// Page.addScriptToEvaluateOnNewDocument runs BEFORE page scripts,
267-
// in the main world — the only reliable way to monkey-patch
268-
// navigator.credentials before sites like Google check it.
269-
if (isLinux) {
270-
contents.on('console-message', (_event, _level, message) => {
271-
if (
272-
message.includes('webauthn') ||
273-
message.includes('WebAuthn') ||
274-
message.includes('DIAG')
275-
) {
276-
debug('WebView console:', message);
277-
}
278-
});
279-
280-
try {
281-
contents.debugger.attach('1.3');
282-
const contentType = contents.getType();
283-
debug(
284-
`Debugger attached to ${contentType}, injecting WebAuthn page script`,
285-
);
286-
287-
// Enable Page domain so addScriptToEvaluateOnNewDocument persists across navigations
288-
contents.debugger.sendCommand('Page.enable', {}).catch(error => {
289-
debug('CDP Page.enable failed:', error);
290-
});
291-
292-
// Inject page script before any page JS runs
293-
contents.debugger
294-
.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
295-
source: webauthnPageScript,
296-
worldName: '',
297-
runImmediately: true,
298-
})
299-
.then(result => {
300-
debug('WebAuthn page script registered via CDP:', result);
301-
})
302-
.catch(error => {
303-
debug('CDP addScriptToEvaluateOnNewDocument failed:', error);
304-
});
305-
306-
// Set up Runtime.addBinding for popup windows that lack a preload script.
307-
// This creates a __webauthnBridge function in the page context that triggers
308-
// Runtime.bindingCalled events in our debugger listener.
309-
contents.debugger
310-
.sendCommand('Runtime.addBinding', {
311-
name: '__webauthnBridge',
312-
})
313-
.catch(error => {
314-
debug('CDP Runtime.addBinding failed:', error);
315-
});
316-
317-
contents.debugger.sendCommand('Runtime.enable', {}).catch(error => {
318-
debug('CDP Runtime.enable failed:', error);
319-
});
320-
321-
// Handle CDP binding calls from popup windows
322-
contents.debugger.on('message', (_event, method, params) => {
323-
if (
324-
method === 'Runtime.bindingCalled' &&
325-
params.name === '__webauthnBridge'
326-
) {
327-
const manager = getAuthenticatorManager();
328-
if (!manager) {
329-
debug('CDP bridge: manager not available');
330-
return;
331-
}
332-
try {
333-
const {
334-
id,
335-
method: webauthnMethod,
336-
arg,
337-
} = JSON.parse(params.payload);
338-
const ctxId = params.executionContextId;
339-
debug(
340-
`CDP bridge call: method=${webauthnMethod} id=${id} ctxId=${ctxId}`,
341-
);
342-
343-
const sendResponse = (error: string | null, result: any) => {
344-
debug(
345-
`CDP bridge response: id=${id} error=${error} hasResult=${result != null}`,
346-
);
347-
const response = JSON.stringify({ id, error, result });
348-
contents.debugger
349-
.sendCommand('Runtime.evaluate', {
350-
expression: `window.__webauthnCallback(${JSON.stringify(response)})`,
351-
contextId: ctxId,
352-
})
353-
.then(() => {
354-
debug(`CDP bridge callback delivered: id=${id}`);
355-
})
356-
.catch(error_ => {
357-
debug('CDP Runtime.evaluate callback failed:', error_);
358-
});
359-
};
360-
361-
switch (webauthnMethod) {
362-
case 'hasCredentials': {
363-
manager
364-
.getAvailableBackendsForGet(arg)
365-
.then(backends => {
366-
debug(
367-
`CDP bridge hasCredentials(${arg}): ${backends.length} backends`,
368-
);
369-
sendResponse(null, backends.length > 0);
370-
})
371-
.catch(error => {
372-
debug(
373-
`CDP bridge hasCredentials error: ${error.message}`,
374-
);
375-
sendResponse(error.message, null);
376-
});
377-
378-
break;
379-
}
380-
case 'create': {
381-
manager
382-
.getAvailableBackendsForCreate()
383-
.then(async backends => {
384-
debug(
385-
`CDP bridge create: ${backends.length} backends available`,
386-
);
387-
if (backends.length === 0)
388-
throw new Error('No WebAuthn authenticator available');
389-
const result = await manager.createCredential(
390-
arg,
391-
backends[0],
392-
);
393-
debug(
394-
'CDP bridge create: credential created successfully',
395-
);
396-
sendResponse(null, result);
397-
})
398-
.catch(error => {
399-
debug(`CDP bridge create error: ${error.message}`);
400-
sendResponse(error.message, null);
401-
});
402-
403-
break;
404-
}
405-
case 'get': {
406-
const rpId = arg.rpId || '';
407-
debug(
408-
`CDP bridge get: rpId=${rpId} allowCredentials=${arg.allowCredentials?.length || 0}`,
409-
);
410-
manager
411-
.getAvailableBackendsForGet(
412-
rpId,
413-
arg.allowCredentials?.map((c: any) => c.id),
414-
)
415-
.then(async backends => {
416-
debug(
417-
`CDP bridge get: ${backends.length} matching backends`,
418-
);
419-
if (backends.length === 0)
420-
throw new Error(
421-
'NoCredentials: No passkeys found for this site.',
422-
);
423-
const result = await manager.getAssertion(
424-
arg,
425-
backends[0],
426-
);
427-
debug('CDP bridge get: assertion retrieved successfully');
428-
sendResponse(null, result);
429-
})
430-
.catch(error => {
431-
debug(`CDP bridge get error: ${error.message}`);
432-
sendResponse(error.message, null);
433-
});
434-
435-
break;
436-
}
437-
default: {
438-
debug(`CDP bridge: unknown method '${webauthnMethod}'`);
439-
break;
440-
}
441-
}
442-
} catch (error: any) {
443-
debug('CDP bridge call parse error:', error.message);
444-
}
445-
}
446-
});
447-
} catch (error) {
448-
debug('Failed to attach debugger for WebAuthn injection:', error);
449-
}
450-
}
451-
452260
if (contents.getType() === 'webview') {
453261
enableWebContents(contents);
454262

@@ -514,6 +322,24 @@ const createWindow = () => {
514322
const popupDomain = getDomain(popupHost);
515323
const currentDomain = getDomain(currentHost);
516324
if (popupDomain && currentDomain && popupDomain === currentDomain) {
325+
// On Linux, give popup windows a WebAuthn preload so passkey
326+
// flows work in auth popups (they don't get the recipe preload).
327+
if (isLinux) {
328+
return {
329+
action: 'allow',
330+
overrideBrowserWindowOptions: {
331+
webPreferences: {
332+
preload: join(
333+
__dirname,
334+
'webview',
335+
'webauthn-popup-preload.js',
336+
),
337+
contextIsolation: true,
338+
sandbox: true,
339+
},
340+
},
341+
};
342+
}
517343
return { action: 'allow' };
518344
}
519345
} catch {

src/webview/recipe.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,27 @@ contextBridge.exposeInMainWorld('ferdium', {
131131
getDisplayMediaSelector,
132132
});
133133

134-
// Expose WebAuthn IPC bridge for passkey support on Linux.
135-
// Page script injection is done from the main process via CDP
136-
// (Page.addScriptToEvaluateOnNewDocument) which runs before page scripts.
134+
// WebAuthn/passkey support on Linux.
135+
// Expose the IPC bridge and inject the page script into the main world
136+
// BEFORE page scripts run, so navigator.credentials is patched in time.
137137
if (process.platform === 'linux') {
138138
contextBridge.exposeInMainWorld('electronWebAuthn', {
139139
create: (options: any) => ipcRenderer.invoke('webauthn:create', options),
140140
get: (options: any) => ipcRenderer.invoke('webauthn:get', options),
141141
hasCredentials: (rpId: string) =>
142142
ipcRenderer.invoke('webauthn:hasCredentials', rpId),
143143
});
144+
145+
// Inject page script at document-start (before any page JS).
146+
// The <script> element runs in the main world (world 0), not the
147+
// isolated preload world, which is what we need for monkey-patching.
148+
const { webauthnPageScript } = require('electron-webauthn-linux');
149+
process.once('document-start', () => {
150+
const script = document.createElement('script');
151+
script.textContent = webauthnPageScript;
152+
document.documentElement.append(script);
153+
script.remove();
154+
});
144155
}
145156

146157
ipcRenderer.sendToHost(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Minimal preload for popup windows (e.g. Google auth popups) on Linux.
3+
* Injects the WebAuthn page script into the main world via DOM script element
4+
* and exposes the electronWebAuthn IPC bridge via contextBridge.
5+
*
6+
* This runs in popup BrowserWindows opened by setWindowOpenHandler with
7+
* { action: 'allow' } -- these windows don't get the recipe preload.
8+
*/
9+
import { contextBridge, ipcRenderer } from 'electron';
10+
import { webauthnPageScript } from 'electron-webauthn-linux';
11+
12+
// Inject the WebAuthn page script into the main world BEFORE page scripts run.
13+
// process.once('document-start') fires at document creation, before any <script> tags.
14+
// The DOM <script> element executes in world 0 (main world), not the isolated preload world.
15+
process.once('document-start', () => {
16+
const script = document.createElement('script');
17+
script.textContent = webauthnPageScript;
18+
document.documentElement.append(script);
19+
script.remove();
20+
});
21+
22+
// Expose the IPC bridge so the page script can route WebAuthn calls to main process.
23+
contextBridge.exposeInMainWorld('electronWebAuthn', {
24+
create: (options: any) => ipcRenderer.invoke('webauthn:create', options),
25+
get: (options: any) => ipcRenderer.invoke('webauthn:get', options),
26+
hasCredentials: (rpId: string) =>
27+
ipcRenderer.invoke('webauthn:hasCredentials', rpId),
28+
});

0 commit comments

Comments
 (0)