feat: GStack Browser stealth + branding — anti-bot patches, custom UA, rebrand

- Add GSTACK_CHROMIUM_PATH env var for custom Chromium binary
- Add BROWSE_EXTENSIONS_DIR env var for extension path override
- Move auth token to /health endpoint (fixes read-only .app bundles)
- Anti-bot stealth: disable navigator.webdriver, fake plugins, languages
- Custom user agent: Chrome/<version> GStackBrowser (auto-detects version)
- Rebrand Chromium plist to "GStack Browser" at launch time
- Update security test to match new token-via-health approach
This commit is contained in:
Garry Tan
2026-03-30 20:47:00 -07:00
parent 18d6e10dbc
commit 126cebf4c4
4 changed files with 137 additions and 27 deletions
+119 -18
View File
@@ -107,6 +107,8 @@ export class BrowserManager {
const fs = require('fs');
const path = require('path');
const candidates = [
// Explicit override via env var (used by GStack Browser.app bundle)
process.env.BROWSE_EXTENSIONS_DIR || '',
// Relative to this source file (dev mode: browse/src/ -> ../../extension)
path.resolve(__dirname, '..', '..', 'extension'),
// Global gstack install
@@ -219,17 +221,26 @@ export class BrowserManager {
// Find the gstack extension directory for auto-loading
const extensionPath = this.findExtensionPath();
const launchArgs = ['--hide-crash-restore-bubble'];
const launchArgs = [
'--hide-crash-restore-bubble',
// Anti-bot-detection: remove the navigator.webdriver flag that Playwright sets.
// Sites like Google and NYTimes check this to block automation browsers.
'--disable-blink-features=AutomationControlled',
];
if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
// Write auth token for extension bootstrap (read via chrome.runtime.getURL)
// Write auth token for extension bootstrap.
// Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only
// in .app bundles and breaks codesigning).
if (authToken) {
const fs = require('fs');
const path = require('path');
const authFile = path.join(extensionPath, '.auth.json');
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
fs.mkdirSync(gstackDir, { recursive: true });
const authFile = path.join(gstackDir, '.auth.json');
try {
fs.writeFileSync(authFile, JSON.stringify({ token: authToken }), { mode: 0o600 });
fs.writeFileSync(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }), { mode: 0o600 });
} catch (err: any) {
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
}
@@ -245,10 +256,59 @@ export class BrowserManager {
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
fs.mkdirSync(userDataDir, { recursive: true });
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
// Used by GStack Browser.app to point at the bundled Chromium.
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
// Patch the Chromium .app's Info.plist so macOS shows our name.
// This works for both dev mode (system Playwright cache) and .app bundle.
const chromePath = executablePath || chromium.executablePath();
try {
// Walk up from binary to the .app's Info.plist
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
// → .../Google Chrome for Testing.app/Contents/Info.plist
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
if (fs.existsSync(chromePlist)) {
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
if (plistContent.includes('Google Chrome for Testing')) {
const patched = plistContent
.replace(/Google Chrome for Testing/g, 'GStack Browser');
fs.writeFileSync(chromePlist, patched);
}
}
} catch {
// Non-fatal: app name just stays as Chrome for Testing
}
// Build custom user agent: keep Chrome version for site compatibility,
// but replace "Chrome for Testing" branding with "GStackBrowser"
let customUA: string | undefined;
if (!this.customUserAgent) {
// Detect Chrome version from the Chromium binary
const chromePath = executablePath || chromium.executablePath();
try {
const versionProc = Bun.spawnSync([chromePath, '--version'], {
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
});
const versionOutput = versionProc.stdout.toString().trim();
// Output like: "Google Chrome for Testing 145.0.6422.0" or "Chromium 145.0.6422.0"
const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/);
const chromeVersion = versionMatch ? versionMatch[1] : '131.0.0.0';
customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 GStackBrowser`;
} catch {
// Fallback: generic modern Chrome UA
customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 GStackBrowser';
}
}
this.context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: launchArgs,
viewport: null, // Use browser's default viewport (real window size)
userAgent: this.customUserAgent || customUA,
...(executablePath ? { executablePath } : {}),
// Playwright adds flags that block extension loading
ignoreDefaultArgs: [
'--disable-extensions',
@@ -259,6 +319,59 @@ export class BrowserManager {
this.connectionMode = 'headed';
this.intentionalDisconnect = false;
// ─── Anti-bot-detection stealth patches ───────────────────────
// Playwright's Chromium is detected by sites like Google/NYTimes via:
// 1. navigator.webdriver = true (handled by --disable-blink-features above)
// 2. Missing plugins array (real Chrome has PDF viewer, etc.)
// 3. Missing languages
// 4. CDP runtime detection (window.cdc_* variables)
// 5. Permissions API returning 'denied' for notifications
await this.context.addInitScript(() => {
// Fake plugins array (real Chrome has at least PDF Viewer)
Object.defineProperty(navigator, 'plugins', {
get: () => {
const plugins = [
{ 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: '' },
];
(plugins as any).namedItem = (name: string) => plugins.find(p => p.name === name) || null;
(plugins as any).refresh = () => {};
return plugins;
},
});
// Fake languages (Playwright sometimes sends empty)
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
});
// Remove CDP runtime artifacts that automation detectors look for
// cdc_ prefixed vars are injected by ChromeDriver/CDP
const cleanup = () => {
for (const key of Object.keys(window)) {
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
try { delete (window as any)[key]; } catch {}
}
}
};
cleanup();
// Re-clean after a tick in case they're injected late
setTimeout(cleanup, 0);
// Override Permissions API to return 'prompt' for notifications
// (automation browsers return 'denied' which is a fingerprint)
const originalQuery = window.navigator.permissions?.query;
if (originalQuery) {
(window.navigator.permissions as any).query = (params: any) => {
if (params.name === 'notifications') {
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
}
return originalQuery.call(window.navigator.permissions, params);
};
}
});
// Inject visual indicator — subtle top-edge amber gradient
// Extension's content script handles the floating pill
const indicatorScript = () => {
@@ -822,20 +935,8 @@ export class BrowserManager {
if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
// Write auth token for extension bootstrap during handoff
if (this.serverPort) {
try {
const { resolveConfig } = require('./config');
const config = resolveConfig();
const stateFile = path.join(config.stateDir, 'browse.json');
if (fs.existsSync(stateFile)) {
const stateData = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
if (stateData.token) {
fs.writeFileSync(path.join(extensionPath, '.auth.json'), JSON.stringify({ token: stateData.token }), { mode: 0o600 });
}
}
} catch {}
}
// Auth token is served via /health endpoint now (no file write needed).
// Extension reads token from /health on connect.
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
} else {
console.log('[browse] Handoff: extension not found — headed mode without side panel');
+4 -1
View File
@@ -955,7 +955,10 @@ async function start() {
uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(),
currentUrl: browserManager.getCurrentUrl(),
// token removed — see .auth.json for extension bootstrap
// Auth token for extension bootstrap. Safe: /health is localhost-only.
// Previously served via .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning. Extension reads token from here.
token: AUTH_TOKEN,
chatEnabled: true,
agent: {
status: agentStatus,
+7 -6
View File
@@ -21,13 +21,14 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s
}
describe('Server auth security', () => {
// Test 1: /health response must not leak the auth token
test('/health response must not contain token field', () => {
// Test 1: /health serves auth token for extension bootstrap (localhost-only, safe)
// Previously token was removed from /health, but extension needs it since
// .auth.json in the extension dir breaks read-only .app bundles and codesigning.
test('/health serves auth token with safety comment', () => {
const healthBlock = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/refs'");
// The old pattern was: token: AUTH_TOKEN
// The new pattern should have a comment indicating token was removed
expect(healthBlock).not.toContain('token: AUTH_TOKEN');
expect(healthBlock).toContain('token removed');
expect(healthBlock).toContain('token: AUTH_TOKEN');
// Must have a comment explaining why this is safe
expect(healthBlock).toContain('localhost-only');
});
// Test 2: /refs endpoint requires auth via validateAuth
+7 -2
View File
@@ -34,8 +34,13 @@ function getBaseUrl() {
async function loadAuthToken() {
if (authToken) return;
// Get token from browse server /health endpoint (localhost-only, safe).
// Previously read from .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning.
const base = getBaseUrl();
if (!base) return;
try {
const resp = await fetch(chrome.runtime.getURL('.auth.json'));
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (resp.ok) {
const data = await resp.json();
if (data.token) authToken = data.token;
@@ -88,7 +93,7 @@ function setConnected(healthData) {
function setDisconnected() {
const wasConnected = isConnected;
isConnected = false;
// Keep authToken — it comes from .auth.json, not /health
// Keep authToken — it persists across reconnections
chrome.action.setBadgeText({ text: '' });
chrome.runtime.sendMessage({ type: 'health', data: null }).catch(() => {});