Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion camofox.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"plugins": {
"youtube": { "enabled": true },
"persistence": { "enabled": true },
"vnc": { "resolution": "1920x1080" }
"vnc": { "resolution": "1920x1080" },
"stealth": { "enabled": false }
}
}
43 changes: 43 additions & 0 deletions plugins/stealth/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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).
78 changes: 78 additions & 0 deletions plugins/stealth/README.md
Original file line number Diff line number Diff line change
@@ -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`.
100 changes: 100 additions & 0 deletions plugins/stealth/index.js
Original file line number Diff line number Diff line change
@@ -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;
144 changes: 144 additions & 0 deletions plugins/stealth/plugin.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});