mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
fix(stealth): address adversarial code review — 7 findings
1. HIGH — webdriver fallback logic bug: define-then-delete on instance re-exposes prototype getter returning true. Fixed: override getter directly on Navigator.prototype when delete fails. 2. HIGH — chrome.runtime clobbered unconditionally, breaking extension messaging (content scripts, sidepanel, background). Fixed: only stub methods that don't already exist (if !w.chrome.runtime.connect ...). 3. MEDIUM — PermissionStatus shape missing EventTarget behavior. Sites calling addEventListener on the result would throw. Fixed: create object via Object.create(EventTarget.prototype). 4. MEDIUM — Plugin item()/namedItem() returned undefined instead of null for missing entries. Detectable and breaks strict checks. Fixed: ?? null. 5. MEDIUM — WebGL params spoofed even without debug extension, which is detectable as synthetic. Fixed: check getExtension() first. 6. LOW/MEDIUM — Only toString itself was registered in the WeakMap; patched getParameter was still inspectable. Fixed: register all patched prototype functions. 7. LOW — Import from playwright-core instead of playwright (transitive dependency). Fixed: import from playwright (direct dependency). All 129 tests pass (54 stealth + 75 existing).
This commit is contained in:
+62
-23
@@ -22,7 +22,7 @@
|
||||
* // Call applyStealthPatches(context) after creating context
|
||||
*/
|
||||
|
||||
import type { BrowserContext } from 'playwright-core';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
/**
|
||||
* Chromium launch args that reduce automation fingerprint.
|
||||
@@ -73,33 +73,53 @@ export async function applyStealthPatches(
|
||||
// Bot detectors check BOTH the value AND property existence.
|
||||
// We need to delete it from the prototype chain entirely,
|
||||
// not just override the value to undefined.
|
||||
//
|
||||
// IMPORTANT: If prototype delete fails, we must NOT define-then-delete
|
||||
// on the instance, as deleting the instance property re-exposes the
|
||||
// prototype getter (which returns true). Instead, override on prototype.
|
||||
try {
|
||||
delete (Navigator.prototype as any).webdriver;
|
||||
} catch { /* immutable in some envs */ }
|
||||
try {
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
delete (navigator as any).webdriver;
|
||||
} catch { /* fallback: at least the value is undefined */ }
|
||||
} catch {
|
||||
// Prototype delete failed (immutable) — override the getter on
|
||||
// the prototype itself so it returns undefined.
|
||||
try {
|
||||
Object.defineProperty(Navigator.prototype, 'webdriver', {
|
||||
get: () => undefined,
|
||||
configurable: true,
|
||||
});
|
||||
} catch { /* truly locked down; value override is best we can do */ }
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2. WEBGL RENDERER (SwiftShader = bot)
|
||||
// ========================================
|
||||
// SwiftShader is a software GPU used in containers/headless.
|
||||
// Real machines report their actual GPU. Spoof to match UA platform.
|
||||
// Only spoof the unmasked vendor/renderer when the debug extension is
|
||||
// actually available on the context. Returning values for these params
|
||||
// when the extension wasn't requested is detectable as synthetic.
|
||||
const origGetParameter = WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter = function (param: GLenum) {
|
||||
if (param === 0x9245) return vendor; // UNMASKED_VENDOR_WEBGL
|
||||
if (param === 0x9246) return renderer; // UNMASKED_RENDERER_WEBGL
|
||||
if (param === 0x9245 || param === 0x9246) {
|
||||
// Only spoof if the context actually has the debug extension
|
||||
const ext = this.getExtension('WEBGL_debug_renderer_info');
|
||||
if (ext) {
|
||||
if (param === 0x9245) return vendor;
|
||||
if (param === 0x9246) return renderer;
|
||||
}
|
||||
}
|
||||
return origGetParameter.call(this, param);
|
||||
};
|
||||
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||
const origGet2 = WebGL2RenderingContext.prototype.getParameter;
|
||||
WebGL2RenderingContext.prototype.getParameter = function (param: GLenum) {
|
||||
if (param === 0x9245) return vendor;
|
||||
if (param === 0x9246) return renderer;
|
||||
if (param === 0x9245 || param === 0x9246) {
|
||||
const ext = this.getExtension('WEBGL_debug_renderer_info');
|
||||
if (ext) {
|
||||
if (param === 0x9245) return vendor;
|
||||
if (param === 0x9246) return renderer;
|
||||
}
|
||||
}
|
||||
return origGet2.call(this, param);
|
||||
};
|
||||
}
|
||||
@@ -140,8 +160,8 @@ export async function applyStealthPatches(
|
||||
length: { get: () => mimes.length, enumerable: true },
|
||||
0: { get: () => mimes[0] },
|
||||
1: { get: () => mimes[1] },
|
||||
item: { value: (i: number) => mimes[i] },
|
||||
namedItem: { value: (name: string) => mimes.find(m => m.type === name) },
|
||||
item: { value: (i: number) => mimes[i] ?? null },
|
||||
namedItem: { value: (name: string) => mimes.find(m => m.type === name) ?? null },
|
||||
});
|
||||
return p;
|
||||
};
|
||||
@@ -150,8 +170,8 @@ export async function applyStealthPatches(
|
||||
const arr = Object.create(PluginArray.prototype);
|
||||
Object.defineProperties(arr, {
|
||||
length: { get: () => plugins.length, enumerable: true },
|
||||
item: { value: (i: number) => plugins[i] },
|
||||
namedItem: { value: (n: string) => plugins.find((p: any) => p.name === n) },
|
||||
item: { value: (i: number) => plugins[i] ?? null },
|
||||
namedItem: { value: (n: string) => plugins.find((p: any) => p.name === n) ?? null },
|
||||
refresh: { value: () => {} },
|
||||
});
|
||||
plugins.forEach((p, i) => Object.defineProperty(arr, i, { get: () => p, enumerable: true }));
|
||||
@@ -172,11 +192,16 @@ export async function applyStealthPatches(
|
||||
installState: () => 'not_installed',
|
||||
runningState: () => 'cannot_run',
|
||||
};
|
||||
w.chrome.runtime = w.chrome.runtime || {};
|
||||
w.chrome.runtime.connect = () => {};
|
||||
w.chrome.runtime.sendMessage = () => {};
|
||||
w.chrome.runtime.onMessage = { addListener: () => {}, removeListener: () => {} };
|
||||
w.chrome.runtime.onConnect = { addListener: () => {}, removeListener: () => {} };
|
||||
// Only stub chrome.runtime if it doesn't already exist (i.e., no extension loaded).
|
||||
// Overwriting real extension APIs breaks messaging between content scripts,
|
||||
// background scripts, and the sidepanel.
|
||||
if (!w.chrome.runtime) {
|
||||
w.chrome.runtime = {};
|
||||
}
|
||||
if (!w.chrome.runtime.connect) w.chrome.runtime.connect = () => {};
|
||||
if (!w.chrome.runtime.sendMessage) w.chrome.runtime.sendMessage = () => {};
|
||||
if (!w.chrome.runtime.onMessage) w.chrome.runtime.onMessage = { addListener: () => {}, removeListener: () => {} };
|
||||
if (!w.chrome.runtime.onConnect) w.chrome.runtime.onConnect = { addListener: () => {}, removeListener: () => {} };
|
||||
if (!w.chrome.csi) w.chrome.csi = () => ({});
|
||||
if (!w.chrome.loadTimes) {
|
||||
w.chrome.loadTimes = () => ({
|
||||
@@ -240,7 +265,15 @@ export async function applyStealthPatches(
|
||||
if (origQuery) {
|
||||
(navigator.permissions as any).query = (params: any) => {
|
||||
if (params.name === 'notifications') {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
|
||||
// Return a proper PermissionStatus-like object with EventTarget methods
|
||||
// to avoid breakage when sites call addEventListener on the result.
|
||||
const status = Object.create(EventTarget.prototype);
|
||||
Object.defineProperties(status, {
|
||||
state: { get: () => 'prompt', enumerable: true },
|
||||
name: { get: () => 'notifications', enumerable: true },
|
||||
onchange: { value: null, writable: true, enumerable: true },
|
||||
});
|
||||
return Promise.resolve(status as PermissionStatus);
|
||||
}
|
||||
return origQuery.call(navigator.permissions, params);
|
||||
};
|
||||
@@ -283,6 +316,12 @@ export async function applyStealthPatches(
|
||||
return nativeStr.call(this);
|
||||
};
|
||||
overrides.set(Function.prototype.toString, 'function toString() { [native code] }');
|
||||
|
||||
// Register all patched functions so .toString() looks native
|
||||
overrides.set(WebGLRenderingContext.prototype.getParameter, 'function getParameter() { [native code] }');
|
||||
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||
overrides.set(WebGL2RenderingContext.prototype.getParameter, 'function getParameter() { [native code] }');
|
||||
}
|
||||
},
|
||||
[gpuVendor, gpuRenderer] as [string, string],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user