diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts index c528a6de356..fc43315a31a 100644 --- a/src/main/browser/browser-cookie-import.comet.test.ts +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -122,6 +122,71 @@ describe('detectInstalledBrowsers — Comet', () => { expect(names).toEqual(['Personal', 'Research', 'Work']) }) + it('ignores Comet profile directories that escape the browser root', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Comet/Local State')) { + return true + } + if (p.includes('Application Support/Outside/Network/Cookies')) { + return true + } + return false + }, + readFileSync: (p: string, enc?: string) => { + if (typeof p === 'string' && p.includes('Comet/Local State')) { + return JSON.stringify({ + profile: { + info_cache: { + '../Outside': { name: 'Outside' } + } + } + }) + } + return actual.readFileSync(p as never, enc as never) + } + } + }) + + const { detectInstalledBrowsers } = await import('./browser-cookie-import') + const detected = detectInstalledBrowsers() + expect(detected.find((b) => b.family === 'comet')).toBeUndefined() + }) + + it('rejects explicit Comet profile selections that escape the browser root', async () => { + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + existsSync: (p: string) => { + if (p.includes('Application Support/Outside/Network/Cookies')) { + return true + } + return false + } + } + }) + + const { selectBrowserProfile } = await import('./browser-cookie-import') + const selected = selectBrowserProfile( + { + family: 'comet', + label: 'Comet', + cookiesPath: '/Users/test/Library/Application Support/Comet/Default/Network/Cookies', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + profiles: [{ name: 'Outside', directory: '../Outside' }], + selectedProfile: 'Default' + }, + '../Outside' + ) + + expect(selected).toBeNull() + }) + it('skips Comet when the data directory exists but no Cookies DB is present', async () => { vi.doMock('node:fs', async () => { const actual = await vi.importActual('node:fs') diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 61cc113a8dc..54eb9b0407f 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -173,6 +173,17 @@ function resolveCookiesPath(profileDir: string): string | null { return null } +function isSafeBrowserProfileDirectory(directory: string): boolean { + return ( + directory.length > 0 && + directory !== '.' && + !directory.includes('\0') && + !directory.includes('/') && + !directory.includes('\\') && + !directory.includes('..') + ) +} + // Why: Chrome's Local State JSON contains profile.info_cache which maps profile // directory names (e.g. "Default", "Profile 1") to metadata including the // user-visible display name. This lets us show human-readable names in the picker. @@ -190,6 +201,10 @@ function discoverProfiles(browserRoot: string): BrowserProfile[] { } const profiles: BrowserProfile[] = [] for (const [dir, info] of Object.entries(infoCache)) { + // Why: Local State is external metadata, but profile dirs become path segments. + if (!isSafeBrowserProfileDirectory(dir)) { + continue + } const profileName = (info as { name?: string })?.name ?? dir profiles.push({ name: profileName, directory: dir }) } @@ -362,6 +377,9 @@ export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string ): DetectedBrowser | null { + if (!isSafeBrowserProfileDirectory(profileDirectory)) { + return null + } if (browser.family === 'firefox') { const profilesRoot = firefoxProfilesRoot() if (!profilesRoot) {