Add Firefox cookie import and stealth mode

- Firefox cookie importer: reads cookies.sqlite with WAL-safe copy,
  profile detection via profiles.ini, cross-platform paths, domain filtering
- Stealth mode: opt-in via launch(stealth: true), patches navigator.webdriver,
  plugins/mimeTypes, permissions API, WebGL renderer, iframe isolation,
  languages, plus realistic Safari UA and context hardening
- Import tool now accepts 'safari' | 'firefox' source
- STEALTH.md reference documentation
- Upgraded @types/node to v25 for node:sqlite support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 23:03:15 +02:00
parent 8ca72632b2
commit 1d3192cffd
19 changed files with 1189 additions and 67 deletions
+138 -2
View File
@@ -4,6 +4,7 @@ import { promisify } from 'node:util';
import { type Browser, type BrowserContext, type Page, type Route, webkit } from 'playwright';
const execAsync = promisify(exec);
import * as firefox from './firefox.js';
import * as image from './image.js';
import * as safari from './safari.js';
import type {
@@ -49,17 +50,39 @@ export class ClaudeBrowser {
fullscreen: options.fullscreen ?? false,
preview: options.preview ?? false,
previewDelay: options.previewDelay ?? 2000,
stealth: options.stealth ?? false,
};
}
async launch(): Promise<void> {
this.browser = await webkit.launch({ headless: this.options.headless });
this.context = await this.browser.newContext({
const contextOptions: Record<string, unknown> = {
viewport: {
width: this.options.width,
height: this.options.height,
},
});
};
if (this.options.stealth) {
Object.assign(contextOptions, {
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
locale: 'en-US',
timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone,
colorScheme: 'light' as const,
extraHTTPHeaders: {
'Accept-Language': 'en-US,en;q=0.9',
},
});
}
this.context = await this.browser.newContext(contextOptions);
if (this.options.stealth) {
await this.applyStealthPatches();
}
this.page = await this.context.newPage();
this.setupConsoleListener(this.page);
this.setupNetworkListener(this.page);
@@ -71,6 +94,91 @@ export class ClaudeBrowser {
}
}
/**
* Apply stealth patches via addInitScript.
* These run before any page script in all Playwright engines (WebKit included).
* Scripts are passed as strings since they execute in browser context, not Node.
* See STEALTH.md for full documentation.
*/
private async applyStealthPatches(): Promise<void> {
if (!this.context) return;
// 1. WebDriver flag — set to undefined, not false
// Some detectors specifically check for false as a signal of patching
await this.context.addInitScript(`
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
`);
// 2. Plugins & MimeTypes — headless reports empty arrays
await this.context.addInitScript(`
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
],
});
Object.defineProperty(navigator, 'mimeTypes', {
get: () => [
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
],
});
`);
// 3. Permissions API — fix notifications query inconsistency
await this.context.addInitScript(`
const __origQuery = navigator.permissions.query.bind(navigator.permissions);
Object.defineProperty(navigator.permissions, 'query', {
value: (params) =>
params.name === 'notifications'
? Promise.resolve({ state: Notification.permission })
: __origQuery(params),
});
`);
// 4. WebGL renderer masking — spoof GPU vendor/renderer
// Params 37445 (UNMASKED_VENDOR_WEBGL) and 37446 (UNMASKED_RENDERER_WEBGL)
await this.context.addInitScript(`
const __origGetParam = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(p) {
if (p === 37445) return 'Apple GPU';
if (p === 37446) return 'Apple M1 Pro';
return __origGetParam.call(this, p);
};
if (typeof WebGL2RenderingContext !== 'undefined') {
const __origGetParam2 = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function(p) {
if (p === 37445) return 'Apple GPU';
if (p === 37446) return 'Apple M1 Pro';
return __origGetParam2.call(this, p);
};
}
`);
// 5. iframe contentWindow isolation — apply webdriver patch in child frames
await this.context.addInitScript(`
const __iframeDesc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
if (__iframeDesc) {
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
get: function() {
const win = __iframeDesc.get?.call(this);
if (win) {
try {
Object.defineProperty(win.navigator, 'webdriver', { get: () => undefined });
} catch(e) {}
}
return win;
},
});
}
`);
// 6. Languages consistency
await this.context.addInitScript(`
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
`);
}
private async enterFullscreen(): Promise<void> {
if (process.platform !== 'darwin') {
console.warn('Native fullscreen only supported on macOS');
@@ -797,6 +905,34 @@ export class ClaudeBrowser {
};
}
if (cmd.source === 'firefox') {
const cookies = firefox.importFirefoxCookies({
domain: cmd.domain,
profile: cmd.profile,
});
if (cookies.length === 0) {
return {
ok: true,
imported: 0,
source: 'firefox',
domains: [],
};
}
const playwrightCookies = cookies.map(firefox.toPlaywrightCookie);
await context.addCookies(playwrightCookies);
const domains = [...new Set(cookies.map((c) => c.domain))];
return {
ok: true,
imported: cookies.length,
source: 'firefox',
domains,
};
}
return { ok: false, error: `Unknown import source: ${cmd.source}` };
}
+285
View File
@@ -0,0 +1,285 @@
/**
* Firefox cookie importer
*
* Reads cookies from Firefox's cookies.sqlite database.
* Firefox stores cookies as plain unencrypted SQLite — no binary parsing needed.
*
* Database schema (moz_cookies table):
* id, originAttributes, name, value, host, path, expiry,
* lastAccessed, creationTime, isSecure, isHttpOnly,
* inBrowserElement, sameSite, rawSameSite, schemeMap
*
* Note: expiry is Unix seconds, but lastAccessed/creationTime are microseconds.
*
* Firefox holds an exclusive WAL lock while running, so we copy the database
* files (cookies.sqlite + WAL + SHM) to a temp directory before reading.
*/
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, platform, tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
export interface FirefoxCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number; // Unix timestamp (seconds)
secure: boolean;
httpOnly: boolean;
sameSite: 'None' | 'Lax' | 'Strict';
}
interface FirefoxProfile {
name: string;
path: string;
isRelative: boolean;
isDefault: boolean;
}
/**
* Finalize a partial profile into a full FirefoxProfile if valid
*/
function finalizeProfile(partial: Partial<FirefoxProfile> | null): FirefoxProfile | null {
if (!partial?.name || !partial?.path) return null;
return {
name: partial.name,
path: partial.path,
isRelative: partial.isRelative ?? true,
isDefault: partial.isDefault ?? false,
};
}
/**
* Apply a key=value line to a partial profile
*/
function applyProfileField(profile: Partial<FirefoxProfile>, key: string, value: string): void {
if (key === 'Name') profile.name = value;
else if (key === 'Path') profile.path = value;
else if (key === 'IsRelative') profile.isRelative = value === '1';
else if (key === 'Default') profile.isDefault = value === '1';
}
/**
* Process a single line of profiles.ini, updating state
*/
function processIniLine(
line: string,
current: Partial<FirefoxProfile> | null,
profiles: FirefoxProfile[]
): Partial<FirefoxProfile> | null {
const trimmed = line.trim();
if (trimmed.startsWith('[Profile') || trimmed.startsWith('[Install')) {
const finalized = finalizeProfile(current);
if (finalized) profiles.push(finalized);
return trimmed.startsWith('[Profile') ? {} : null;
}
if (current) {
const eqIdx = trimmed.indexOf('=');
if (eqIdx !== -1) applyProfileField(current, trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
}
return current;
}
/**
* Parse Firefox profiles.ini to find available profiles
*/
function parseProfilesIni(iniPath: string): FirefoxProfile[] {
if (!existsSync(iniPath)) return [];
const lines = readFileSync(iniPath, 'utf-8').split('\n');
const profiles: FirefoxProfile[] = [];
let current: Partial<FirefoxProfile> | null = null;
for (const line of lines) {
current = processIniLine(line, current, profiles);
}
const last = finalizeProfile(current);
if (last) profiles.push(last);
return profiles;
}
/**
* Get the Firefox profiles root directory for the current platform
*/
function getFirefoxRoot(): string {
const home = homedir();
switch (platform()) {
case 'darwin':
return join(home, 'Library/Application Support/Firefox');
case 'linux':
return join(home, '.mozilla/firefox');
case 'win32':
return join(process.env.APPDATA || join(home, 'AppData/Roaming'), 'Mozilla/Firefox');
default:
throw new Error(`Unsupported platform: ${platform()}`);
}
}
/**
* List available Firefox profiles
*/
export function listFirefoxProfiles(): FirefoxProfile[] {
const root = getFirefoxRoot();
const iniPath = join(root, 'profiles.ini');
return parseProfilesIni(iniPath);
}
/**
* Resolve a FirefoxProfile to its absolute path
*/
function profileToAbsolutePath(root: string, p: FirefoxProfile): string {
return p.isRelative ? join(root, p.path) : p.path;
}
/**
* Resolve the full path to a Firefox profile directory
*/
function resolveProfilePath(profile?: string): string {
const root = getFirefoxRoot();
const profiles = listFirefoxProfiles();
if (!profile) {
const defaultProfile = profiles.find((p) => p.isDefault) || profiles[0];
if (!defaultProfile) throw new Error('No Firefox profiles found. Is Firefox installed?');
return profileToAbsolutePath(root, defaultProfile);
}
// Try exact match by name or path
const match = profiles.find((p) => p.name === profile || p.path === profile);
if (match) return profileToAbsolutePath(root, match);
// Try as direct path fragment in Profiles dir
const directPath = join(root, 'Profiles', profile);
if (existsSync(directPath)) return directPath;
// Try as absolute path
if (existsSync(profile)) return profile;
const available = profiles.map((p) => p.name).join(', ');
throw new Error(`Firefox profile not found: "${profile}". Available: ${available}`);
}
/**
* Safely copy the Firefox cookies database to a temp directory.
* Copies cookies.sqlite + WAL + SHM files to avoid lock conflicts.
*/
function copyDatabaseSafely(dbPath: string): { tmpDir: string; tmpDbPath: string } {
if (!existsSync(dbPath)) {
throw new Error(
`Firefox cookies database not found at: ${dbPath}\nMake sure Firefox has been used at least once.`
);
}
const tmpDir = mkdtempSync(join(tmpdir(), 'browse-fx-'));
const dbName = 'cookies.sqlite';
try {
// Copy main database
copyFileSync(dbPath, join(tmpDir, dbName));
// Copy WAL and SHM if they exist (needed for up-to-date reads)
for (const ext of ['-wal', '-shm']) {
const src = `${dbPath}${ext}`;
if (existsSync(src)) {
copyFileSync(src, join(tmpDir, `${dbName}${ext}`));
}
}
return { tmpDir, tmpDbPath: join(tmpDir, dbName) };
} catch (err) {
// Clean up on failure
rmSync(tmpDir, { recursive: true, force: true });
throw err;
}
}
/**
* Convert Firefox sameSite integer to string
* 0 = None, 1 = Lax, 2 = Strict
*/
function sameSiteToString(value: number): 'None' | 'Lax' | 'Strict' {
switch (value) {
case 2:
return 'Strict';
case 1:
return 'Lax';
default:
return 'None';
}
}
/**
* Import cookies from Firefox's cookies.sqlite database
*/
export function importFirefoxCookies(options?: {
profile?: string;
domain?: string;
}): FirefoxCookie[] {
const profilePath = resolveProfilePath(options?.profile);
const dbPath = join(profilePath, 'cookies.sqlite');
const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath);
try {
const db = new DatabaseSync(tmpDbPath, { readOnly: true });
let query =
'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies';
const params: string[] = [];
if (options?.domain) {
const domain = options.domain.toLowerCase();
query += ' WHERE LOWER(host) = ? OR LOWER(host) = ? OR LOWER(host) LIKE ?';
params.push(domain, `.${domain}`, `%.${domain}`);
}
const stmt = db.prepare(query);
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
db.close();
return (rows as Record<string, unknown>[]).map((row) => ({
name: row.name as string,
value: row.value as string,
domain: row.host as string,
path: row.path as string,
expires: row.expiry as number,
secure: (row.isSecure as number) === 1,
httpOnly: (row.isHttpOnly as number) === 1,
sameSite: sameSiteToString(row.sameSite as number),
}));
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
/**
* Convert FirefoxCookie to Playwright cookie format
*/
export function toPlaywrightCookie(cookie: FirefoxCookie): {
name: string;
value: string;
domain: string;
path: string;
expires: number;
secure: boolean;
httpOnly: boolean;
sameSite: 'Strict' | 'Lax' | 'None';
} {
return {
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path,
expires: cookie.expires,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
};
}
+54 -38
View File
@@ -20,6 +20,7 @@ let browserOptions = {
fullscreen: false,
preview: false,
previewDelay: 2000,
stealth: false,
};
let browser = new ClaudeBrowser(browserOptions);
let launched = false;
@@ -86,43 +87,55 @@ server.tool(
previewDelay: z.number().optional().default(2000).describe('Preview highlight duration in ms'),
width: z.number().optional().default(1280).describe('Viewport width'),
height: z.number().optional().default(800).describe('Viewport height'),
stealth: z
.boolean()
.optional()
.default(false)
.describe(
'Enable stealth mode to reduce bot detection. Patches navigator.webdriver, plugins, WebGL, permissions, and sets a realistic Safari user-agent. See STEALTH.md for details.'
),
},
withLogging('launch', async ({ headed, fullscreen, preview, previewDelay, width, height }) => {
// Close existing browser if launched
if (launched) {
await browser.close();
launched = false;
withLogging(
'launch',
async ({ headed, fullscreen, preview, previewDelay, width, height, stealth }) => {
// Close existing browser if launched
if (launched) {
await browser.close();
launched = false;
}
// Update options - fullscreen/preview imply headed
browserOptions = {
headless: fullscreen || preview ? false : !headed,
width,
height,
fullscreen,
preview,
previewDelay,
stealth,
};
// Create new browser with updated options
browser = new ClaudeBrowser(browserOptions);
await browser.launch();
launched = true;
return textResult(
JSON.stringify({
ok: true,
message: `Browser launched${stealth ? ' (stealth mode)' : ''}`,
options: {
headed: !browserOptions.headless,
fullscreen,
preview,
previewDelay,
stealth,
viewport: { width, height },
},
})
);
}
// Update options - fullscreen/preview imply headed
browserOptions = {
headless: fullscreen || preview ? false : !headed,
width,
height,
fullscreen,
preview,
previewDelay,
};
// Create new browser with updated options
browser = new ClaudeBrowser(browserOptions);
await browser.launch();
launched = true;
return textResult(
JSON.stringify({
ok: true,
message: 'Browser launched',
options: {
headed: !browserOptions.headless,
fullscreen,
preview,
previewDelay,
viewport: { width, height },
},
})
);
})
)
);
// Navigation
@@ -680,14 +693,17 @@ server.tool(
// Browser import
server.tool(
'import',
'Import cookies from Safari browser (macOS only). Requires Full Disk Access permission.',
'Import cookies from Safari or Firefox browser. Safari requires Full Disk Access permission (macOS only). Firefox works on macOS, Linux, and Windows.',
{
source: z.enum(['safari']).describe('Browser to import from (currently only Safari supported)'),
source: z.enum(['safari', 'firefox']).describe('Browser to import from'),
domain: z
.string()
.optional()
.describe('Filter cookies to specific domain (e.g., "github.com")'),
profile: z.string().optional().describe('Safari profile/WebKit data store ID (optional)'),
profile: z
.string()
.optional()
.describe('Safari profile/WebKit data store ID, or Firefox profile name (optional)'),
},
withLogging('import', async ({ source, domain, profile }) => {
await ensureLaunched();
+3 -2
View File
@@ -5,6 +5,7 @@ export interface BrowserOptions {
fullscreen?: boolean;
preview?: boolean;
previewDelay?: number;
stealth?: boolean;
}
export interface ElementInfo {
@@ -301,10 +302,10 @@ export interface EmulateCommand {
device: string;
}
// Safari import
// Browser import (Safari, Firefox)
export interface ImportCommand {
cmd: 'import';
source: 'safari';
source: 'safari' | 'firefox';
domain?: string;
profile?: string;
}