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
+229
View File
@@ -0,0 +1,229 @@
/**
* 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';
/**
* Finalize a partial profile into a full FirefoxProfile if valid
*/
function finalizeProfile(partial) {
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, key, value) {
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, current, profiles) {
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) {
if (!existsSync(iniPath))
return [];
const lines = readFileSync(iniPath, 'utf-8').split('\n');
const profiles = [];
let current = 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() {
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() {
const root = getFirefoxRoot();
const iniPath = join(root, 'profiles.ini');
return parseProfilesIni(iniPath);
}
/**
* Resolve a FirefoxProfile to its absolute path
*/
function profileToAbsolutePath(root, p) {
return p.isRelative ? join(root, p.path) : p.path;
}
/**
* Resolve the full path to a Firefox profile directory
*/
function resolveProfilePath(profile) {
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) {
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) {
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) {
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 = [];
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.map((row) => ({
name: row.name,
value: row.value,
domain: row.host,
path: row.path,
expires: row.expiry,
secure: row.isSecure === 1,
httpOnly: row.isHttpOnly === 1,
sameSite: sameSiteToString(row.sameSite),
}));
}
finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
/**
* Convert FirefoxCookie to Playwright cookie format
*/
export function toPlaywrightCookie(cookie) {
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,
};
}
//# sourceMappingURL=firefox.js.map