mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
fix: support Linux Chromium cookie import
This commit is contained in:
+1
-1
@@ -247,7 +247,7 @@ Tests spin up a local HTTP server (`browse/test/test-server.ts`) serving HTML fi
|
||||
| `browse/src/read-commands.ts` | Non-mutating commands: `text`, `html`, `links`, `js`, `css`, `is`, `dialog`, `forms`, etc. Exports `getCleanText()`. |
|
||||
| `browse/src/write-commands.ts` | Mutating commands: `goto`, `click`, `fill`, `upload`, `dialog-accept`, `useragent` (with context recreation), etc. |
|
||||
| `browse/src/meta-commands.ts` | Server management, chain routing, diff (DRY via `getCleanText`), snapshot delegation. |
|
||||
| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies via macOS Keychain + PBKDF2/AES-128-CBC. Auto-detects installed browsers. |
|
||||
| `browse/src/cookie-import-browser.ts` | Decrypt Chromium cookies from macOS and Linux browser profiles using platform-specific safe-storage key lookup. Auto-detects installed browsers. |
|
||||
| `browse/src/cookie-picker-routes.ts` | HTTP routes for `/cookie-picker/*` — browser list, domain search, import, remove. |
|
||||
| `browse/src/cookie-picker-ui.ts` | Self-contained HTML generator for the interactive cookie picker (dark theme, no frameworks). |
|
||||
| `browse/src/buffers.ts` | `CircularBuffer<T>` (O(1) ring buffer) + console/network/dialog capture with async disk flush. |
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `click <sel>` | Click element |
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d]` | Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import) |
|
||||
| `cookie-import-browser [browser] [--domain d]` | Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import) |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
|
||||
@@ -73,7 +73,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size', usage: 'viewport <WxH>' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from Comet, Chrome, Arc, Brave, or Edge (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Import cookies from installed Chromium browsers (opens picker, or use --domain for direct import)', usage: 'cookie-import-browser [browser] [--domain d]' },
|
||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
/**
|
||||
* Chromium browser cookie import — read and decrypt cookies from real browsers
|
||||
*
|
||||
* Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge.
|
||||
* Supports macOS and Linux Chromium-based browsers.
|
||||
* Pure logic module — no Playwright dependency, no HTTP concerns.
|
||||
*
|
||||
* Decryption pipeline (Chromium macOS "v10" format):
|
||||
* Decryption pipeline:
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────────────┐
|
||||
* │ 1. Keychain: `security find-generic-password -s "<svc>" -w` │
|
||||
* │ → base64 password string │
|
||||
* │ 1. Resolve the cookie DB from the browser profile dir │
|
||||
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
|
||||
* │ - Linux: ~/.config/<browser>/<profile> │
|
||||
* │ │
|
||||
* │ 2. Key derivation: │
|
||||
* │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │
|
||||
* │ → 16-byte AES key │
|
||||
* │ 2. Derive the AES key │
|
||||
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
|
||||
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
|
||||
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
|
||||
* │ │
|
||||
* │ 3. For each cookie with encrypted_value starting with "v10": │
|
||||
* │ 3. For each cookie with encrypted_value starting with "v10"/ │
|
||||
* │ "v11": │
|
||||
* │ - Ciphertext = encrypted_value[3:] │
|
||||
* │ - IV = 16 bytes of 0x20 (space character) │
|
||||
* │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
|
||||
* │ - Remove PKCS7 padding │
|
||||
* │ - Skip first 32 bytes (HMAC-SHA256 authentication tag) │
|
||||
* │ - Skip first 32 bytes of Chromium cookie metadata │
|
||||
* │ - Remaining bytes = cookie value (UTF-8) │
|
||||
* │ │
|
||||
* │ 4. If encrypted_value is empty but `value` field is set, │
|
||||
@@ -42,9 +45,11 @@ import * as os from 'os';
|
||||
|
||||
export interface BrowserInfo {
|
||||
name: string;
|
||||
dataDir: string; // relative to ~/Library/Application Support/
|
||||
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
|
||||
keychainService: string;
|
||||
aliases: string[];
|
||||
linuxDataDir?: string;
|
||||
linuxApplication?: string;
|
||||
}
|
||||
|
||||
export interface DomainEntry {
|
||||
@@ -81,15 +86,24 @@ export class CookieImportError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type BrowserPlatform = 'darwin' | 'linux';
|
||||
|
||||
interface BrowserMatch {
|
||||
browser: BrowserInfo;
|
||||
platform: BrowserPlatform;
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
// ─── Browser Registry ───────────────────────────────────────────
|
||||
// Hardcoded — NEVER interpolate user input into shell commands.
|
||||
|
||||
const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] },
|
||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge' },
|
||||
];
|
||||
|
||||
// ─── Key Cache ──────────────────────────────────────────────────
|
||||
@@ -104,11 +118,14 @@ const keyCache = new Map<string, Buffer>();
|
||||
* Find which browsers are installed (have a cookie DB on disk).
|
||||
*/
|
||||
export function findInstalledBrowsers(): BrowserInfo[] {
|
||||
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
||||
return BROWSER_REGISTRY.filter(b => {
|
||||
const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies');
|
||||
try { return fs.existsSync(dbPath); } catch { return false; }
|
||||
});
|
||||
return BROWSER_REGISTRY.filter(browser => findBrowserMatch(browser, 'Default') !== null);
|
||||
}
|
||||
|
||||
export function listSupportedBrowserNames(): string[] {
|
||||
const hostPlatform = getHostPlatform();
|
||||
return BROWSER_REGISTRY
|
||||
.filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true)
|
||||
.map(browser => browser.name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +133,8 @@ export function findInstalledBrowsers(): BrowserInfo[] {
|
||||
*/
|
||||
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const dbPath = getCookieDbPath(browser, profile);
|
||||
const db = openDb(dbPath, browser.name);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
const rows = db.query(
|
||||
@@ -144,9 +161,9 @@ export async function importCookies(
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
|
||||
const browser = resolveBrowser(browserName);
|
||||
const derivedKey = await getDerivedKey(browser);
|
||||
const dbPath = getCookieDbPath(browser, profile);
|
||||
const db = openDb(dbPath, browser.name);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const derivedKeys = await getDerivedKeys(match);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
|
||||
try {
|
||||
const now = chromiumNow();
|
||||
@@ -167,7 +184,7 @@ export async function importCookies(
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const value = decryptCookieValue(row, derivedKey);
|
||||
const value = decryptCookieValue(row, derivedKeys);
|
||||
const cookie = toPlaywrightCookie(row, value);
|
||||
cookies.push(cookie);
|
||||
domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
|
||||
@@ -208,17 +225,61 @@ function validateProfile(profile: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getCookieDbPath(browser: BrowserInfo, profile: string): string {
|
||||
validateProfile(profile);
|
||||
const appSupport = path.join(os.homedir(), 'Library', 'Application Support');
|
||||
const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies');
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is not installed (no cookie database at ${dbPath})`,
|
||||
'not_installed',
|
||||
);
|
||||
function getHostPlatform(): BrowserPlatform | null {
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') return process.platform;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSearchPlatforms(): BrowserPlatform[] {
|
||||
const current = getHostPlatform();
|
||||
const order: BrowserPlatform[] = [];
|
||||
if (current) order.push(current);
|
||||
for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) {
|
||||
if (!order.includes(platform)) order.push(platform);
|
||||
}
|
||||
return dbPath;
|
||||
return order;
|
||||
}
|
||||
|
||||
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
||||
return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null;
|
||||
}
|
||||
|
||||
function getBaseDir(platform: BrowserPlatform): string {
|
||||
return platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
|
||||
validateProfile(profile);
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies');
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return { browser, platform, dbPath };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
||||
const match = findBrowserMatch(browser, profile);
|
||||
if (match) return match;
|
||||
|
||||
const attempted = getSearchPlatforms()
|
||||
.map(platform => {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null;
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
@@ -273,17 +334,40 @@ function openDbFromCopy(dbPath: string, browserName: string): Database {
|
||||
|
||||
// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
|
||||
|
||||
async function getDerivedKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
const cached = keyCache.get(browser.keychainService);
|
||||
if (cached) return cached;
|
||||
function deriveKey(password: string, iterations: number): Buffer {
|
||||
return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
|
||||
}
|
||||
|
||||
const password = await getKeychainPassword(browser.keychainService);
|
||||
const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
|
||||
keyCache.set(browser.keychainService, derived);
|
||||
function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer {
|
||||
const cached = keyCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const derived = deriveKey(password, iterations);
|
||||
keyCache.set(cacheKey, derived);
|
||||
return derived;
|
||||
}
|
||||
|
||||
async function getKeychainPassword(service: string): Promise<string> {
|
||||
async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>> {
|
||||
if (match.platform === 'darwin') {
|
||||
const password = await getMacKeychainPassword(match.browser.keychainService);
|
||||
return new Map([
|
||||
['v10', getCachedDerivedKey(`darwin:${match.browser.keychainService}:v10`, password, 1003)],
|
||||
]);
|
||||
}
|
||||
|
||||
const keys = new Map<string, Buffer>();
|
||||
keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
|
||||
|
||||
const linuxPassword = await getLinuxSecretPassword(match.browser);
|
||||
if (linuxPassword) {
|
||||
keys.set(
|
||||
'v11',
|
||||
getCachedDerivedKey(`linux:${match.browser.keychainService}:v11`, linuxPassword, 1),
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function getMacKeychainPassword(service: string): Promise<string> {
|
||||
// Use async Bun.spawn with timeout to avoid blocking the event loop.
|
||||
// macOS may show an Allow/Deny dialog that blocks until the user responds.
|
||||
const proc = Bun.spawn(
|
||||
@@ -341,6 +425,47 @@ async function getKeychainPassword(service: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | null> {
|
||||
const attempts: string[][] = [
|
||||
['secret-tool', 'lookup', 'Title', browser.keychainService],
|
||||
];
|
||||
|
||||
if (browser.linuxApplication) {
|
||||
attempts.push(
|
||||
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password_v2', 'application', browser.linuxApplication],
|
||||
['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password', 'application', browser.linuxApplication],
|
||||
);
|
||||
}
|
||||
|
||||
for (const cmd of attempts) {
|
||||
const password = await runPasswordLookup(cmd, 3_000);
|
||||
if (password) return password;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs),
|
||||
);
|
||||
|
||||
const exitCode = await Promise.race([proc.exited, timeout]);
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
if (exitCode !== 0) return null;
|
||||
|
||||
const password = stdout.trim();
|
||||
return password.length > 0 ? password : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: Cookie Decryption ────────────────────────────────
|
||||
|
||||
interface RawCookie {
|
||||
@@ -356,7 +481,7 @@ interface RawCookie {
|
||||
samesite: number;
|
||||
}
|
||||
|
||||
function decryptCookieValue(row: RawCookie, key: Buffer): string {
|
||||
function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>): string {
|
||||
// Prefer unencrypted value if present
|
||||
if (row.value && row.value.length > 0) return row.value;
|
||||
|
||||
@@ -364,16 +489,15 @@ function decryptCookieValue(row: RawCookie, key: Buffer): string {
|
||||
if (ev.length === 0) return '';
|
||||
|
||||
const prefix = ev.slice(0, 3).toString('utf-8');
|
||||
if (prefix !== 'v10') {
|
||||
throw new Error(`Unknown encryption prefix: ${prefix}`);
|
||||
}
|
||||
const key = keys.get(prefix);
|
||||
if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
|
||||
|
||||
const ciphertext = ev.slice(3);
|
||||
const iv = Buffer.alloc(16, 0x20); // 16 space characters
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
|
||||
// First 32 bytes are HMAC-SHA256 authentication tag; actual value follows
|
||||
// Chromium prefixes encrypted cookie payloads with 32 bytes of metadata.
|
||||
if (plaintext.length <= 32) return '';
|
||||
return plaintext.slice(32).toString('utf-8');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
|
||||
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -333,7 +333,7 @@ export async function handleWriteCommand(
|
||||
|
||||
const browsers = findInstalledBrowsers();
|
||||
if (browsers.length === 0) {
|
||||
throw new Error('No Chromium browsers found. Supported: Comet, Chrome, Arc, Brave, Edge');
|
||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
||||
}
|
||||
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Remaining bytes = actual cookie value
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test';
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
@@ -24,16 +24,26 @@ import * as os from 'os';
|
||||
|
||||
const TEST_PASSWORD = 'test-keychain-password';
|
||||
const TEST_KEY = crypto.pbkdf2Sync(TEST_PASSWORD, 'saltysalt', 1003, 16, 'sha1');
|
||||
const LINUX_V10_PASSWORD = 'peanuts';
|
||||
const LINUX_V10_KEY = crypto.pbkdf2Sync(LINUX_V10_PASSWORD, 'saltysalt', 1, 16, 'sha1');
|
||||
const LINUX_V11_PASSWORD = 'test-linux-secret';
|
||||
const LINUX_V11_KEY = crypto.pbkdf2Sync(LINUX_V11_PASSWORD, 'saltysalt', 1, 16, 'sha1');
|
||||
const IV = Buffer.alloc(16, 0x20);
|
||||
const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
|
||||
|
||||
// Fixture DB path
|
||||
const FIXTURE_DIR = path.join(import.meta.dir, 'fixtures');
|
||||
const FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies.db');
|
||||
const LINUX_FIXTURE_DB = path.join(FIXTURE_DIR, 'test-cookies-linux.db');
|
||||
|
||||
// ─── Encryption Helper ──────────────────────────────────────────
|
||||
|
||||
function encryptCookieValue(value: string): Buffer {
|
||||
function encryptCookieValue(
|
||||
value: string,
|
||||
options?: { key?: Buffer; prefix?: 'v10' | 'v11' },
|
||||
): Buffer {
|
||||
const key = options?.key ?? TEST_KEY;
|
||||
const prefix = options?.prefix ?? 'v10';
|
||||
// 32-byte HMAC tag (random for test) + actual value
|
||||
const hmacTag = crypto.randomBytes(32);
|
||||
const plaintext = Buffer.concat([hmacTag, Buffer.from(value, 'utf-8')]);
|
||||
@@ -43,12 +53,11 @@ function encryptCookieValue(value: string): Buffer {
|
||||
const padLen = blockSize - (plaintext.length % blockSize);
|
||||
const padded = Buffer.concat([plaintext, Buffer.alloc(padLen, padLen)]);
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', TEST_KEY, IV);
|
||||
const cipher = crypto.createCipheriv('aes-128-cbc', key, IV);
|
||||
cipher.setAutoPadding(false); // We padded manually
|
||||
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
||||
|
||||
// Prefix with "v10"
|
||||
return Buffer.concat([Buffer.from('v10'), encrypted]);
|
||||
return Buffer.concat([Buffer.from(prefix), encrypted]);
|
||||
}
|
||||
|
||||
function chromiumEpoch(unixSeconds: number): bigint {
|
||||
@@ -57,11 +66,11 @@ function chromiumEpoch(unixSeconds: number): bigint {
|
||||
|
||||
// ─── Create Fixture Database ────────────────────────────────────
|
||||
|
||||
function createFixtureDb() {
|
||||
function createFixtureDb(dbPath: string): Database {
|
||||
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
||||
if (fs.existsSync(FIXTURE_DB)) fs.unlinkSync(FIXTURE_DB);
|
||||
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
||||
|
||||
const db = new Database(FIXTURE_DB);
|
||||
const db = new Database(dbPath);
|
||||
db.run(`CREATE TABLE cookies (
|
||||
host_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
@@ -74,7 +83,11 @@ function createFixtureDb() {
|
||||
has_expires INTEGER NOT NULL DEFAULT 0,
|
||||
samesite INTEGER NOT NULL DEFAULT 1
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function createMacFixtureDb() {
|
||||
const db = createFixtureDb(FIXTURE_DB);
|
||||
const insert = db.prepare(`INSERT INTO cookies
|
||||
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
@@ -110,6 +123,21 @@ function createFixtureDb() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
function createLinuxFixtureDb() {
|
||||
const db = createFixtureDb(LINUX_FIXTURE_DB);
|
||||
const insert = db.prepare(`INSERT INTO cookies
|
||||
(host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
|
||||
const futureExpiry = Number(chromiumEpoch(Math.floor(Date.now() / 1000) + 86400 * 365));
|
||||
|
||||
insert.run('.linux-v10.com', 'sid', '', encryptCookieValue('linux-v10-value', { key: LINUX_V10_KEY, prefix: 'v10' }), '/', futureExpiry, 1, 1, 1, 1);
|
||||
insert.run('.linux-v11.com', 'auth', '', encryptCookieValue('linux-v11-value', { key: LINUX_V11_KEY, prefix: 'v11' }), '/', futureExpiry, 1, 1, 1, 1);
|
||||
insert.run('.linux-plain.com', 'plain', 'plain-linux', Buffer.alloc(0), '/', futureExpiry, 0, 0, 1, 1);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
// ─── Mock Setup ─────────────────────────────────────────────────
|
||||
// We need to mock:
|
||||
// 1. The Keychain access (getKeychainPassword) to return TEST_PASSWORD
|
||||
@@ -120,17 +148,18 @@ let findInstalledBrowsers: any;
|
||||
let listDomains: any;
|
||||
let importCookies: any;
|
||||
let CookieImportError: any;
|
||||
let originalSpawn: typeof Bun.spawn;
|
||||
|
||||
beforeAll(async () => {
|
||||
createFixtureDb();
|
||||
createMacFixtureDb();
|
||||
createLinuxFixtureDb();
|
||||
|
||||
// Mock Bun.spawn to return test password for keychain access
|
||||
const origSpawn = Bun.spawn;
|
||||
originalSpawn = Bun.spawn;
|
||||
// @ts-ignore - monkey-patching for test
|
||||
Bun.spawn = function(cmd: any, opts: any) {
|
||||
// Intercept security find-generic-password calls
|
||||
if (Array.isArray(cmd) && cmd[0] === 'security' && cmd[1] === 'find-generic-password') {
|
||||
const service = cmd[3]; // -s <service>
|
||||
// Return test password for any known test service
|
||||
return {
|
||||
stdout: new ReadableStream({
|
||||
@@ -146,8 +175,23 @@ beforeAll(async () => {
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
if (Array.isArray(cmd) && cmd[0] === 'secret-tool' && cmd[1] === 'lookup') {
|
||||
return {
|
||||
stdout: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(LINUX_V11_PASSWORD + '\n'));
|
||||
controller.close();
|
||||
}
|
||||
}),
|
||||
stderr: new ReadableStream({
|
||||
start(controller) { controller.close(); }
|
||||
}),
|
||||
exited: Promise.resolve(0),
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
// Pass through other spawn calls
|
||||
return origSpawn(cmd, opts);
|
||||
return originalSpawn(cmd, opts);
|
||||
};
|
||||
|
||||
// Import the module (uses our mocked Bun.spawn)
|
||||
@@ -159,8 +203,12 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore Bun.spawn
|
||||
// @ts-ignore - monkey-patching for test
|
||||
Bun.spawn = originalSpawn;
|
||||
// Clean up fixture DB
|
||||
try { fs.unlinkSync(FIXTURE_DB); } catch {}
|
||||
try { fs.unlinkSync(LINUX_FIXTURE_DB); } catch {}
|
||||
try { fs.rmdirSync(FIXTURE_DIR); } catch {}
|
||||
});
|
||||
|
||||
@@ -176,6 +224,35 @@ afterAll(() => {
|
||||
// 2. Decrypting them with the module's decryption logic
|
||||
// The actual DB path resolution is tested separately.
|
||||
|
||||
async function withInstalledProfile<T>(
|
||||
relativeBrowserDir: string,
|
||||
sourceDb: string,
|
||||
run: () => Promise<T>,
|
||||
profile = 'Default',
|
||||
): Promise<T> {
|
||||
const homeDir = os.homedir();
|
||||
const profileDir = path.join(homeDir, relativeBrowserDir, profile);
|
||||
const cookiesPath = path.join(profileDir, 'Cookies');
|
||||
const backupPath = path.join(profileDir, `Cookies.backup-${crypto.randomUUID()}`);
|
||||
const hadOriginal = fs.existsSync(cookiesPath);
|
||||
|
||||
fs.mkdirSync(profileDir, { recursive: true });
|
||||
if (hadOriginal) fs.copyFileSync(cookiesPath, backupPath);
|
||||
fs.copyFileSync(sourceDb, cookiesPath);
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (hadOriginal) {
|
||||
fs.copyFileSync(backupPath, cookiesPath);
|
||||
fs.unlinkSync(backupPath);
|
||||
} else {
|
||||
try { fs.unlinkSync(cookiesPath); } catch {}
|
||||
try { fs.rmdirSync(profileDir); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Cookie Import Browser', () => {
|
||||
@@ -351,6 +428,51 @@ describe('Cookie Import Browser', () => {
|
||||
expect(b).toHaveProperty('aliases');
|
||||
}
|
||||
});
|
||||
|
||||
test('detects linux-style Chromium profiles under ~/.config', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const browsers = findInstalledBrowsers();
|
||||
const names = browsers.map((browser: any) => browser.name);
|
||||
|
||||
expect(names).toContain('Chromium');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real Profile Imports', () => {
|
||||
test('imports Linux v10 cookies from ~/.config/chromium', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const result = await importCookies('chromium', ['.linux-v10.com'], 'GstackLinuxV10');
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.cookies[0].name).toBe('sid');
|
||||
expect(result.cookies[0].value).toBe('linux-v10-value');
|
||||
}, 'GstackLinuxV10');
|
||||
});
|
||||
|
||||
test('imports Linux v11 cookies when secret-tool returns a key', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const result = await importCookies('chromium', ['.linux-v11.com'], 'GstackLinuxV11');
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.cookies[0].name).toBe('auth');
|
||||
expect(result.cookies[0].value).toBe('linux-v11-value');
|
||||
}, 'GstackLinuxV11');
|
||||
});
|
||||
|
||||
test('lists domains from Linux Chromium profiles', async () => {
|
||||
await withInstalledProfile('.config/chromium', LINUX_FIXTURE_DB, async () => {
|
||||
const result = listDomains('chromium', 'GstackLinuxDomains');
|
||||
const domains = result.domains.map((entry: any) => entry.domain);
|
||||
|
||||
expect(result.browser).toBe('Chromium');
|
||||
expect(domains).toContain('.linux-v10.com');
|
||||
expect(domains).toContain('.linux-v11.com');
|
||||
expect(domains).toContain('.linux-plain.com');
|
||||
}, 'GstackLinuxDomains');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Corrupt Data Handling', () => {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
name: setup-browser-cookies
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the
|
||||
headless browse session. Opens an interactive picker UI where you select which
|
||||
cookie domains to import. Use before QA testing authenticated pages. Use when asked
|
||||
to "import cookies", "login to the site", or "authenticate the browser".
|
||||
Import cookies from your real Chromium browser into the headless browse session.
|
||||
Opens an interactive picker UI where you select which cookie domains to import.
|
||||
Use before QA testing authenticated pages. Use when asked to "import cookies",
|
||||
"login to the site", or "authenticate the browser".
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -258,7 +258,7 @@ If `NEEDS_SETUP`:
|
||||
$B cookie-import-browser
|
||||
```
|
||||
|
||||
This auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge) and opens
|
||||
This auto-detects installed Chromium browsers and opens
|
||||
an interactive picker UI in your default browser where you can:
|
||||
- Switch between installed browsers
|
||||
- Search domains
|
||||
@@ -289,7 +289,8 @@ Show the user a summary of imported cookies (domain counts).
|
||||
|
||||
## Notes
|
||||
|
||||
- First import per browser may trigger a macOS Keychain dialog — click "Allow" / "Always Allow"
|
||||
- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow"
|
||||
- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key
|
||||
- Cookie picker is served on the same port as the browse server (no extra process)
|
||||
- Only domain names and cookie counts are shown in the UI — no cookie values are exposed
|
||||
- The browse session persists cookies between commands, so imported cookies work immediately
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
name: setup-browser-cookies
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Import cookies from your real browser (Comet, Chrome, Arc, Brave, Edge) into the
|
||||
headless browse session. Opens an interactive picker UI where you select which
|
||||
cookie domains to import. Use before QA testing authenticated pages. Use when asked
|
||||
to "import cookies", "login to the site", or "authenticate the browser".
|
||||
Import cookies from your real Chromium browser into the headless browse session.
|
||||
Opens an interactive picker UI where you select which cookie domains to import.
|
||||
Use before QA testing authenticated pages. Use when asked to "import cookies",
|
||||
"login to the site", or "authenticate the browser".
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -37,7 +37,7 @@ Import logged-in sessions from your real Chromium browser into the headless brow
|
||||
$B cookie-import-browser
|
||||
```
|
||||
|
||||
This auto-detects installed Chromium browsers (Comet, Chrome, Arc, Brave, Edge) and opens
|
||||
This auto-detects installed Chromium browsers and opens
|
||||
an interactive picker UI in your default browser where you can:
|
||||
- Switch between installed browsers
|
||||
- Search domains
|
||||
@@ -68,7 +68,8 @@ Show the user a summary of imported cookies (domain counts).
|
||||
|
||||
## Notes
|
||||
|
||||
- First import per browser may trigger a macOS Keychain dialog — click "Allow" / "Always Allow"
|
||||
- On macOS, the first import per browser may trigger a Keychain dialog — click "Allow" / "Always Allow"
|
||||
- On Linux, `v11` cookies may require `secret-tool`/libsecret access; `v10` cookies use Chromium's standard fallback key
|
||||
- Cookie picker is served on the same port as the browse server (no extra process)
|
||||
- Only domain names and cookie counts are shown in the UI — no cookie values are exposed
|
||||
- The browse session persists cookies between commands, so imported cookies work immediately
|
||||
|
||||
Reference in New Issue
Block a user