Google binds its session cookies to the device (Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir, --remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP. --use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
270 lines
18 KiB
TypeScript
270 lines
18 KiB
TypeScript
// storebound console fill: which browser it uses. Pure parts (data dir, profile pick, file selection, cookie host
|
|
// filter, DevToolsActivePort, the opt-in flag, the browser plan, Google's rejected page, the launch switches) and the
|
|
// session copy on throwaway folders (cleanup on success and on error). Safety: the default run never copies Google
|
|
// (or Apple) cookies; the opt-in copy of Google cookies warns about device-bound sessions. Integration (real Google
|
|
// Chrome, throwaway profiles, a local fixture site): the window storebound starts has navigator.webdriver false and no
|
|
// automation switch on its real command line (chrome://version); the opt-in copy carries a cookie of the source
|
|
// profile and drops another host's. It never touches the real Chrome profile or a store site; it skips when Google
|
|
// Chrome is not installed or not on macOS.
|
|
import { afterAll, describe, expect, test } from 'bun:test';
|
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { APPLE_COPY_NOTE, AUTOMATION_SWITCHES, browserPlan, chromeDataDir, consoleChromeArgs, CONSOLE_HOSTS, cookieApplies, copySession, devToolsEndpoint, filterCookies, GOOGLE_COPY_WARNING, GOOGLE_REJECTED, googleRejected, NEVER_COPY, openConsoleBrowser, parseUseChromeSession, pickProfile, PROVIDER_HOSTS, sessionFiles } from '../cli/console/session';
|
|
import { capped, CLOSE_TIMEOUT_MS, LAUNCH_TIMEOUT_MS } from './helpers/browser';
|
|
|
|
const scratch = mkdtempSync(join(tmpdir(), 'sb-session-test-'));
|
|
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
|
|
const tmp = (name: string) => { const d = join(scratch, name); mkdirSync(d, { recursive: true }); return d; };
|
|
|
|
async function cookieDb(path: string, hosts: string[]): Promise<void> {
|
|
const { Database } = await import('bun:sqlite');
|
|
const db = new Database(path);
|
|
db.exec('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB)');
|
|
const ins = db.prepare('INSERT INTO cookies VALUES (?, ?, ?, ?)');
|
|
for (const h of hosts) ins.run(h, 'c', '', new Uint8Array([1]));
|
|
db.close();
|
|
}
|
|
|
|
/** A fake Chrome user data dir with the session files and the ones that must never be copied. */
|
|
async function fakeChrome(name: string, profile = 'Default'): Promise<string> {
|
|
const d = tmp(name);
|
|
writeFileSync(join(d, 'Local State'), JSON.stringify({ profile: { last_used: profile } }));
|
|
mkdirSync(join(d, profile), { recursive: true });
|
|
await cookieDb(join(d, profile, 'Cookies'), ['.apple.com', 'appstoreconnect.apple.com', '.google.com', 'play.google.com', 'accounts.google.com', '.youtube.com', 'mail.google.com', '.example.com', 'news.example.org']);
|
|
writeFileSync(join(d, profile, 'Cookies-journal'), '');
|
|
for (const f of ['Login Data', 'History', 'Web Data', 'Bookmarks', 'Preferences']) writeFileSync(join(d, profile, f), 'x');
|
|
mkdirSync(join(d, profile, 'Extensions'), { recursive: true });
|
|
return d;
|
|
}
|
|
|
|
describe('pure parts', () => {
|
|
test('Chrome user data dir per OS', () => {
|
|
expect(chromeDataDir('darwin', '/Users/you')).toBe('/Users/you/Library/Application Support/Google/Chrome');
|
|
expect(chromeDataDir('linux', '/home/you', {})).toBe('/home/you/.config/google-chrome');
|
|
expect(chromeDataDir('win32', 'C:\\Users\\you', { LOCALAPPDATA: 'L' })).toContain('User Data');
|
|
});
|
|
test('profile: --chrome-profile, else last used, else Default; no path tricks', () => {
|
|
expect(pickProfile(null)).toBe('Default');
|
|
expect(pickProfile('{"profile":{"last_used":"Profile 2"}}')).toBe('Profile 2');
|
|
expect(pickProfile('{"profile":{"last_used":"../x"}}')).toBe('Default');
|
|
expect(pickProfile('not json', 'Profile 1')).toBe('Profile 1');
|
|
expect(() => pickProfile(null, '../Default')).toThrow('--chrome-profile');
|
|
});
|
|
test('files: Local State and the cookie store only, never passwords, history or extensions', () => {
|
|
const all = new Set(['Local State', 'Default/Cookies', 'Default/Cookies-journal', 'Default/Login Data', 'Default/History', 'Default/Network/Cookies']);
|
|
expect(sessionFiles('Default', r => all.has(r))).toEqual([
|
|
['Local State', 'Local State'], ['Default/Cookies', 'Default/Cookies'], ['Default/Cookies-journal', 'Default/Cookies-journal'], ['Default/Network/Cookies', 'Default/Network/Cookies'],
|
|
]);
|
|
expect(sessionFiles('Profile 1', r => r === 'Profile 1/Cookies')).toEqual([['Profile 1/Cookies', 'Default/Cookies']]);
|
|
for (const f of ['Default/Login Data', 'Default/History', 'Default/Extensions', 'Default/Web Data']) expect(NEVER_COPY.test(f)).toBe(true);
|
|
expect(NEVER_COPY.test('Default/Cookies')).toBe(false);
|
|
});
|
|
test('cookie hosts: only domains the consoles get', () => {
|
|
for (const h of ['.apple.com', 'appstoreconnect.apple.com', 'idmsa.apple.com', '.google.com', 'play.google.com', 'accounts.google.com']) expect(cookieApplies(h)).toBe(true);
|
|
for (const h of ['.youtube.com', 'mail.google.com', 'developer.apple.com', '.example.com', 'apple.com.example.org', '', '.com']) expect(cookieApplies(h)).toBe(false);
|
|
expect(CONSOLE_HOSTS).toContain('play.google.com');
|
|
});
|
|
test('DevToolsActivePort -> a local endpoint', () => {
|
|
const d = tmp('devtools');
|
|
expect(devToolsEndpoint(d)).toBeNull();
|
|
writeFileSync(join(d, 'DevToolsActivePort'), '9333\n/devtools/browser/abc\n');
|
|
expect(devToolsEndpoint(d)).toBe('http://127.0.0.1:9333');
|
|
writeFileSync(join(d, 'DevToolsActivePort'), 'nope');
|
|
expect(devToolsEndpoint(d)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Google session safety (device-bound cookies)', () => {
|
|
const none = { storeboundChrome: null, chrome: null, chromium: null };
|
|
const copies = () => readdirSync(tmpdir()).filter(f => f.startsWith('storebound-chrome-')).length;
|
|
test('--use-chrome-session: google | apple only; absent = no copy', () => {
|
|
expect(parseUseChromeSession(undefined)).toEqual([]);
|
|
expect(parseUseChromeSession('google')).toEqual(['google']);
|
|
expect(parseUseChromeSession('Apple, google,apple')).toEqual(['apple', 'google']);
|
|
expect(() => parseUseChromeSession(true)).toThrow('--use-chrome-session');
|
|
expect(() => parseUseChromeSession('all')).toThrow('--use-chrome-session');
|
|
});
|
|
test('plan: no copy by default for either store; an opt-in copies only the form\'s own provider', () => {
|
|
for (const store of ['play', 'apple'] as const) expect(browserPlan({ store, platform: 'darwin' }, true).copy).toBeNull();
|
|
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBe('google');
|
|
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBeNull();
|
|
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['google'] }, true).copy).toBeNull();
|
|
expect(browserPlan({ store: 'apple', platform: 'darwin', useChromeSession: ['apple'] }, true).copy).toBe('apple');
|
|
expect(browserPlan({ store: 'play', platform: 'darwin', useChromeSession: ['google'], fresh: true }, true).copy).toBeNull();
|
|
expect(browserPlan({ store: 'play', platform: 'linux', useChromeSession: ['google'] }, true).copy).toBeNull();
|
|
// an Apple copy never carries a Google cookie and the other way round
|
|
expect(PROVIDER_HOSTS.apple.some(h => h.includes('google'))).toBe(false);
|
|
expect(PROVIDER_HOSTS.google.some(h => h.includes('apple'))).toBe(false);
|
|
});
|
|
test('the default run makes no copy of the owner\'s Chrome cookies (play and apple)', async () => {
|
|
const src = await fakeChrome('chrome-default');
|
|
for (const store of ['play', 'apple'] as const) {
|
|
const said: string[] = [];
|
|
const before = copies();
|
|
const e = await openConsoleBrowser(scratch, { store, profileDir: tmp(`own-${store}`), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(x => x as Error);
|
|
expect((e as Error).message).toContain('no usable browser'); // it went straight to storebound's own profile
|
|
expect(said.join('\n')).not.toMatch(/copied|Chrome sign-in|WARNING/);
|
|
expect(copies()).toBe(before);
|
|
}
|
|
});
|
|
test('the opt-in Google copy warns about device-bound sessions first; the Apple one says its note', async () => {
|
|
const src = await fakeChrome('chrome-optin');
|
|
const said: string[] = [];
|
|
await openConsoleBrowser(scratch, { store: 'play', useChromeSession: ['google'], profileDir: tmp('own-optin'), say: m => said.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
|
|
expect(said[0]).toBe(GOOGLE_COPY_WARNING);
|
|
expect(GOOGLE_COPY_WARNING).toContain('sign you out of your real Chrome');
|
|
expect(said.join('\n')).toContain('could not use your Chrome sign-in'); // no Google Chrome here: the copy is removed again
|
|
const saidA: string[] = [];
|
|
await openConsoleBrowser(scratch, { store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-optin-a'), say: m => saidA.push(m), chromeDir: src, platform: 'darwin', launchDeps: { installed: none } }).catch(() => null);
|
|
expect(saidA[0]).toBe(APPLE_COPY_NOTE);
|
|
});
|
|
test('Google\'s rejected page is recognised (and only that page)', () => {
|
|
for (const u of ['https://accounts.google.com/v3/signin/rejected?continue=x', 'https://accounts.google.com/signin/rejected', 'https://accounts.google.com/signin/v2/rejected']) expect(googleRejected(u)).toBe(true);
|
|
for (const u of ['https://accounts.google.com/v3/signin/accountchooser', 'https://accounts.google.com/v3/signin/identifier', 'https://play.google.com/console/signin/rejected', 'https://example.com/v3/signin/rejected', 'not a url']) expect(googleRejected(u)).toBe(false);
|
|
expect(GOOGLE_REJECTED).toBe('Google refused sign-in in this window (/signin/rejected). storebound uses a normal Chrome window; try again, or sign in at accounts.google.com first in this window.');
|
|
});
|
|
test('launch switches: no automation switch, webdriver off, session restore, a DevTools port the OS picks', () => {
|
|
const a = consoleChromeArgs('/p');
|
|
for (const s of AUTOMATION_SWITCHES) expect(a.some(x => x.startsWith(s))).toBe(false);
|
|
expect(a).toContain('--disable-blink-features=AutomationControlled');
|
|
expect(a).toContain('--restore-last-session');
|
|
expect(a).toContain('--remote-debugging-port=0');
|
|
expect(a).toContain('--user-data-dir=/p');
|
|
expect(a.some(x => /user-agent/i.test(x))).toBe(false);
|
|
expect(consoleChromeArgs('/p', { headless: true })).toContain('--headless=new');
|
|
});
|
|
});
|
|
|
|
describe('session copy', () => {
|
|
test('copies only the session files, filters the cookies, cleans up', async () => {
|
|
const src = await fakeChrome('chrome-a');
|
|
const root = tmp('copies-a');
|
|
const c = await copySession(src, { tmpRoot: root });
|
|
expect(c.profile).toBe('Default');
|
|
expect(c.files).toEqual(['Local State', 'Default/Cookies', 'Default/Cookies-journal']);
|
|
for (const f of readdirSync(join(c.dir, 'Default'))) expect(['Cookies', 'Cookies-journal']).toContain(f);
|
|
expect(readdirSync(c.dir).sort()).toEqual(['Default', 'Local State']);
|
|
for (const f of ['Login Data', 'History', 'Web Data', 'Bookmarks', 'Preferences', 'Extensions']) expect(existsSync(join(c.dir, 'Default', f))).toBe(false);
|
|
expect(c.filtered).toBe(true);
|
|
expect(c.kept).toBe(5);
|
|
const { Database } = await import('bun:sqlite');
|
|
const db = new Database(join(c.dir, 'Default', 'Cookies'), { readonly: true });
|
|
expect((db.query('SELECT host_key AS h FROM cookies ORDER BY h').all() as { h: string }[]).map(r => r.h)).toEqual(['.apple.com', '.google.com', 'accounts.google.com', 'appstoreconnect.apple.com', 'play.google.com']);
|
|
db.close();
|
|
c.cleanup(); c.cleanup();
|
|
expect(existsSync(c.dir)).toBe(false);
|
|
expect(readdirSync(root)).toEqual([]);
|
|
// the source is untouched
|
|
expect(existsSync(join(src, 'Default', 'Login Data'))).toBe(true);
|
|
});
|
|
test('a named profile lands in Default of the copy', async () => {
|
|
const src = await fakeChrome('chrome-b', 'Profile 3');
|
|
const c = await copySession(src, { tmpRoot: tmp('copies-b'), profile: 'Profile 3' });
|
|
expect(c.files).toContain('Profile 3/Cookies');
|
|
expect(existsSync(join(c.dir, 'Default', 'Cookies'))).toBe(true);
|
|
c.cleanup();
|
|
});
|
|
test('an error removes the temporary profile before it is thrown', async () => {
|
|
const src = tmp('chrome-broken');
|
|
mkdirSync(join(src, 'Default', 'Cookies'), { recursive: true }); // a folder where the file should be: the copy fails
|
|
const root = tmp('copies-c');
|
|
await expect(copySession(src, { tmpRoot: root })).rejects.toThrow();
|
|
expect(readdirSync(root)).toEqual([]);
|
|
await expect(copySession(tmp('chrome-empty'), { tmpRoot: root })).rejects.toThrow('no Chrome cookie store');
|
|
expect(readdirSync(root)).toEqual([]);
|
|
});
|
|
test('an unreadable cookie file is copied whole and the log says so (filtered: false)', async () => {
|
|
const src = tmp('chrome-notdb');
|
|
writeFileSync(join(src, 'Local State'), '{}');
|
|
mkdirSync(join(src, 'Default'));
|
|
writeFileSync(join(src, 'Default', 'Cookies'), 'not a database');
|
|
const c = await copySession(src, { tmpRoot: tmp('copies-d') });
|
|
expect(c.filtered).toBe(false);
|
|
c.cleanup();
|
|
expect(await filterCookies(join(scratch, 'none.db')).catch(() => 'err')).toBe('err');
|
|
});
|
|
});
|
|
|
|
// ---- integration: real Google Chrome, throwaway profiles, a local fixture site ----
|
|
|
|
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
const hasChrome = process.platform === 'darwin' && existsSync(CHROME);
|
|
if (!hasChrome) console.warn('console-session integration test skipped: Google Chrome is not installed (or not macOS)');
|
|
|
|
describe.skipIf(!hasChrome)('copy mode with real Chrome (throwaway profile, local site)', () => {
|
|
test('a cookie set in the source profile is there in the copy; another host\'s is filtered out; the copy is deleted on close', async () => {
|
|
let chromium: any;
|
|
try { chromium = (await import('playwright')).chromium; } catch { console.warn('skipped: playwright not installed'); return; }
|
|
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: req => {
|
|
const u = new URL(req.url);
|
|
if (u.pathname === '/set') return new Response('set', { headers: { 'set-cookie': 'sb_test=1; Max-Age=3600; Path=/' } });
|
|
return new Response(`cookie: ${/sb_test=1/.test(req.headers.get('cookie') ?? '') ? 'yes' : 'no'}`);
|
|
} });
|
|
const site = `http://127.0.0.1:${server.port}`, other = `http://localhost:${server.port}`;
|
|
const src = tmp('chrome-real');
|
|
// two real Chrome launches: SLOW; launch timeouts raised like the shared browser's (tests/helpers/browser.ts), and
|
|
// whatever is still open is closed in `finally`, so a failed assertion leaves no Chrome running
|
|
let seed: any = null, b: any = null;
|
|
try {
|
|
const opts = { channel: 'chrome', headless: true, timeout: LAUNCH_TIMEOUT_MS, ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'] };
|
|
seed = await chromium.launchPersistentContext(src, opts);
|
|
const p0 = seed.pages()[0] ?? await seed.newPage();
|
|
await p0.goto(`${site}/set`); await p0.goto(`${other}/set`);
|
|
await seed.close();
|
|
seed = null;
|
|
const said: string[] = [];
|
|
b = await openConsoleBrowser(join(import.meta.dir, '..'), {
|
|
store: 'apple', useChromeSession: ['apple'], profileDir: tmp('own-profile'), say: m => said.push(m), chromeDir: src, hosts: ['127.0.0.1'], headless: true, platform: 'darwin', launchTimeoutMs: LAUNCH_TIMEOUT_MS,
|
|
});
|
|
expect(b.mode).toBe('copy');
|
|
expect(said[0]).toBe(APPLE_COPY_NOTE);
|
|
expect(said.join('\n')).toContain('your Chrome sign-in');
|
|
expect(said.join('\n')).not.toContain('sb_test');
|
|
expect(b.tempDir && existsSync(b.tempDir)).toBe(true);
|
|
await b.page.goto(`${site}/check`);
|
|
expect(await b.page.textContent('body')).toBe('cookie: yes');
|
|
await b.page.goto(`${other}/check`);
|
|
expect(await b.page.textContent('body')).toBe('cookie: no');
|
|
await b.close();
|
|
await b.closed;
|
|
expect(existsSync(b.tempDir!)).toBe(false);
|
|
} finally {
|
|
if (seed) await capped(seed.close(), CLOSE_TIMEOUT_MS);
|
|
if (b && !b.isClosed()) { await capped(b.close(), CLOSE_TIMEOUT_MS); b.cleanup(); }
|
|
server.stop(true);
|
|
}
|
|
}, 2 * LAUNCH_TIMEOUT_MS); // bounded by the two launches; ~3 s on an idle machine
|
|
});
|
|
|
|
describe.skipIf(!hasChrome)('the console window has no automation markers (real Chrome, throwaway profile, local page)', () => {
|
|
test('navigator.webdriver is false and the real command line has no --enable-automation', async () => {
|
|
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response(
|
|
'<p id="r"></p><script>document.getElementById("r").textContent = JSON.stringify({ webdriver: navigator.webdriver, ua: navigator.userAgent })</script>',
|
|
{ headers: { 'content-type': 'text/html' } }) });
|
|
let b: any = null;
|
|
try {
|
|
b = await openConsoleBrowser(join(import.meta.dir, '..'), {
|
|
store: 'play', profileDir: tmp('own-webdriver'), say: () => {}, headless: true, platform: 'darwin', chromeDir: tmp('no-chrome-here'), launchTimeoutMs: LAUNCH_TIMEOUT_MS,
|
|
});
|
|
expect(b.mode).toBe('profile');
|
|
for (const s of AUTOMATION_SWITCHES) expect(b.args.some((x: string) => x.startsWith(s))).toBe(false);
|
|
await b.page.goto(`http://127.0.0.1:${server.port}/`);
|
|
const r = JSON.parse(await b.page.textContent('#r'));
|
|
expect(r.webdriver).toBe(false);
|
|
expect(r.ua).toContain('Chrome/');
|
|
await b.page.goto('chrome://version');
|
|
const cmd: string = await b.page.textContent('#command_line');
|
|
expect(cmd).toContain('--user-data-dir=');
|
|
expect(cmd).not.toContain('--enable-automation');
|
|
expect(cmd).not.toContain('--remote-debugging-pipe');
|
|
expect(cmd).toContain('--disable-blink-features=AutomationControlled');
|
|
await b.close();
|
|
await b.closed;
|
|
expect(b.isClosed()).toBe(true);
|
|
} finally {
|
|
if (b && !b.isClosed()) await capped(b.close(), CLOSE_TIMEOUT_MS);
|
|
server.stop(true);
|
|
}
|
|
}, 2 * LAUNCH_TIMEOUT_MS);
|
|
});
|