Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/emdash-desktop/electron-builder.canary.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ const config: Configuration = {
perMachine: false,
},
npmRebuild: false,
// Encrypt Chromium's on-disk cookie store (in-app browser logins) with OS-level
// keys, like Chrome does. One-way: never disable once shipped or existing
// cookie stores become unreadable.
electronFuses: {
enableCookieEncryption: true,
},
};

export default config;
6 changes: 6 additions & 0 deletions apps/emdash-desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ const config: Configuration = {
perMachine: false,
},
npmRebuild: false,
// Encrypt Chromium's on-disk cookie store (in-app browser logins) with OS-level
// keys, like Chrome does. One-way: never disable once shipped or existing
// cookie stores become unreadable.
electronFuses: {
enableCookieEncryption: true,
},
};

export default config;
7 changes: 2 additions & 5 deletions apps/emdash-desktop/src/main/app/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,8 @@ function registerBrowserWebviewHandlers(win: BrowserWindow): void {
});

win.webContents.on('did-attach-webview', (_event, webContents) => {
const browserId = browserWebContentsRegistry.getBrowserIdForWebContents(webContents);
if (!browserId) {
webContents.close();
return;
if (!browserWebContentsRegistry.handleWebviewAttached(webContents)) {
log.warn('Closed webview without a registered browser session');
}
browserWebContentsRegistry.attachWebContents(browserId, webContents, win);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { app } from 'electron';
import { log } from '@main/lib/logger';
import { BROWSER_PARTITION_PREFIX } from '@shared/browser';

const PERSIST_PREFIX = 'persist:';
const LEGACY_DIR_PREFIX = `${BROWSER_PARTITION_PREFIX.slice(PERSIST_PREFIX.length)}-`;
const PROFILE_DIR_PREFIX = `${BROWSER_PARTITION_PREFIX.slice(PERSIST_PREFIX.length)}-profile`;
const ISOLATED_DIR_PREFIX = `${BROWSER_PARTITION_PREFIX.slice(PERSIST_PREFIX.length)}-isolated-`;

/**
* Browsers used to get one persistent partition per browser tab. Keep named
* profile and isolated-task partitions, but remove old unused tab partitions so
* stale cookies and caches do not accumulate in userData/Partitions.
*/
export async function cleanupLegacyBrowserPartitions(): Promise<void> {
const partitionsDir = join(app.getPath('userData'), 'Partitions');
let entries;
try {
entries = await readdir(partitionsDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.startsWith(LEGACY_DIR_PREFIX)) continue;
if (entry.name.startsWith(PROFILE_DIR_PREFIX) || entry.name.startsWith(ISOLATED_DIR_PREFIX)) {
continue;
}
try {
await rm(join(partitionsDir, entry.name), { recursive: true, force: true });
} catch (error) {
log.warn('Failed to remove legacy browser partition', { partition: entry.name, error });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { app, session, type Session } from 'electron';
import { log } from '@main/lib/logger';
import {
firefoxUserAgent,
isGoogleAuthUrl,
stripEmbeddedBrowserTokens,
} from './browser-user-agent';

// Web permissions the embedded browser may use without asking. Everything else
// (camera, microphone, geolocation, notifications, USB/HID/serial, …) is denied:
// the in-app browser holds logged-in sessions and must not become a side channel
// into device capabilities (Electron security checklist #5).
const ALLOWED_BROWSER_PERMISSIONS: ReadonlySet<string> = new Set([
'clipboard-sanitized-write',
'fullscreen',
]);

const configuredPartitions = new Set<string>();

/**
* Returns the session for a browser partition, applying profile-wide hardening
* exactly once per partition: deny-by-default permissions, an embedded-browser
* user agent without Electron tokens, and a Firefox user agent on Google auth
* hosts so third-party "Sign in with Google" flows are not rejected.
*/
export function configureBrowserProfileSession(partition: string): Session {
const ses = session.fromPartition(partition);
if (configuredPartitions.has(partition)) return ses;
configuredPartitions.add(partition);

ses.setUserAgent(stripEmbeddedBrowserTokens(ses.getUserAgent(), app.getName()));

ses.webRequest.onBeforeSendHeaders((details, callback) => {
if (isGoogleAuthUrl(details.url)) {
details.requestHeaders['User-Agent'] = firefoxUserAgent();
}
callback({ requestHeaders: details.requestHeaders });
});

ses.setPermissionRequestHandler((_webContents, permission, callback) => {
const granted = ALLOWED_BROWSER_PERMISSIONS.has(permission);
if (!granted) {
log.debug('Denied browser permission request', { permission });
}
callback(granted);
});
ses.setPermissionCheckHandler((_webContents, permission) =>
ALLOWED_BROWSER_PERMISSIONS.has(permission)
);

return ses;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
firefoxUserAgent,
isGoogleAuthUrl,
stripEmbeddedBrowserTokens,
userAgentForBrowserUrl,
} from './browser-user-agent';

const DEFAULT_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' +
'emdash/1.1.32 Chrome/138.0.7204.97 Electron/40.7.0 Safari/537.36';

describe('stripEmbeddedBrowserTokens', () => {
it('removes the Electron and app-name tokens', () => {
expect(stripEmbeddedBrowserTokens(DEFAULT_UA, 'emdash')).toBe(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/138.0.7204.97 Safari/537.36'
);
});

it('leaves already-clean user agents untouched', () => {
const clean =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/138.0.7204.97 Safari/537.36';
expect(stripEmbeddedBrowserTokens(clean, 'emdash')).toBe(clean);
});

it('escapes regex metacharacters in the app name', () => {
const ua = 'Mozilla/5.0 my.app+name/1.0 Chrome/138.0 Safari/537.36';
expect(stripEmbeddedBrowserTokens(ua, 'my.app+name')).toBe(
'Mozilla/5.0 Chrome/138.0 Safari/537.36'
);
});
});

describe('Google auth user agent override', () => {
it('detects Google auth hosts only', () => {
expect(isGoogleAuthUrl('https://accounts.google.com/o/oauth2/v2/auth')).toBe(true);
expect(isGoogleAuthUrl('https://accounts.youtube.com/accounts/SetSID')).toBe(true);
expect(isGoogleAuthUrl('https://www.google.com/search?q=test')).toBe(false);
expect(isGoogleAuthUrl('https://accounts.google.com.evil.com/login')).toBe(false);
expect(isGoogleAuthUrl('not a url')).toBe(false);
});

it('returns a Firefox user agent for Google auth URLs and the base elsewhere', () => {
expect(userAgentForBrowserUrl('https://accounts.google.com/signin', DEFAULT_UA)).toContain(
'Firefox/'
);
expect(userAgentForBrowserUrl('https://github.com/login', DEFAULT_UA)).toBe(DEFAULT_UA);
});

it('builds platform-specific Firefox user agents without Electron tokens', () => {
for (const platform of ['darwin', 'win32', 'linux'] as const) {
const ua = firefoxUserAgent(platform);
expect(ua).toMatch(/^Mozilla\/5\.0 \(.+; rv:[\d.]+\) Gecko\/20100101 Firefox\/[\d.]+$/);
expect(ua).not.toContain('Electron');
}
});
});
39 changes: 39 additions & 0 deletions apps/emdash-desktop/src/main/core/browser/browser-user-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const FIREFOX_VERSION = '140.0';

// Sites (notably Google sign-in) block embedded Chromium views by detecting the
// `Electron/x.y` and app-name tokens Chromium appends to the default user agent.
export function stripEmbeddedBrowserTokens(userAgent: string, appName: string): string {
const escapedAppName = appName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return userAgent
.replace(new RegExp(`\\s${escapedAppName}/\\S+`, 'i'), '')
.replace(/\sElectron\/\S+/i, '')
.trim();
}

// Google rejects OAuth/sign-in from embedded Chromium-based views even with the
// Electron token stripped ("This browser or app may not be secure"). Presenting
// as Firefox on Google's auth hosts is the established workaround used by
// Electron-based browsers (Wexond, Ferdium).
export function firefoxUserAgent(platform: NodeJS.Platform = process.platform): string {
const os =
platform === 'darwin'
? 'Macintosh; Intel Mac OS X 10.15'
: platform === 'win32'
? 'Windows NT 10.0; Win64; x64'
: 'X11; Linux x86_64';
return `Mozilla/5.0 (${os}; rv:${FIREFOX_VERSION}) Gecko/20100101 Firefox/${FIREFOX_VERSION}`;
}

const GOOGLE_AUTH_HOSTS = new Set(['accounts.google.com', 'accounts.youtube.com']);

export function isGoogleAuthUrl(url: string): boolean {
try {
return GOOGLE_AUTH_HOSTS.has(new URL(url).hostname.toLowerCase());
} catch {
return false;
}
}

export function userAgentForBrowserUrl(url: string, baseUserAgent: string): string {
return isGoogleAuthUrl(url) ? firefoxUserAgent() : baseUserAgent;
}
Loading
Loading