diff --git a/camofox.config.json b/camofox.config.json index 03dcaff..d316bbb 100644 --- a/camofox.config.json +++ b/camofox.config.json @@ -5,6 +5,7 @@ "plugins": { "youtube": { "enabled": true }, "persistence": { "enabled": true }, - "vnc": { "resolution": "1920x1080" } + "vnc": { "resolution": "1920x1080" }, + "stealth": { "enabled": false } } } diff --git a/plugins/stealth/AGENTS.md b/plugins/stealth/AGENTS.md new file mode 100644 index 0000000..66f1c9a --- /dev/null +++ b/plugins/stealth/AGENTS.md @@ -0,0 +1,43 @@ +# Stealth Plugin — Agent Guide + +Plugs leaks in the default camoufox launch that fingerprinting suites +(sannysoft, creepjs, browserleaks) reliably catch when no residential +proxy is configured. + +## How It Works + +- `browser:launching` hook → mutates `options.firefoxUserPrefs`: + - Disables WebRTC (`media.peerconnection.enabled = false`) and tightens + ICE so even if WebRTC were re-enabled it can't reveal host candidates. + - Sets `intl.accept_languages` so HTTP `Accept-Language` matches the + configured locale. +- `session:creating` hook → mutates `contextOptions`: + - `locale`, `timezoneId`, `geolocation` — only when the corresponding + config keys are set. Empty config = passthrough. + +All overrides are last-write-wins after core's defaults, so the plugin +cleanly replaces the hardcoded LA/en-US/SF block when no proxy is in use. + +## Key Files + +- `index.js` — both hooks, no routes, no I/O +- `plugin.test.js` — integration tests covering each config knob and the + passthrough case + +## Limitation Worth Knowing + +The `browser:launching` hook fires AFTER camoufox-js's `launchOptions()` +returns. That means `options.os`, `options.humanize`, `options.geoip` +have already been turned into Playwright launch args / firefoxUserPrefs. +Pref overrides here are last-write-wins, but the `os` selection (which +drives navigator.platform, UA family, WebGL renderer) is baked too +deeply to flip from a plugin. To change that, core needs to pick a +different `os` upstream of this hook. + +## When to Update This Plugin + +- Add a new knob — extend `cfg` in `index.js`, add an env-var, add a hook + branch, update README, add a test asserting both the env path and the + config path. +- Don't add knobs that aren't reachable from outside the box (no `app.get` + routes — this plugin is hook-only). diff --git a/plugins/stealth/README.md b/plugins/stealth/README.md new file mode 100644 index 0000000..301498d --- /dev/null +++ b/plugins/stealth/README.md @@ -0,0 +1,78 @@ +# stealth + +Hardens the fingerprint and IP-leak surface of camofox-browser for cases +where a residential proxy is not in use. + +## What it does + +- **Disables WebRTC entirely** via Firefox prefs. Closes the public-IP leak + exposed by STUN srflx candidates — the most common single fingerprint + signal beyond the UA, and not fixable via HTTP proxy alone. +- **Overrides per-session locale, timezone, and geolocation.** With no proxy, + core sets these to `en-US` / `America/Los_Angeles` / SF; this plugin lets + you choose any value (or leave them untouched). +- **Sets `intl.accept_languages`** to match the chosen locale, so HTTP + `Accept-Language` headers don't lie about the configured locale. + +## Configuration + +In `camofox.config.json`: + +```json +{ + "plugins": { + "stealth": { + "enabled": true, + "locale": "en-GB", + "timezone": "Europe/London", + "geoLat": 51.5074, + "geoLon": -0.1278, + "blockWebrtc": true + } + } +} +``` + +Or override via environment variables (highest precedence): + +| Var | Format | Example | +| --- | --- | --- | +| `CAMOFOX_STEALTH_LOCALE` | BCP-47 tag | `en-GB` | +| `CAMOFOX_STEALTH_TZ` | IANA TZ name | `Europe/London` | +| `CAMOFOX_STEALTH_GEO_LAT` | float | `51.5074` | +| `CAMOFOX_STEALTH_GEO_LON` | float | `-0.1278` | +| `CAMOFOX_STEALTH_BLOCK_WEBRTC` | `"0"` to disable | `0` | + +Anything not configured is left at core's default — the plugin only mutates +the fields you ask it to. + +## How it works + +Two lifecycle hooks: + +- **`browser:launching`** — mutates `options.firefoxUserPrefs` to disable + WebRTC (`media.peerconnection.enabled`, `...ice.no_host`, + `...ice.proxy_only_if_behind_proxy`) and to set + `intl.accept_languages`. +- **`session:creating`** — mutates `contextOptions.locale`, + `contextOptions.timezoneId`, `contextOptions.geolocation` per the config. + +## Limitations + +The `browser:launching` hook fires **after** camoufox-js's `launchOptions()` +has already baked the `os` choice into Playwright launch args. This plugin +therefore cannot change `navigator.platform`, the UA family, or WebGL +renderer — those depend on core picking a different `os` value upstream of +this hook. + +## Verifying + +Quick before/after on a configured stealth instance: + +- WebRTC IP leak — visit `https://browserleaks.com/webrtc`. With + `blockWebrtc: true` the host/srflx candidate fields are empty. +- Timezone / locale — visit `https://abrahamjuliot.github.io/creepjs/` and + look at the Timezone and Intl sections; they should reflect the + configured values. +- Geolocation — visit `https://browserleaks.com/geo` (allow geolocation + permission). The reported lat/lon should match `geoLat` / `geoLon`. diff --git a/plugins/stealth/index.js b/plugins/stealth/index.js new file mode 100644 index 0000000..837b6f5 --- /dev/null +++ b/plugins/stealth/index.js @@ -0,0 +1,100 @@ +/** + * Stealth plugin for camofox-browser. + * + * Hardens fingerprint and IP-leak vectors that the default camoufox launch + * does not address out of the box: + * + * - Disables WebRTC peer connections (kills public-IP leak via STUN + * srflx candidates — the most common single fingerprint signal beyond + * UA itself, and not fixable via HTTP proxy alone). + * - Overrides per-session locale, timezone, and geolocation. When no + * proxy is configured, core sets these to en-US / America/Los_Angeles / + * SF — this plugin lets you point them anywhere via env or config. + * - Sets HTTP Accept-Language to match the chosen locale. + * + * Hook points: + * - browser:launching → mutates firefoxUserPrefs (WebRTC kill, + * Accept-Language) + * - session:creating → mutates contextOptions.locale, + * timezoneId, geolocation + * + * Both hooks are no-ops when their respective config keys are unset, so the + * plugin is safe to enable globally with empty config — you only get + * WebRTC block, nothing else changes. + * + * Configuration (camofox.config.json): + * { + * "plugins": { + * "stealth": { + * "enabled": true, + * "locale": "en-GB", + * "timezone": "Europe/London", + * "geoLat": 51.5074, + * "geoLon": -0.1278, + * "blockWebrtc": true + * } + * } + * } + * + * Or via environment variables (override config file): + * CAMOFOX_STEALTH_LOCALE BCP-47 locale tag (e.g. "en-GB") + * CAMOFOX_STEALTH_TZ IANA TZ name (e.g. "Europe/London") + * CAMOFOX_STEALTH_GEO_LAT float latitude + * CAMOFOX_STEALTH_GEO_LON float longitude + * CAMOFOX_STEALTH_BLOCK_WEBRTC "0" to leave WebRTC enabled + * + * Limitations: + * The browser:launching hook fires after camoufox-js's launchOptions() has + * already baked the `os` choice into Playwright launch args. This plugin + * therefore cannot change navigator.platform, UA family, or WebGL + * renderer — those require core to pick a different `os` value. + */ + +function numOr(value, fallback) { + const n = parseFloat(value); + return Number.isFinite(n) ? n : fallback; +} + +function boolFromEnv(value, fallback) { + if (value === undefined || value === null) return fallback; + return value !== '0' && value !== 'false'; +} + +export async function register(app, ctx, pluginConfig = {}) { + const { events, log } = ctx; + + const cfg = { + locale: process.env.CAMOFOX_STEALTH_LOCALE || pluginConfig.locale || null, + timezone: process.env.CAMOFOX_STEALTH_TZ || pluginConfig.timezone || null, + geoLat: numOr(process.env.CAMOFOX_STEALTH_GEO_LAT, numOr(pluginConfig.geoLat, null)), + geoLon: numOr(process.env.CAMOFOX_STEALTH_GEO_LON, numOr(pluginConfig.geoLon, null)), + blockWebrtc: boolFromEnv( + process.env.CAMOFOX_STEALTH_BLOCK_WEBRTC, + pluginConfig.blockWebrtc !== false, + ), + }; + + log('info', 'stealth plugin registered', cfg); + + events.on('browser:launching', ({ options }) => { + options.firefoxUserPrefs = options.firefoxUserPrefs || {}; + if (cfg.blockWebrtc) { + options.firefoxUserPrefs['media.peerconnection.enabled'] = false; + options.firefoxUserPrefs['media.peerconnection.ice.no_host'] = true; + options.firefoxUserPrefs['media.peerconnection.ice.proxy_only_if_behind_proxy'] = true; + } + if (cfg.locale) { + options.firefoxUserPrefs['intl.accept_languages'] = `${cfg.locale},en`; + } + }); + + events.on('session:creating', ({ contextOptions }) => { + if (cfg.locale) contextOptions.locale = cfg.locale; + if (cfg.timezone) contextOptions.timezoneId = cfg.timezone; + if (cfg.geoLat !== null && cfg.geoLon !== null) { + contextOptions.geolocation = { latitude: cfg.geoLat, longitude: cfg.geoLon }; + } + }); +} + +export default register; diff --git a/plugins/stealth/plugin.test.js b/plugins/stealth/plugin.test.js new file mode 100644 index 0000000..9f7fd56 --- /dev/null +++ b/plugins/stealth/plugin.test.js @@ -0,0 +1,144 @@ +import { jest } from '@jest/globals'; +import { createPluginEvents } from '../../lib/plugins.js'; +import { register } from './index.js'; + +const STEALTH_ENV_KEYS = [ + 'CAMOFOX_STEALTH_LOCALE', + 'CAMOFOX_STEALTH_TZ', + 'CAMOFOX_STEALTH_GEO_LAT', + 'CAMOFOX_STEALTH_GEO_LON', + 'CAMOFOX_STEALTH_BLOCK_WEBRTC', +]; + +describe('stealth plugin', () => { + let events, ctx, mockApp, originalEnv; + + beforeEach(() => { + events = createPluginEvents(); + mockApp = {}; + ctx = { + events, + config: {}, + log: jest.fn(), + }; + originalEnv = {}; + for (const k of STEALTH_ENV_KEYS) { + originalEnv[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of STEALTH_ENV_KEYS) { + if (originalEnv[k] === undefined) delete process.env[k]; + else process.env[k] = originalEnv[k]; + } + }); + + test('blocks WebRTC via firefoxUserPrefs by default', async () => { + await register(mockApp, ctx, { enabled: true }); + + const options = {}; + await events.emitAsync('browser:launching', { options }); + + expect(options.firefoxUserPrefs['media.peerconnection.enabled']).toBe(false); + expect(options.firefoxUserPrefs['media.peerconnection.ice.no_host']).toBe(true); + expect(options.firefoxUserPrefs['media.peerconnection.ice.proxy_only_if_behind_proxy']).toBe(true); + }); + + test('honors blockWebrtc=false to leave WebRTC enabled', async () => { + await register(mockApp, ctx, { blockWebrtc: false }); + + const options = { firefoxUserPrefs: {} }; + await events.emitAsync('browser:launching', { options }); + + expect(options.firefoxUserPrefs['media.peerconnection.enabled']).toBeUndefined(); + }); + + test('CAMOFOX_STEALTH_BLOCK_WEBRTC=0 overrides config to leave WebRTC enabled', async () => { + process.env.CAMOFOX_STEALTH_BLOCK_WEBRTC = '0'; + await register(mockApp, ctx, { blockWebrtc: true }); + + const options = { firefoxUserPrefs: {} }; + await events.emitAsync('browser:launching', { options }); + + expect(options.firefoxUserPrefs['media.peerconnection.enabled']).toBeUndefined(); + }); + + test('sets intl.accept_languages when locale configured', async () => { + await register(mockApp, ctx, { locale: 'en-GB' }); + + const options = {}; + await events.emitAsync('browser:launching', { options }); + + expect(options.firefoxUserPrefs['intl.accept_languages']).toBe('en-GB,en'); + }); + + test('leaves Accept-Language alone when no locale configured', async () => { + await register(mockApp, ctx, {}); + + const options = {}; + await events.emitAsync('browser:launching', { options }); + + expect(options.firefoxUserPrefs['intl.accept_languages']).toBeUndefined(); + }); + + test('overrides session contextOptions.locale, timezoneId, and geolocation', async () => { + await register(mockApp, ctx, { + locale: 'en-GB', + timezone: 'Europe/London', + geoLat: 51.5074, + geoLon: -0.1278, + }); + + const contextOptions = { locale: 'en-US', timezoneId: 'America/Los_Angeles', geolocation: { latitude: 0, longitude: 0 } }; + await events.emitAsync('session:creating', { userId: 'u', contextOptions }); + + expect(contextOptions.locale).toBe('en-GB'); + expect(contextOptions.timezoneId).toBe('Europe/London'); + expect(contextOptions.geolocation).toEqual({ latitude: 51.5074, longitude: -0.1278 }); + }); + + test('environment overrides plugin config for locale / TZ / geo', async () => { + process.env.CAMOFOX_STEALTH_LOCALE = 'fr-FR'; + process.env.CAMOFOX_STEALTH_TZ = 'Europe/Paris'; + process.env.CAMOFOX_STEALTH_GEO_LAT = '48.8566'; + process.env.CAMOFOX_STEALTH_GEO_LON = '2.3522'; + + await register(mockApp, ctx, { + locale: 'en-GB', + timezone: 'Europe/London', + geoLat: 51.5074, + geoLon: -0.1278, + }); + + const contextOptions = {}; + await events.emitAsync('session:creating', { userId: 'u', contextOptions }); + + expect(contextOptions.locale).toBe('fr-FR'); + expect(contextOptions.timezoneId).toBe('Europe/Paris'); + expect(contextOptions.geolocation).toEqual({ latitude: 48.8566, longitude: 2.3522 }); + }); + + test('passthrough: leaves contextOptions untouched when nothing configured', async () => { + await register(mockApp, ctx, {}); + + const contextOptions = { locale: 'en-US', timezoneId: 'America/Los_Angeles', geolocation: { latitude: 1, longitude: 2 } }; + await events.emitAsync('session:creating', { userId: 'u', contextOptions }); + + expect(contextOptions.locale).toBe('en-US'); + expect(contextOptions.timezoneId).toBe('America/Los_Angeles'); + expect(contextOptions.geolocation).toEqual({ latitude: 1, longitude: 2 }); + }); + + test('partial config only overrides the specified keys', async () => { + await register(mockApp, ctx, { timezone: 'Europe/London' }); + + const contextOptions = { locale: 'en-US', timezoneId: 'America/Los_Angeles', geolocation: { latitude: 1, longitude: 2 } }; + await events.emitAsync('session:creating', { userId: 'u', contextOptions }); + + expect(contextOptions.locale).toBe('en-US'); + expect(contextOptions.timezoneId).toBe('Europe/London'); + expect(contextOptions.geolocation).toEqual({ latitude: 1, longitude: 2 }); + }); +});