diff --git a/package.json b/package.json index ddda52d1b2..a7a8fade60 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "electron-find": "1.0.7", "electron-react-titlebar": "1.2.1", "electron-updater": "6.3.9", + "electron-webauthn-linux": "0.1.1", "electron-window-state": "5.0.3", "fast-folder-size": "2.2.0", "fs-extra": "11.2.0", @@ -127,6 +128,7 @@ "sqlite3": "5.1.6", "tar": "6.2.1", "tinycolor2": "1.6.0", + "tldts": "7.0.23", "tslib": "2.7.0", "useragent-generator": "1.1.1-amkt-22079-finish.0", "uuid": "9.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b04733ef6..db7aa8c39b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: electron-updater: specifier: 6.3.9 version: 6.3.9 + electron-webauthn-linux: + specifier: 0.1.1 + version: 0.1.1(electron@37.6.0) electron-window-state: specifier: 5.0.3 version: 5.0.3 @@ -242,6 +245,9 @@ importers: tinycolor2: specifier: 1.6.0 version: 1.6.0 + tldts: + specifier: 7.0.23 + version: 7.0.23 tslib: specifier: 2.7.0 version: 2.7.0 @@ -3264,6 +3270,12 @@ packages: electron-updater@6.3.9: resolution: {integrity: sha512-2PJNONi+iBidkoC5D1nzT9XqsE8Q1X28Fn6xRQhO3YX8qRRyJ3mkV4F1aQsuRnYPqq6Hw+E51y27W75WgDoofw==} + electron-webauthn-linux@0.1.1: + resolution: {integrity: sha512-cws9rFV1p/w+iaQXQ67IVYkAri988n6kJyVXkZ3/G3PXGVIvhlRop2lyBR2/EEVcS9hQZtpyPaQVAvxMigatrg==} + engines: {node: '>=18.0.0'} + peerDependencies: + electron: '>=25.0.0' + electron-window-state@5.0.3: resolution: {integrity: sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg==} engines: {node: '>=8.0.0'} @@ -7107,6 +7119,13 @@ packages: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -11240,6 +11259,10 @@ snapshots: transitivePeerDependencies: - supports-color + electron-webauthn-linux@0.1.1(electron@37.6.0): + dependencies: + electron: 37.6.0 + electron-window-state@5.0.3: dependencies: jsonfile: 4.0.0 @@ -16008,6 +16031,12 @@ snapshots: titleize@3.0.0: {} + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + tmp-promise@3.0.3: dependencies: tmp: 0.2.1 diff --git a/src/components/util/ErrorBoundary/index.tsx b/src/components/util/ErrorBoundary/index.tsx index a37d1b33a8..c750a38309 100644 --- a/src/components/util/ErrorBoundary/index.tsx +++ b/src/components/util/ErrorBoundary/index.tsx @@ -36,7 +36,9 @@ class ErrorBoundary extends Component { }; } - componentDidCatch(): void { + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error('ErrorBoundary caught:', error); + console.error('Component stack:', errorInfo.componentStack); this.setState({ hasError: true }); } diff --git a/src/index.ts b/src/index.ts index 5d26a24b75..24fc09ee9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,10 +13,12 @@ import { } from 'electron'; import { initialize } from 'electron-react-titlebar/main'; +import { setupWebAuthn } from 'electron-webauthn-linux'; import windowStateKeeper from 'electron-window-state'; import { emptyDirSync, ensureFileSync } from 'fs-extra'; import minimist from 'minimist'; import ms from 'ms'; +import { getDomain } from 'tldts'; import { enableWebContents, initializeRemote } from './electron-util'; import enforceMacOSAppLocation from './enforce-macos-app-location'; @@ -257,7 +259,92 @@ const createWindow = () => { app.on('web-contents-created', (_e, contents) => { if (contents.getType() === 'webview') { enableWebContents(contents); + + // Set permission handlers on service webview sessions. + // setPermissionRequestHandler allows safe permissions and denies unknown ones. + // setPermissionCheckHandler additionally allows hid/serial/usb feature detection + // (actual device access is gated by select-hid-device/select-usb-device events). + const ses = contents.session; + if (!(ses as any)._permissionHandlersSet) { + (ses as any)._permissionHandlersSet = true; + + ses.setPermissionRequestHandler((webContents, permission, callback) => { + const allowedPermissions = [ + 'media', + 'notifications', + 'fullscreen', + 'pointerLock', + 'display-capture', + 'idle-detection', + 'clipboard-read', + 'clipboard-sanitized-write', + 'speaker-selection', + ]; + + if (allowedPermissions.includes(permission)) { + callback(true); + return; + } + + debug( + `Denied permission request: ${permission} from ${webContents?.getURL()}`, + ); + callback(false); + }); + + ses.setPermissionCheckHandler((_webContents, permission) => { + const allowedChecks = [ + 'media', + 'notifications', + 'fullscreen', + 'pointerLock', + 'display-capture', + 'idle-detection', + 'clipboard-read', + 'clipboard-sanitized-write', + 'hid', + 'serial', + 'usb', + 'speaker-selection', + ]; + + return allowedChecks.includes(permission); + }); + } + contents.setWindowOpenHandler(({ url }) => { + // Allow same-domain popups (e.g. Google auth) to open + // as child windows within Ferdium instead of the external browser. + // Uses tldts for correct eTLD+1 matching (handles .co.uk, .com.au, etc.) + try { + const popupHost = new URL(url).hostname; + const currentHost = new URL(contents.getURL()).hostname; + const popupDomain = getDomain(popupHost); + const currentDomain = getDomain(currentHost); + if (popupDomain && currentDomain && popupDomain === currentDomain) { + // On Linux, give popup windows a WebAuthn preload so passkey + // flows work in auth popups (they don't get the recipe preload). + if (isLinux) { + return { + action: 'allow', + overrideBrowserWindowOptions: { + webPreferences: { + preload: join( + __dirname, + 'webview', + 'webauthn-popup-preload.js', + ), + contextIsolation: true, + sandbox: true, + }, + }, + }; + } + return { action: 'allow' }; + } + } catch { + // fall through + } openExternalUrl(url); return { action: 'deny' }; }); @@ -531,6 +618,16 @@ app.on('ready', () => { initialize(); + // Initialize WebAuthn/passkey support on Linux + if (isLinux) { + setupWebAuthn({ + storagePath: userDataPath(), + enableHardwareKeys: true, + }).catch(error => { + debug('WebAuthn setup failed:', error.message); + }); + } + createWindow(); }); diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts index 9f6ed13199..7c226ff61f 100644 --- a/src/stores/AppStore.ts +++ b/src/stores/AppStore.ts @@ -410,9 +410,8 @@ export default class AppStore extends TypedStore { } _readSandboxes() { - this.sandboxServices = readJsonSync( - userDataPath('config', 'sandboxes.json'), - ); + const data = readJsonSync(userDataPath('config', 'sandboxes.json')); + this.sandboxServices = Array.isArray(data) ? data : []; } _writeSandboxes() { diff --git a/src/webview/recipe.ts b/src/webview/recipe.ts index ad2215ffdf..6e8a8ee129 100644 --- a/src/webview/recipe.ts +++ b/src/webview/recipe.ts @@ -131,6 +131,29 @@ contextBridge.exposeInMainWorld('ferdium', { getDisplayMediaSelector, }); +// WebAuthn/passkey support on Linux. +// Expose the IPC bridge and inject the page script into the main world +// BEFORE page scripts run, so navigator.credentials is patched in time. +if (process.platform === 'linux') { + contextBridge.exposeInMainWorld('electronWebAuthn', { + create: (options: any) => ipcRenderer.invoke('webauthn:create', options), + get: (options: any) => ipcRenderer.invoke('webauthn:get', options), + hasCredentials: (rpId: string) => + ipcRenderer.invoke('webauthn:hasCredentials', rpId), + }); + + // Inject page script at document-start (before any page JS). + // The