From 3e3e2e0563d62c90777bcb43a0a1034a53ddd0bb Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 19 Aug 2025 22:10:22 +0530 Subject: [PATCH] feat: persist cookies in app (#5318) --- .../001-cookie-persistence.spec.ts | 43 ++++ .../002-corrupted-passkey.spec.ts | 47 +++++ packages/bruno-electron/src/index.js | 20 +- packages/bruno-electron/src/ipc/collection.js | 24 ++- .../bruno-electron/src/ipc/network/index.js | 3 + packages/bruno-electron/src/store/cookies.js | 192 ++++++++++++++++++ .../bruno-electron/src/utils/encryption.js | 36 ++-- .../tests/cookies-store.test.js | 71 +++++++ playwright/index.ts | 27 ++- 9 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts create mode 100644 e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts create mode 100644 packages/bruno-electron/src/store/cookies.js create mode 100644 packages/bruno-electron/tests/cookies-store.test.js diff --git a/e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts b/e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts new file mode 100644 index 000000000..beefb3b39 --- /dev/null +++ b/e2e-tests/002-cookies-tests/001-cookie-persistence.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '../../playwright'; + +test('should persist cookies across app restarts', async ({ createTmpDir, launchElectronApp }) => { + // Create a temporary user-data directory so we control where the cookies store file is written. + const userDataPath = await createTmpDir('cookie-persistence'); + + const app1 = await launchElectronApp({ userDataPath }); + const page1 = await app1.firstWindow(); + await page1.waitForSelector('[data-trigger="cookies"]'); + + // Open Cookies modal via the status-bar button. + await page1.click('[data-trigger="cookies"]'); + + // When no cookies are present the modal shows a centred "Add Cookie" button. + await page1.getByRole('button', { name: /Add Cookie/i }).click(); + + // Fill out the form. + await page1.fill('input[name="domain"]', 'example.com'); + await page1.fill('input[name="path"]', '/'); + await page1.fill('input[name="key"]', 'session'); + await page1.fill('input[name="value"]', 'abc123'); + await page1.check('input[name="secure"]'); + await page1.check('input[name="httpOnly"]'); + + await page1.getByRole('button', { name: 'Save' }).click(); + + await expect(page1.getByText('example.com')).toBeVisible(); + + await app1.close(); + + // Second launch – verify the cookie was persisted and re-loaded + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + + // Open the Cookies modal again. + await page2.waitForSelector('[data-trigger="cookies"]'); + await page2.click('[data-trigger="cookies"]'); + + // The domain we added earlier should still be present. + await expect(page2.getByText('example.com')).toBeVisible(); + + await app2.close(); +}); diff --git a/e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts b/e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts new file mode 100644 index 000000000..02c2a5010 --- /dev/null +++ b/e2e-tests/002-cookies-tests/002-corrupted-passkey.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '../../playwright'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +test('should handle corrupted passkey and still display saved cookie list', async ({ createTmpDir, launchElectronApp }) => { + const userDataPath = await createTmpDir('corrupted-passkey'); + + const app1 = await launchElectronApp({ userDataPath }); + // 1. First run – add a cookie via the UI so `cookies.json` is created. + const page1 = await app1.firstWindow(); + + await page1.waitForSelector('[data-trigger="cookies"]'); + await page1.click('[data-trigger="cookies"]'); + await page1.getByRole('button', { name: /Add Cookie/i }).click(); + + await page1.fill('input[name="domain"]', 'example.com'); + await page1.fill('input[name="path"]', '/'); + await page1.fill('input[name="key"]', 'session'); + await page1.fill('input[name="value"]', 'abc123'); + await page1.check('input[name="secure"]'); + await page1.check('input[name="httpOnly"]'); + + await page1.getByRole('button', { name: 'Save' }).click(); + + await expect(page1.getByText('example.com')).toBeVisible(); + + await app1.close(); + + // 2. Corrupt the encryptedPasskey in cookies.json + const cookiesFilePath = path.join(userDataPath, 'cookies.json'); + const raw = await fs.readFile(cookiesFilePath, 'utf-8'); + const cookiesJson = JSON.parse(raw); + cookiesJson.encryptedPasskey = 'deadbeef'; // clearly invalid value + await fs.writeFile(cookiesFilePath, JSON.stringify(cookiesJson, null, 2)); + + // 3. Second run – Bruno should recover and still list the cookie domain + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + + await page2.waitForSelector('[data-trigger="cookies"]'); + await page2.click('[data-trigger="cookies"]'); + + // The domain row should still be visible (even if cookie values are blank). + await expect(page2.getByText('example.com')).toBeVisible(); + + await app2.close(); +}); diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 3998e634d..763d4d788 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -32,6 +32,8 @@ const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window') const registerNotificationsIpc = require('./ipc/notifications'); const registerGlobalEnvironmentsIpc = require('./ipc/global-environments'); const { safeParseJSON, safeStringifyJSON } = require('./utils/common'); +const { getDomainsWithCookies } = require('./utils/cookies'); +const { cookiesStore } = require('./store/cookies'); const lastOpenedCollections = new LastOpenedCollections(); @@ -179,7 +181,7 @@ app.on('ready', async () => { mainWindow.minimize(); }); - mainWindow.webContents.on('did-finish-load', () => { + mainWindow.webContents.on('did-finish-load', async () => { let ogSend = mainWindow.webContents.send; mainWindow.webContents.send = function(channel, ...args) { return ogSend.apply(this, [channel, ...args?.map(_ => { @@ -187,6 +189,14 @@ app.on('ready', async () => { return safeParseJSON(safeStringifyJSON(_)); })]); } + // Send cookies list after renderer is ready + try { + cookiesStore.initializeCookies(); + const cookiesList = await getDomainsWithCookies(); + mainWindow.webContents.send('main:cookies-update', cookiesList); + } catch (err) { + console.error('Failed to load cookies for renderer', err); + } }); // register all ipc handlers @@ -198,6 +208,14 @@ app.on('ready', async () => { }); // Quit the app once all windows are closed +app.on('before-quit', () => { + try { + cookiesStore.saveCookieJar(true); + } catch (err) { + console.warn('Failed to flush cookies on quit', err); + } +}); + app.on('window-all-closed', app.quit); // Open collection from Recent menu (#1521) diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 0d51b2f92..ade058d76 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -19,6 +19,7 @@ const { } = require('@usebruno/filestore'); const brunoConverters = require('@usebruno/converters'); const { postmanToBruno } = brunoConverters; +const { cookiesStore } = require('../store/cookies'); const { parseLargeRequestWithRedaction } = require('../utils/parse'); const { @@ -890,12 +891,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); + const updateCookiesAndNotify = async () => { + const domainsWithCookies = await getDomainsWithCookies(); + mainWindow.webContents.send( + 'main:cookies-update', + safeParseJSON(safeStringifyJSON(domainsWithCookies)) + ); + cookiesStore.saveCookieJar(); + }; + + // Delete all cookies for a domain ipcMain.handle('renderer:delete-cookies-for-domain', async (event, domain) => { try { await deleteCookiesForDomain(domain); - - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } @@ -904,8 +913,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:delete-cookie', async (event, domain, path, cookieKey) => { try { await deleteCookie(domain, path, cookieKey); - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } @@ -915,8 +923,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:add-cookie', async (event, domain, cookie) => { try { await addCookieForDomain(domain, cookie); - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } @@ -926,8 +933,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection ipcMain.handle('renderer:modify-cookie', async (event, domain, oldCookie, cookie) => { try { await modifyCookieForDomain(domain, oldCookie, cookie); - const domainsWithCookies = await getDomainsWithCookies(); - mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + await updateCookiesAndNotify(); } catch (error) { return Promise.reject(error); } diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 2369f698e..45e14b78c 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -32,6 +32,7 @@ const { getProcessEnvVars } = require('../../store/process-env'); const { getBrunoConfig } = require('../../store/bruno-config'); const Oauth2Store = require('../../store/oauth2'); const { isRequestTagsIncluded } = require('@usebruno/common'); +const { cookiesStore } = require('../../store/cookies'); const saveCookies = (url, headers) => { if (preferencesUtil.shouldStoreCookies()) { @@ -771,6 +772,7 @@ const registerNetworkIpc = (mainWindow) => { const domainsWithCookies = await getDomainsWithCookies(); mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies))); + cookiesStore.saveCookieJar(); let postResponseScriptResult = null; let postResponseError = null; @@ -900,6 +902,7 @@ const registerNetworkIpc = (mainWindow) => { const domainsWithCookiesTest = await getDomainsWithCookies(); mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest))); + cookiesStore.saveCookieJar(); } return { diff --git a/packages/bruno-electron/src/store/cookies.js b/packages/bruno-electron/src/store/cookies.js new file mode 100644 index 000000000..c99adda2a --- /dev/null +++ b/packages/bruno-electron/src/store/cookies.js @@ -0,0 +1,192 @@ +const Store = require('electron-store'); +const { cookies: cookiesModule } = require('@usebruno/common'); +const { cookieJar } = cookiesModule; +const { Cookie } = require('tough-cookie'); +const { createCookieString } = cookiesModule; +const crypto = require('crypto'); +const { encryptString, decryptString } = require('../utils/encryption'); + +const DEBOUNCE_MS = 5000; // Debounce duration (ms) for persisting cookie jar +class CookiesStore { + #saveTimerId = null; + #debounceStart = null; // Track first debounce time + #passkey = null; + + constructor() { + this.store = new Store({ + name: 'cookies', + clearInvalidConfig: true, + defaults: { + encryptedPasskey: null, + cookies: {} + } + }); + this.initializeEncryption(); + } + + #generatePasskey() { + // Generate 32 bytes (256 bits) of random data and convert to hex + return crypto.randomBytes(32).toString('hex'); + } + + initializeEncryption() { + try { + let encryptedPasskey = this.store.get('encryptedPasskey'); + if (!encryptedPasskey) { + // Generate cryptographically secure random passkey + const passkey = this.#generatePasskey(); + encryptedPasskey = encryptString(passkey); + if (!encryptedPasskey) { + console.warn('Failed to encrypt new passkey, falling back to unencrypted cookies'); + this.#passkey = null; + return; + } + this.store.set('encryptedPasskey', encryptedPasskey); + } + this.#passkey = decryptString(encryptedPasskey); + if (!this.#passkey) { + console.warn('Failed to decrypt passkey, falling back to unencrypted cookies'); + } + } catch (err) { + console.warn('Failed to initialize encryption, falling back to unencrypted cookies:', err); + this.#passkey = null; + } + } + + getCookies() { + const cookieStore = this.store.get('cookies', {}); + const decryptedCookies = []; + + // Filter and decrypt cookies + Object.values(cookieStore).forEach(domainCookies => { + if (!Array.isArray(domainCookies)) return; + + domainCookies.forEach(cookie => { + try { + + // Create cookie with decrypted value + const decryptedCookie = { + ...cookie, + value: decryptString(cookie.value, this.#passkey) + }; + decryptedCookies.push(decryptedCookie); + } catch (err) { + console.warn('Failed to process cookie:', cookie?.key, err); + // Still add the cookie but with empty value if processing fails + decryptedCookies.push({ + ...cookie, + value: '' + }); + } + }); + }); + + return decryptedCookies; + } + + setCookies(cookies) { + try { + // Organize cookies by domain + const cookiesByDomain = {}; + cookies.cookies.forEach(cookie => { + try { + if (!cookiesByDomain[cookie.domain]) { + cookiesByDomain[cookie.domain] = []; + } + + cookiesByDomain[cookie.domain].push({ + ...cookie, + value: encryptString(cookie.value, this.#passkey) + }); + } catch (err) { + console.warn('Failed to process cookie for storage:', cookie?.key, err); + // Still store the cookie but with original value if encryption fails + if (!cookiesByDomain[cookie.domain]) { + cookiesByDomain[cookie.domain] = []; + } + cookiesByDomain[cookie.domain].push(cookie); + } + }); + + return this.store.set('cookies', cookiesByDomain); + } catch (err) { + console.warn('Failed to set cookies:', err); + } + } + + // Initialize cookies from store into cookie jar + initializeCookies() { + try { + const storedCookies = this.getCookies(); + + if (Array.isArray(storedCookies) && storedCookies.length) { + storedCookies.forEach((cookie) => this.loadCookieIntoJar(cookie)); + } + } catch (err) { + console.warn('Failed to initialize cookies:', err); + } + } + + // Load a single cookie into the cookie jar + loadCookieIntoJar(rawCookie) { + try { + const cookie = Cookie.fromJSON(rawCookie); + if (!cookie) return; + + // Re-assemble request URL for tough-cookie + const protocol = cookie.secure ? 'https' : 'http'; + const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain; + const url = `${protocol}://${domain}${cookie.path || '/'}`; + const setCookieHeader = createCookieString(cookie); + + cookieJar.setCookieSync(setCookieHeader, url, { ignoreError: true }); + } catch (err) { + console.warn('Failed to load cookie:', rawCookie?.key, err?.message); + } + } + + // Save current cookie jar state to store with debouncing + writeCookieJar() { + try { + const serialized = cookieJar.serializeSync(); + this.setCookies(serialized); + + } catch (err) { + console.warn('Failed to save cookie jar:', err); + } finally { + this.#debounceStart = null; + } + } + + saveCookieJar(immediate = false) { + // Debounced write to avoid excessive disk I/O during rapid request bursts + if (immediate) { + if (this.#saveTimerId) { + clearTimeout(this.#saveTimerId); + this.#saveTimerId = null; + } + return this.writeCookieJar(); + } + + if (!this.#debounceStart) { + this.#debounceStart = Date.now(); + } + + if (this.#saveTimerId) { + clearTimeout(this.#saveTimerId); + } + this.#saveTimerId = setTimeout(() => { + this.writeCookieJar(); + this.#saveTimerId = null; + }, DEBOUNCE_MS); + } + +} + +// Create singleton instance +const cookiesStore = new CookiesStore(); + +module.exports = { + cookiesStore, + CookiesStore +}; \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/encryption.js b/packages/bruno-electron/src/utils/encryption.js index 103f92155..54c1a7cc9 100644 --- a/packages/bruno-electron/src/utils/encryption.js +++ b/packages/bruno-electron/src/utils/encryption.js @@ -29,8 +29,8 @@ function deriveKeyAndIv(password, keyLength, ivLength) { return { key, iv }; } -function aes256Encrypt(data) { - const rawKey = machineIdSync(); +function aes256Encrypt(data, passkey = null) { + const rawKey = passkey || machineIdSync(); const iv = Buffer.alloc(16, 0); // Default IV for new encryption const key = crypto.createHash('sha256').update(rawKey).digest(); // Derive a 32-byte key const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); @@ -40,8 +40,8 @@ function aes256Encrypt(data) { return encrypted; } -function aes256Decrypt(data) { - const rawKey = machineIdSync(); +function aes256Decrypt(data, passkey = null) { + const rawKey = passkey || machineIdSync(); // Attempt to decrypt using new method first const iv = Buffer.alloc(16, 0); // Default IV for new encryption @@ -95,7 +95,7 @@ function safeStorageDecrypt(str) { } } -function encryptString(str) { +function encryptString(str, passkey = null) { if (typeof str !== 'string') { throw new Error('Encrypt failed: invalid string'); } @@ -103,20 +103,31 @@ function encryptString(str) { return ''; } - let encryptedString = ''; + // If a passkey is provided (from cookies store), we must use it for encryption. + if (passkey !== null && passkey !== undefined) { + if (typeof passkey !== 'string' || passkey.length === 0) { + // Corrupted / empty passkey -> do not encrypt, return empty value + return ''; + } + try { + const encryptedString = aes256Encrypt(str, passkey); + return `$${AES256_ALGO}:${encryptedString}`; + } catch (err) { + // Any error indicates the passkey is unusable; return empty string + return ''; + } + } if (safeStorage && safeStorage.isEncryptionAvailable()) { - encryptedString = safeStorageEncrypt(str); + const encryptedString = safeStorageEncrypt(str); return `$${ELECTRONSAFESTORAGE_ALGO}:${encryptedString}`; } - // fallback to aes256 - encryptedString = aes256Encrypt(str); - + const encryptedString = aes256Encrypt(str); return `$${AES256_ALGO}:${encryptedString}`; } -function decryptString(str) { +function decryptString(str, passkey = null) { if (typeof str !== 'string') { throw new Error('Decrypt failed: unrecognized string format'); } @@ -148,8 +159,9 @@ function decryptString(str) { } if (algo === AES256_ALGO) { - return aes256Decrypt(encryptedString); + return aes256Decrypt(encryptedString, passkey || null); } + throw new Error('Decrypt failed: Invalid algo'); } function decryptStringSafe(str) { diff --git a/packages/bruno-electron/tests/cookies-store.test.js b/packages/bruno-electron/tests/cookies-store.test.js new file mode 100644 index 000000000..12b859e07 --- /dev/null +++ b/packages/bruno-electron/tests/cookies-store.test.js @@ -0,0 +1,71 @@ +const path = require('path'); + +const mockEncrypt = (str) => (str.length ? `$enc:${str}` : ''); +const mockDecrypt = (str) => str.replace(/^\$enc:/, ''); + +jest.mock('../src/utils/encryption', () => ({ + encryptString: jest.fn(mockEncrypt), + decryptString: jest.fn(mockDecrypt) +})); + +jest.mock('electron-store', () => { + return jest.fn().mockImplementation((opts = {}) => { + const data = { ...(opts.defaults || {}) }; + return { + get: (key, fallback) => (key in data ? data[key] : fallback), + set: (key, value) => { + data[key] = value; + } + }; + }); +}); + +const { CookiesStore } = require(path.join('..', 'src', 'store', 'cookies')); + +function createFreshStore() { + return new CookiesStore(); +} + +describe('CookiesStore', () => { + test('setCookies encrypts values and getCookies returns decrypted values', () => { + const store = createFreshStore(); + + const cookie = { + domain: 'example.com', + key: 'auth', + value: 'token', + path: '/', + secure: true, + httpOnly: true + }; + + store.setCookies({ cookies: [cookie] }); + + // Raw persisted value should be encrypted + const raw = store.store.get('cookies'); + expect(raw['example.com'][0].value).toBe(`$enc:${cookie.value}`); + + // API should return decrypted value + const retrieved = store.getCookies(); + expect(retrieved[0].value).toBe(cookie.value); + }); + + test('getCookies leaves plain-text cookie values untouched', () => { + const store = createFreshStore(); + + const plainCookie = { + domain: 'example.com', + key: 'sid', + value: 'plaintext', + path: '/', + secure: false, + httpOnly: false + }; + + // Manually inject to the underlying store to simulate legacy/plain data + store.store.set('cookies', { 'example.com': [plainCookie] }); + + const cookies = store.getCookies(); + expect(cookies[0].value).toBe('plaintext'); + }); +}); diff --git a/playwright/index.ts b/playwright/index.ts index 549086326..555ea9551 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -14,9 +14,9 @@ export const test = baseTest.extend< }, { createTmpDir: (tag?: string) => Promise; - launchElectronApp: (options?: { initUserDataPath?: string }) => Promise; + launchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise; electronApp: ElectronApplication; - reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string }) => Promise; + reuseOrLaunchElectronApp: (options?: { initUserDataPath?: string; userDataPath?: string }) => Promise; } >({ createTmpDir: [ @@ -37,8 +37,13 @@ export const test = baseTest.extend< launchElectronApp: [ async ({ playwright, createTmpDir }, use, workerInfo) => { const apps: ElectronApplication[] = []; - await use(async ({ initUserDataPath } = {}) => { - const userDataPath = await createTmpDir('electron-userdata'); + await use(async ({ initUserDataPath, userDataPath: providedUserDataPath } = {}) => { + const userDataPath = providedUserDataPath || (await createTmpDir('electron-userdata')); + + // Ensure dir exists when caller supplies their own path + if (providedUserDataPath) { + await fs.promises.mkdir(userDataPath, { recursive: true }); + } if (initUserDataPath) { const replacements = { @@ -67,10 +72,10 @@ export const test = baseTest.extend< }); const { workerIndex } = workerInfo; - app.process().stdout.on('data', (data) => { + app.process()?.stdout?.on('data', (data) => { process.stdout.write(data.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); }); - app.process().stderr.on('data', (error) => { + app.process()?.stderr?.on('data', (error) => { process.stderr.write(error.toString().replace(/^(?=.)/gm, `[Electron #${workerIndex}] |`)); }); @@ -137,13 +142,15 @@ export const test = baseTest.extend< reuseOrLaunchElectronApp: [ async ({ launchElectronApp }, use, testInfo) => { const apps: Record = {}; - await use(async ({ initUserDataPath } = {}) => { - const key = initUserDataPath; + await use(async ({ initUserDataPath, userDataPath } = {}) => { + const key = userDataPath || initUserDataPath; if (key && apps[key]) { return apps[key]; } - const app = await launchElectronApp({ initUserDataPath }); - apps[key] = app; + const app = await launchElectronApp({ initUserDataPath, userDataPath }); + if (key) { + apps[key] = app; + } return app; }); },