This commit is contained in:
kgod
2026-05-26 21:02:17 +08:00
commit 8697477a53
10000 changed files with 1541403 additions and 0 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from playwright_stealth.stealth import Stealth, ALL_EVASIONS_DISABLED_KWARGS
@@ -0,0 +1,77 @@
from collections.abc import MutableMapping
# straight from: https://github.com/kennethreitz/requests/blob/master/src/requests/structures.py
class CaseInsensitiveDict(MutableMapping):
"""A case-insensitive ``dict``-like object.
Implements all methods and operations of
``MutableMapping`` as well as dict's ``copy``. Also
provides ``lower_items``.
All keys are expected to be strings. The structure remembers the
case of the last key to be set, and ``iter(instance)``,
``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()``
will contain case-sensitive keys. However, querying and contains
testing is case insensitive::
cid = CaseInsensitiveDict()
cid['Accept'] = 'application/json'
cid['aCCEPT'] == 'application/json' # True
list(cid) == ['Accept'] # True
For example, ``headers['content-encoding']`` will return the
value of a ``'Content-Encoding'`` response header, regardless
of how the header name was originally stored.
If the constructor, ``.update``, or equality comparison
operations are given keys that have equal ``.lower()``s, the
behavior is undefined.
"""
def __init__(self, data=None, **kwargs):
self._store = {}
if data is None:
data = {}
self.update(data, **kwargs)
def __setitem__(self, key, value):
# Use the lowercased key for lookups, but store the actual
# key alongside the value.
self._store[key.lower()] = (key, value)
def __getitem__(self, key):
return self._store[key.lower()][1]
def __delitem__(self, key):
del self._store[key.lower()]
def __iter__(self):
return (casedkey for casedkey, mappedvalue in self._store.values())
def __len__(self):
return len(self._store)
def lower_items(self):
"""Like iteritems(), but with all lowercase keys."""
return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items())
def __eq__(self, other):
from collections.abc import Mapping
if isinstance(other, Mapping):
other = CaseInsensitiveDict(other)
else:
return NotImplemented
# Compare insensitively
return dict(self.lower_items()) == dict(other.lower_items())
# Copy is required
def copy(self):
return CaseInsensitiveDict(self._store.values())
def __repr__(self):
return str(dict(self.items()))
def items(self):
pass
@@ -0,0 +1,43 @@
from playwright import async_api, sync_api
class AsyncWrappingContextManager:
def __init__(self, stealth: "Stealth", manager: async_api.PlaywrightContextManager):
if isinstance(manager, sync_api.PlaywrightContextManager):
raise TypeError("You need to call 'use_sync' instead of 'use_async' for a sync Playwright context")
self.stealth = stealth
self.manager = manager
async def __aenter__(
self,
) -> async_api.Playwright:
context = await self.manager.__aenter__()
self.stealth.hook_playwright_context(context)
return context
async def start(self):
return await self.__aenter__()
async def __aexit__(self, *args) -> None:
await self.manager.__aexit__(*args)
class SyncWrappingContextManager:
def __init__(self, stealth: "Stealth", manager: sync_api.PlaywrightContextManager):
if isinstance(manager, async_api.PlaywrightContextManager):
raise TypeError("You need to call 'use_async' instead of 'use_sync' for an async Playwright context")
self.stealth = stealth
self.manager = manager
def __enter__(
self,
) -> sync_api.Playwright:
context = self.manager.__enter__()
self.stealth.hook_playwright_context(context)
return context
def start(self):
return self.__enter__()
def __exit__(self, *args) -> None:
self.manager.__exit__(*args)
@@ -0,0 +1,68 @@
log("loading chrome.app.js");
if (!window.chrome) {
// Use the exact property descriptor found in headful Chrome
// fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')`
Object.defineProperty(window, "chrome", {
writable: true,
enumerable: true,
configurable: false, // note!
value: {} // We'll extend that later
});
}
// app in window.chrome means we're running headful and don't need to mock anything
if (!("app" in window.chrome)) {
const makeError = {
ErrorInInvocation: (fn) => {
const err = new TypeError(`Error in invocation of app.${fn}()`);
return utils.stripErrorWithAnchor(err, `at ${fn} (eval at <anonymous>`);
}
};
const APP_STATIC_DATA = JSON.parse(
`
{
"isInstalled": false,
"InstallState": {
"DISABLED": "disabled",
"INSTALLED": "installed",
"NOT_INSTALLED": "not_installed"
},
"RunningState": {
"CANNOT_RUN": "cannot_run",
"READY_TO_RUN": "ready_to_run",
"RUNNING": "running"
}
}
`.trim()
);
window.chrome.app = {
...APP_STATIC_DATA,
get isInstalled() {
return false;
},
getDetails: function getDetails() {
if (arguments.length) {
throw makeError.ErrorInInvocation(`getDetails`);
}
return null;
},
getIsInstalled: function getDetails() {
if (arguments.length) {
throw makeError.ErrorInInvocation(`getIsInstalled`);
}
return false;
},
runningState: function getDetails() {
if (arguments.length) {
throw makeError.ErrorInInvocation(`runningState`);
}
return "cannot_run";
}
};
utils.patchToStringNested(window.chrome.app);
}
@@ -0,0 +1,29 @@
log("loading chrome.csi.js");
if (!window.chrome) {
// Use the exact property descriptor found in headful Chrome
// fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')`
Object.defineProperty(window, "chrome", {
writable: true,
enumerable: true,
configurable: false, // note!
value: {} // We'll extend that later
});
}
// Check if we're running headful and don't need to mock anything
// Check that the Navigation Timing API v1 is available, we need that
if (!("csi" in window.chrome) && window.performance?.timing) {
const { csi_timing } = window.performance;
log("loading chrome.csi.js");
window.chrome.csi = function() {
return {
onloadT: csi_timing?.domContentLoadedEventEnd,
startE: csi_timing?.navigationStart,
pageT: Date.now() - csi_timing?.navigationStart,
tran: 15 // transition? seems constant
};
};
utils.patchToString(window.chrome.csi);
}
@@ -0,0 +1,14 @@
log("loading chrome.hairline.js");
// inspired by: https://intoli.com/blog/making-chrome-headless-undetectable/
const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype,
"offsetHeight");
utils.replaceProperty(HTMLDivElement.prototype, "offsetHeight", {
get: function() {
// hmmm not sure about this
if (this.id === "modernizr") {
return 1;
}
return elementDescriptor.get.apply(this);
}
});
@@ -0,0 +1,116 @@
log("loading chrome.load.times.js");
if (!window.chrome) {
// Use the exact property descriptor found in headful Chrome
// fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')`
Object.defineProperty(window, "chrome", {
writable: true,
enumerable: true,
configurable: false, // note!
value: {} // We'll extend that later
});
}
// That means we're running headful and don't need to mock anything
if ("loadTimes" in window.chrome) {
warn("skipping chrome loadtimes update, running in headful mode");
}
// Check that the Navigation Timing API v1 + v2 is available, we need that
if (window.performance?.timing || window.PerformancePaintTiming) {
const { performance } = window;
// Some stuff is not available on about:blank as it requires a navigation to occur,
// let's harden the code to not fail then:
const ntEntryFallback = {
nextHopProtocol: "h2",
type: "other"
};
// The API exposes some funky info regarding the connection
const protocolInfo = {
get connectionInfo() {
const ntEntry = performance.getEntriesByType("navigation")[0] ||
ntEntryFallback;
return ntEntry.nextHopProtocol;
},
get npnNegotiatedProtocol() {
// NPN is deprecated in favor of ALPN, but this implementation returns the
// HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN.
const ntEntry = performance.getEntriesByType("navigation")[0] ||
ntEntryFallback;
return ["h2", "hq"].includes(ntEntry.nextHopProtocol) ?
ntEntry.nextHopProtocol :
"unknown";
},
get navigationType() {
const ntEntry = performance.getEntriesByType("navigation")[0] ||
ntEntryFallback;
return ntEntry.type;
},
get wasAlternateProtocolAvailable() {
// The Alternate-Protocol header is deprecated in favor of Alt-Svc
// (https://www.mnot.net/blog/2016/03/09/alt-svc), so technically this
// should always return false.
return false;
},
get wasFetchedViaSpdy() {
// SPDY is deprecated in favor of HTTP/2, but this implementation returns
// true for HTTP/2 or HTTP2+QUIC/39 as well.
const ntEntry = performance.getEntriesByType("navigation")[0] ||
ntEntryFallback;
return ["h2", "hq"].includes(ntEntry.nextHopProtocol);
},
get wasNpnNegotiated() {
// NPN is deprecated in favor of ALPN, but this implementation returns true
// for HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN.
const ntEntry = performance.getEntriesByType("navigation")[0] ||
ntEntryFallback;
return ["h2", "hq"].includes(ntEntry.nextHopProtocol);
}
};
const { timing } = window.performance;
// Truncate number to specific number of decimals, most of the `loadTimes` stuff has 3
function toFixed(num, fixed) {
const re = new RegExp("^-?\\d+(?:.\\d{0," + (fixed || -1) + "})?");
return num.toString().match(re)[0];
}
const timingInfo = {
get firstPaintAfterLoadTime() {
// This was never actually implemented and always returns 0.
return 0;
},
get requestTime() {
return timing.navigationStart / 1000;
},
get startLoadTime() {
return timing.navigationStart / 1000;
},
get commitLoadTime() {
return timing.responseStart / 1000;
},
get finishDocumentLoadTime() {
return timing.domContentLoadedEventEnd / 1000;
},
get finishLoadTime() {
return timing.loadEventEnd / 1000;
},
get firstPaintTime() {
const fpEntry = performance.getEntriesByType("paint")[0] || {
startTime: timing.loadEventEnd / 1000 // Fallback if no navigation occured (`about:blank`)
};
return toFixed((fpEntry.startTime + performance.timeOrigin) / 1000, 3);
}
};
window.chrome.loadTimes = function() {
return {
...protocolInfo,
...timingInfo
};
};
utils.patchToString(window.chrome.loadTimes);
}
@@ -0,0 +1,240 @@
log("loading chrome.runtime.js");
const STATIC_DATA = {
OnInstalledReason: {
CHROME_UPDATE: "chrome_update",
INSTALL: "install",
SHARED_MODULE_UPDATE: "shared_module_update",
UPDATE: "update"
},
OnRestartRequiredReason: {
APP_UPDATE: "app_update",
OS_UPDATE: "os_update",
PERIODIC: "periodic"
},
PlatformArch: {
ARM: "arm",
ARM64: "arm64",
MIPS: "mips",
MIPS64: "mips64",
X86_32: "x86-32",
X86_64: "x86-64"
},
PlatformNaclArch: {
ARM: "arm",
MIPS: "mips",
MIPS64: "mips64",
X86_32: "x86-32",
X86_64: "x86-64"
},
PlatformOs: {
ANDROID: "android",
CROS: "cros",
LINUX: "linux",
MAC: "mac",
OPENBSD: "openbsd",
WIN: "win"
},
RequestUpdateCheckStatus: {
NO_UPDATE: "no_update",
THROTTLED: "throttled",
UPDATE_AVAILABLE: "update_available"
}
};
if (!window.chrome) {
// Use the exact property descriptor found in headful Chrome
// fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')`
Object.defineProperty(window, "chrome", {
writable: true,
enumerable: true,
configurable: false, // note!
value: {} // We'll extend that later
});
}
// That means we're running headful and don't need to mock anything
const existsAlready = "runtime" in window.chrome;
// `chrome.runtime` is only exposed on secure origins
const isNotSecure = !window.location.protocol.startsWith("https");
if (!existsAlready && !isNotSecure) {
window.chrome.runtime = {
// There's a bunch of static data in that property which doesn't seem to change,
// we should periodically check for updates: `JSON.stringify(window.chrome.runtime, null, 2)`
...STATIC_DATA,
// `chrome.runtime.id` is extension related and returns undefined in Chrome
get id() {
return undefined;
},
// These two require more sophisticated mocks
connect: null,
sendMessage: null
};
const makeCustomRuntimeErrors = (preamble, method, extensionId) => ({
NoMatchingSignature: new TypeError(preamble + `No matching signature.`),
MustSpecifyExtensionID: new TypeError(
preamble +
`${method} called from a webpage must specify an Extension ID (string) for its first argument.`
),
InvalidExtensionID: new TypeError(
preamble + `Invalid extension id: '${extensionId}'`)
});
// Valid Extension IDs are 32 characters in length and use the letter `a` to `p`:
// https://source.chromium.org/chromium/chromium/src/+/master:components/crx_file/id_util.cc;drc=14a055ccb17e8c8d5d437fe080faba4c6f07beac;l=90
const isValidExtensionID = (str) => str.length === 32 &&
str.toLowerCase().match(/^[a-p]+$/);
/** Mock `chrome.runtime.sendMessage` */
const sendMessageHandler = {
apply: function(target, ctx, args) {
const [extensionId, options, responseCallback] = args || [];
// Define custom errors
const errorPreamble = `Error in invocation of runtime.sendMessage(optional string extensionId, any message, optional object options, optional function responseCallback): `;
const Errors = makeCustomRuntimeErrors(errorPreamble,
`chrome.runtime.sendMessage()`, extensionId);
// Check if the call signature looks ok
const noArguments = args.length === 0;
const tooManyArguments = args.length > 4;
const incorrectOptions = options && typeof options !== "object";
const incorrectResponseCallback = responseCallback &&
typeof responseCallback !== "function";
if (noArguments || tooManyArguments || incorrectOptions ||
incorrectResponseCallback) {
throw Errors.NoMatchingSignature;
}
// At least 2 arguments are required before we even validate the extension ID
if (args.length < 2) {
throw Errors.MustSpecifyExtensionID;
}
// Now let's make sure we got a string as extension ID
if (typeof extensionId !== "string") {
throw Errors.NoMatchingSignature;
}
if (!isValidExtensionID(extensionId)) {
throw Errors.InvalidExtensionID;
}
return undefined; // Normal behavior
}
};
utils.mockWithProxy(window.chrome.runtime, "sendMessage",
function sendMessage() {
}, sendMessageHandler);
/**
* Mock `chrome.runtime.connect`
*
* @see https://developer.chrome.com/apps/runtime#method-connect
*/
const connectHandler = {
apply: function(target, ctx, args) {
const [extensionId, connectInfo] = args || [];
// Define custom errors
const errorPreamble = `Error in invocation of runtime.connect(optional string extensionId, optional object connectInfo): `;
const Errors = makeCustomRuntimeErrors(errorPreamble,
`chrome.runtime.connect()`, extensionId);
// Behavior differs a bit from sendMessage:
const noArguments = args.length === 0;
const emptyStringArgument = args.length === 1 && extensionId === "";
if (noArguments || emptyStringArgument) {
throw Errors.MustSpecifyExtensionID;
}
const tooManyArguments = args.length > 2;
const incorrectConnectInfoType = connectInfo && typeof connectInfo !==
"object";
if (tooManyArguments || incorrectConnectInfoType) {
throw Errors.NoMatchingSignature;
}
const extensionIdIsString = typeof extensionId === "string";
if (extensionIdIsString && extensionId === "") {
throw Errors.MustSpecifyExtensionID;
}
if (extensionIdIsString && !isValidExtensionID(extensionId)) {
throw Errors.InvalidExtensionID;
}
// There's another edge-case here: extensionId is optional so we might find a connectInfo object as first param, which we need to validate
const validateConnectInfo = (ci) => {
// More than a first param connectInfo as been provided
if (args.length > 1) {
throw Errors.NoMatchingSignature;
}
// An empty connectInfo has been provided
if (Object.keys(ci).length === 0) {
throw Errors.MustSpecifyExtensionID;
}
// Loop over all connectInfo props an check them
Object.entries(ci).forEach(([k, v]) => {
const isExpected = ["name", "includeTlsChannelId"].includes(k);
if (!isExpected) {
throw new TypeError(errorPreamble + `Unexpected property: '${k}'.`);
}
const MismatchError = (propName, expected, found) =>
TypeError(
errorPreamble +
`Error at property '${propName}': Invalid type: expected ${expected}, found ${found}.`
);
if (k === "name" && typeof v !== "string") {
throw MismatchError(k, "string", typeof v);
}
if (k === "includeTlsChannelId" && typeof v !== "boolean") {
throw MismatchError(k, "boolean", typeof v);
}
});
};
if (typeof extensionId === "object") {
validateConnectInfo(extensionId);
throw Errors.MustSpecifyExtensionID;
}
// Unfortunately even when the connect fails Chrome will return an object with methods we need to mock as well
return utils.patchToStringNested(makeConnectResponse());
}
};
utils.mockWithProxy(window.chrome.runtime, "connect", function connect() {
}, connectHandler);
function makeConnectResponse() {
const onSomething = () => ({
addListener: function addListener() {
},
dispatch: function dispatch() {
},
hasListener: function hasListener() {
},
hasListeners: function hasListeners() {
return false;
},
removeListener: function removeListener() {
}
});
const response = {
name: "",
sender: undefined,
disconnect: function disconnect() {
},
onDisconnect: onSomething(),
onMessage: onSomething(),
postMessage: function postMessage() {
if (!arguments.length) {
throw new TypeError(`Insufficient number of arguments.`);
}
throw new Error(`Attempting to use a disconnected port object`);
}
};
return response;
}
}
@@ -0,0 +1,3 @@
log("loading error.prototype.js");
Object.defineProperty(Error.prototype, "name", {configurable: false, enumerable: false})
@@ -0,0 +1,95 @@
log("loading iframe.contentWindow.js");
try {
// Adds a contentWindow proxy to the provided iframe element
const addContentWindowProxy = (iframe) => {
const contentWindowProxy = {
get(target, key) {
// Now to the interesting part:
// We actually make this thing behave like a regular iframe window,
// by intercepting calls to e.g. `.self` and redirect it to the correct thing. :)
// That makes it possible for these assertions to be correct:
// iframe.contentWindow.self === window.top // must be false
if (key === "self") {
return this;
}
// iframe.contentWindow.frameElement === iframe // must be true
if (key === "frameElement") {
return iframe;
}
return Reflect.get(target, key);
}
};
if (!iframe.contentWindow) {
const proxy = new Proxy(window, contentWindowProxy);
Object.defineProperty(iframe, "contentWindow", {
get() {
return proxy;
},
set(newValue) {
return newValue; // contentWindow is immutable
},
enumerable: true,
configurable: false
});
}
};
// Handles iframe element creation, augments `srcdoc` property so we can intercept further
const handleIframeCreation = (target, thisArg, args) => {
const iframe = target.apply(thisArg, args);
// We need to keep the originals around
const _iframe = iframe;
const _srcdoc = _iframe.srcdoc;
// Add hook for the srcdoc property
// We need to be very surgical here to not break other iframes by accident
Object.defineProperty(iframe, "srcdoc", {
configurable: true, // Important, so we can reset this later
get: function() {
return _iframe.srcdoc;
},
set: function(newValue) {
addContentWindowProxy(this);
// Reset property, the hook is only needed once
Object.defineProperty(iframe, "srcdoc", {
configurable: false,
writable: false,
value: _srcdoc
});
_iframe.srcdoc = newValue;
}
});
return iframe;
};
// Adds a hook to intercept iframe creation events
const addIframeCreationSniffer = () => {
/* global document */
const createElementHandler = {
// Make toString() native
get(target, key) {
return Reflect.get(target, key);
},
apply: function(target, thisArg, args) {
const isIframe = args && args.length && `${args[0]}`.toLowerCase() ===
"iframe";
if (!isIframe) {
// Everything as usual
return target.apply(thisArg, args);
} else {
return handleIframeCreation(target, thisArg, args);
}
}
};
// All this just due to iframes with srcdoc bug
utils.replaceWithProxy(document, "createElement", createElementHandler);
};
// Let's go
addIframeCreationSniffer();
} catch (err) {
// console.warn(err)
}
@@ -0,0 +1,59 @@
log("loading media.codec.js");
/**
* Input might look funky, we need to normalize it so e.g. whitespace isn't an issue for our spoofing.
*
* @example
* video/webm; codecs="vp8, vorbis"
* video/mp4; codecs="avc1.42E01E"
* audio/x-m4a;
* audio/ogg; codecs="vorbis"
* @param {String} arg
*/
const parseInput = (arg) => {
const [mime, codecStr] = arg.trim().split(";");
let codecs = [];
if (codecStr && codecStr.includes("codecs=\"")) {
codecs = codecStr.trim().
replace(`codecs="`, "").
replace(`"`, "").
trim().
split(",").
filter((x) => !!x).
map((x) => x.trim());
}
return {
mime,
codecStr,
codecs
};
};
const canPlayType = {
// Intercept certain requests
apply: function(target, ctx, args) {
if (!args || !args.length) {
return target.apply(ctx, args);
}
const { mime, codecs } = parseInput(args[0]);
// This specific mp4 codec is missing in Chromium
if (mime === "video/mp4") {
if (codecs.includes("avc1.42E01E")) {
return "probably";
}
}
// This mimetype is only supported if no codecs are specified
if (mime === "audio/x-m4a" && !codecs.length) {
return "maybe";
}
// This mimetype is only supported if no codecs are specified
if (mime === "audio/aac" && !codecs.length) {
return "probably";
}
// Everything else as usual
return target.apply(ctx, args);
}
};
/* global HTMLMediaElement */
utils.replaceWithProxy(HTMLMediaElement.prototype, "canPlayType", canPlayType);
@@ -0,0 +1,7 @@
log("loading navigator.hardwareConcurrency");
utils.replaceProperty(Object.getPrototypeOf(navigator), "hardwareConcurrency", {
get() {
return 4;
}
});
@@ -0,0 +1,9 @@
log("loading navigator.languages.js");
log(navigator.languages, opts.navigator_languages_override);
if (utils.arrayEqual(navigator.languages, opts.navigator_languages_override)) {
log("not patching navigator.languages, assuming CLI args were used instead");
} else {
utils.replaceProperty(Object.getPrototypeOf(navigator), "languages", {
get: () => opts.navigator_languages_override
});
}
@@ -0,0 +1,21 @@
log("loading navigator.permissions.js");
const handler = {
apply: function(target, ctx, args) {
const param = (args || [])[0];
if (param && param.name && param.name === "notifications") {
const result = { state: Notification.permission };
Object.setPrototypeOf(result, PermissionStatus.prototype);
return Promise.resolve(result);
}
return utils.cache.Reflect.apply(...arguments);
}
};
utils.replaceWithProxy(
window.navigator.permissions.__proto__, // eslint-disable-line no-proto
"query",
handler
);
@@ -0,0 +1,7 @@
log(`loading navigator.platform.js with opt: ${opts.navigator_platform}`);
if (opts.navigator_platform && navigator.platform !== opts.navigator_platform) {
utils.replaceProperty(Object.getPrototypeOf(navigator), "platform", {
get: () => opts.navigator_platform
});
}
@@ -0,0 +1,84 @@
log("loading navigator.plugins.js");
const data = {
mimeTypes: [
{
type: "application/pdf",
suffixes: "pdf",
description: "",
__pluginName: "Chrome PDF Viewer"
},
{
type: "application/x-google-chrome-pdf",
suffixes: "pdf",
description: "Portable Document Format",
__pluginName: "Chrome PDF Plugin"
},
{
type: "application/x-nacl",
suffixes: "",
description: "Native Client Executable",
__pluginName: "Native Client"
},
{
type: "application/x-pnacl",
suffixes: "",
description: "Portable Native Client Executable",
__pluginName: "Native Client"
}
],
plugins: [
{
name: "Chrome PDF Plugin",
filename: "internal-pdf-viewer",
description: "Portable Document Format",
__mimeTypes: ["application/x-google-chrome-pdf"]
},
{
name: "Chrome PDF Viewer",
filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai",
description: "",
__mimeTypes: ["application/pdf"]
},
{
name: "Native Client",
filename: "internal-nacl-plugin",
description: "",
__mimeTypes: ["application/x-nacl", "application/x-pnacl"]
}
]
};
// That means we're running headful
const hasPlugins = "plugins" in navigator && navigator.plugins.length;
if (!hasPlugins) {
const mimeTypes = generateMagicArray(data.mimeTypes, MimeTypeArray.prototype,
MimeType.prototype, "type");
const plugins = generateMagicArray(data.plugins, PluginArray.prototype,
Plugin.prototype, "name");
// Plugin and MimeType cross-reference each other, let's do that now
// Note: We're looping through `data.plugins` here, not the generated `plugins`
for (const pluginData of data.plugins) {
pluginData.__mimeTypes.forEach((type, index) => {
plugins[pluginData.name][index] = mimeTypes[type];
plugins[type] = mimeTypes[type];
Object.defineProperty(mimeTypes[type], "enabledPlugin", {
value: JSON.parse(JSON.stringify(plugins[pluginData.name])),
writable: false,
enumerable: false, // Important: `JSON.stringify(navigator.plugins)`
configurable: false
});
});
}
const patchNavigator = (name, value) =>
utils.replaceProperty(Object.getPrototypeOf(navigator), name, {
get() {
return value;
}
});
patchNavigator("mimeTypes", mimeTypes);
patchNavigator("plugins", plugins);
}
@@ -0,0 +1,7 @@
log("loading navigator.userAgent.js");
// replace Headless references in default useragent
const current_ua = navigator.userAgent;
utils.replaceProperty(Object.getPrototypeOf(navigator), "userAgent", {
get: () => opts.navigator_user_agent ||
current_ua.replace("HeadlessChrome/", "Chrome/")
});
@@ -0,0 +1,72 @@
log("loading navigator.userAgentData.js");
const originalUserAgentData = navigator.userAgentData;
if (originalUserAgentData) {
/**
* Helper to replace HeadlessChrome with Google Chrome in brand lists
* @param {Array<{brand: string, version: string}>} list - Array of UADataBrand objects
*/
const filterBrandList = (list) =>
list.map((b) => (b.brand === "HeadlessChrome" ? { brand: "Google Chrome", version: b.version } : b));
// Get the NavigatorUAData prototype
const NavigatorUADataProto = Object.getPrototypeOf(originalUserAgentData);
// Cache original methods before patching
const originalGetHighEntropyValues = NavigatorUADataProto.getHighEntropyValues;
const originalToJSON = NavigatorUADataProto.toJSON;
const originalBrandsDescriptor = Object.getOwnPropertyDescriptor(NavigatorUADataProto, "brands");
let cachedFilteredBrands = null;
// Helper to get filtered brands (cached for identity checks)
const getFilteredBrands = () => {
if (cachedFilteredBrands === null) {
const originalBrands = originalBrandsDescriptor.get.call(originalUserAgentData);
cachedFilteredBrands = filterBrandList(originalBrands);
}
return cachedFilteredBrands;
};
// Patch getHighEntropyValues to filter HeadlessChrome from results
utils.replaceProperty(NavigatorUADataProto, "getHighEntropyValues", {
value: function (hints) {
return originalGetHighEntropyValues.call(this, hints).then((data) => {
const newData = { ...data };
if (newData.brands) {
newData.brands = filterBrandList(newData.brands);
}
if (newData.fullVersionList) {
newData.fullVersionList = filterBrandList(newData.fullVersionList);
}
return newData;
});
},
});
// Patch toJSON to filter HeadlessChrome
utils.replaceProperty(NavigatorUADataProto, "toJSON", {
value: function () {
const data = originalToJSON.call(this);
return {
brands: filterBrandList(data.brands),
mobile: data.mobile,
platform: data.platform,
};
},
});
// Patch brands getter to return filtered array (same instance each call, like real Chrome)
utils.replaceProperty(NavigatorUADataProto, "brands", {
get: function () {
// Return cached filtered brands, computing and freezing on first access
return getFilteredBrands();
},
enumerable: originalBrandsDescriptor.enumerable,
configurable: originalBrandsDescriptor.configurable,
});
utils.replaceProperty(NavigatorUADataProto, "userAgentData", {
get: () => originalUserAgentData,
});
}
@@ -0,0 +1,5 @@
log("loading navigator.vendor.js");
utils.replaceProperty(Object.getPrototypeOf(navigator), "vendor", {
get: () => opts.navigator_vendor || "Google Inc."
});
@@ -0,0 +1,20 @@
log("loading navigator.webdriver.js");
// this is close to the most accurate way to emulate this: https://stackoverflow.com/a/69533548
// no point "giving ourselves away" if we don't need to mock this values
// techniques exist to detect Object.defineProperty etc., so if we can avoid it we do
// if args include --disable-blink-features=AutomationControlled, we do not need to mock this
if (navigator.webdriver) {
utils.replaceProperty(Object.getPrototypeOf(navigator), "webdriver", {
get: new Proxy(
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator),
"webdriver").get, {
apply: (target, thisArg, args) => {
// emulate getter call validation
Reflect.apply(target, thisArg, args);
return false;
}
})
});
} else {
log("not patching navigator.webdriver, assuming CLI args were used instead");
}
@@ -0,0 +1,26 @@
log("loading webgl.vendor.js");
const getParameterProxyHandler = {
apply: function (target, ctx, args) {
const param = (args || [])[0];
// UNMASKED_VENDOR_WEBGL
if (param === 37445) {
return opts.webgl_vendor || "Intel Inc."; // default in headless: Google Inc.
}
// UNMASKED_RENDERER_WEBGL
if (param === 37446) {
return opts.webgl_renderer || "Intel Iris OpenGL Engine"; // default in headless: Google SwiftShader
}
return utils.cache.Reflect.apply(target, ctx, args);
},
};
// There's more than one WebGL rendering context
// https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext#Browser_compatibility
// To find out the original values here: Object.getOwnPropertyDescriptors(WebGLRenderingContext.prototype.getParameter)
const addProxy = (obj, propName) => {
utils.replaceWithProxy(obj, propName, getParameterProxyHandler);
};
// For whatever weird reason loops don't play nice with Object.defineProperty, here's the next best thing:
addProxy(WebGLRenderingContext.prototype, "getParameter");
addProxy(WebGL2RenderingContext.prototype, "getParameter");
@@ -0,0 +1,130 @@
generateFunctionMocks = (proto, itemMainProp, dataArray) => ({
item: utils.createProxy(proto.item, {
apply(target, ctx, args) {
if (!args.length) {
throw new TypeError(
`Failed to execute 'item' on '${proto[Symbol.toStringTag]}': 1 argument required, but only 0 present.`
);
}
// Special behavior alert:
// - Vanilla tries to cast strings to Numbers (only integers!) and use them as property index lookup
// - If anything else than an integer (including as string) is provided it will return the first entry
const isInteger = args[0] && Number.isInteger(Number(args[0])); // Cast potential string to number first, then check for integer
// Note: Vanilla never returns `undefined`
return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null;
}
}),
/** Returns the MimeType object with the specified name. */
namedItem: utils.createProxy(proto.namedItem, {
apply(target, ctx, args) {
if (!args.length) {
throw new TypeError(
`Failed to execute 'namedItem' on '${proto[Symbol.toStringTag]}': 1 argument required, but only 0 present.`
);
}
return dataArray.find((mt) => mt[itemMainProp] === args[0]) || null; // Not `undefined`!
}
}),
/** Does nothing and shall return nothing */
refresh: proto.refresh
? utils.createProxy(proto.refresh, {
apply(target, ctx, args) {
return undefined;
}
})
: undefined
});
function generateMagicArray(
dataArray = [],
proto = MimeTypeArray.prototype,
itemProto = MimeType.prototype,
itemMainProp = "type"
) {
// Quick helper to set props with the same descriptors vanilla is using
const defineProp = (obj, prop, value) =>
Object.defineProperty(obj, prop, {
value,
writable: false,
enumerable: false, // Important for mimeTypes & plugins: `JSON.stringify(navigator.mimeTypes)`
configurable: false
});
// Loop over our fake data and construct items
const makeItem = (data) => {
const item = {};
for (const prop of Object.keys(data)) {
if (prop.startsWith("__")) {
continue;
}
defineProp(item, prop, data[prop]);
}
// navigator.plugins[i].length should always be 1
if (itemProto === Plugin.prototype) {
defineProp(item, "length", 1);
}
// We need to spoof a specific `MimeType` or `Plugin` object
return Object.create(itemProto, Object.getOwnPropertyDescriptors(item));
};
const magicArray = [];
// Loop through our fake data and use that to create convincing entities
dataArray.forEach((data) => {
magicArray.push(makeItem(data));
});
// Add direct property access based on types (e.g. `obj['application/pdf']`) afterwards
magicArray.forEach((entry) => {
defineProp(magicArray, entry[itemMainProp], entry);
});
// This is the best way to fake the type to make sure this is false: `Array.isArray(navigator.mimeTypes)`
const magicArrayObj = Object.create(proto, {
...Object.getOwnPropertyDescriptors(magicArray),
// There's one ugly quirk we unfortunately need to take care of:
// The `MimeTypeArray` prototype has an enumerable `length` property,
// but headful Chrome will still skip it when running `Object.getOwnPropertyNames(navigator.mimeTypes)`.
// To strip it we need to make it first `configurable` and can then overlay a Proxy with an `ownKeys` trap.
length: {
value: magicArray.length,
writable: false,
enumerable: false,
configurable: true // Important to be able to use the ownKeys trap in a Proxy to strip `length`
}
});
// Generate our functional function mocks :-)
const functionMocks = generateFunctionMocks(proto, itemMainProp, magicArray);
// Override custom object with proxy
return new Proxy(magicArrayObj, {
get(target, key = "") {
// Redirect function calls to our custom proxied versions mocking the vanilla behavior
if (key === "item") {
return functionMocks.item;
}
if (key === "namedItem") {
return functionMocks.namedItem;
}
if (proto === PluginArray.prototype && key === "refresh") {
return functionMocks.refresh;
}
// Everything else can pass through as normal
return utils.cache.Reflect.get(...arguments);
},
ownKeys(target) {
// There are a couple of quirks where the original property demonstrates "magical" behavior that makes no sense
// This can be witnessed when calling `Object.getOwnPropertyNames(navigator.mimeTypes)` and the absense of `length`
// My guess is that it has to do with the recent change of not allowing data enumeration and this being implemented weirdly
// For that reason we just completely fake the available property names based on our data to match what regular Chrome is doing
// Specific issues when not patching this: `length` property is available, direct `types` props (e.g. `obj['application/pdf']`) are missing
const keys = [];
const typeProps = magicArray.map((mt) => mt[itemMainProp]);
typeProps.forEach((_, i) => keys.push(`${i}`));
typeProps.forEach((propName) => keys.push(propName));
return keys;
}
});
}
@@ -0,0 +1,435 @@
/**
* A set of shared utility functions specifically to modify native browser APIs without leaving traces.
*/
const utils = {};
/**
* Wraps a JS Proxy Handler and strips it's presence from error stacks, in case the traps throw.
* The presence of a JS Proxy can be revealed as it shows up in error stack traces.
*
* @param {object} handler - The JS Proxy handler to wrap
*/
utils.stripProxyFromErrors = (handler = {}) => {
const handler_name = (Math.random() + 1).toString(36).substring(2);
window[handler_name] = {}
// We wrap each trap in the handler in a try/catch and modify the error stack if they throw
const traps = Object.getOwnPropertyNames(handler);
traps.forEach((trap) => {
window[handler_name][trap] = function () {
try {
// Forward the call to the defined proxy handler
return handler[trap].apply(this, arguments || []);
} catch (err) {
// Stack traces differ per browser, we only support chromium based ones currently
if (!err || !err.stack || !err.stack.includes(`at `)) {
throw err;
}
// When something throws within one of our traps the Proxy will show up in error stacks
// An earlier implementation of this code would simply strip lines with a blacklist,
// but it makes sense to be more surgical here and only remove lines related to our Proxy.
// We try to use a known "anchor" line for that and strip it with everything above it.
// If the anchor line cannot be found for some reason we fall back to our blacklist approach.
const stripWithBlacklist = (stack) => {
const blacklist = [
`at Reflect.${trap} `, // e.g. Reflect.get or Reflect.apply
`at Object.${trap} `, // e.g. Object.get or Object.apply
`at window.<computed>.<computed> [as ${trap}] `, // caused by this very wrapper :-)
];
return (
err.stack
.split("\n")
// Always remove the first (file) line in the stack (guaranteed to be our proxy)
.filter((line, index) => index !== 1)
// Check if the line starts with one of our blacklisted strings
.filter((line) => !blacklist.some((bl) => line.trim().startsWith(bl)))
.join("\n")
);
};
const stripWithAnchor = (stack) => {
const stackArr = stack.split("\n");
const anchor = `at window.<computed>.<computed> [as ${trap}] `; // Known first Proxy line in chromium
const anchorIndex = stackArr.findIndex((line) => line.trim().startsWith(anchor));
if (anchorIndex === -1) {
return false; // 404, anchor not found
}
// Strip everything from the top until we reach the anchor line
// Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`)
stackArr.splice(1, anchorIndex);
return stackArr.join("\n");
};
// Try using the anchor method, fallback to blacklist if necessary
err.stack = stripWithAnchor(err.stack) || stripWithBlacklist(err.stack);
throw err; // Re-throw our now sanitized error
}
};
});
return window[handler_name];
};
/**
* Strip error lines from stack traces until (and including) a known line the stack.
*
* @param {object} err - The error to sanitize
* @param {string} anchor - The string the anchor line starts with
*/
utils.stripErrorWithAnchor = (err, anchor) => {
const stackArr = err.stack.split("\n");
const anchorIndex = stackArr.findIndex((line) => line.trim().startsWith(anchor));
if (anchorIndex === -1) {
return err; // 404, anchor not found
}
// Strip everything from the top until we reach the anchor line (remove anchor line as well)
// Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`)
stackArr.splice(1, anchorIndex);
err.stack = stackArr.join("\n");
return err;
};
/**
* Replace the property of an object in a stealthy way.
*
* Note: You also want to work on the prototype of an object most often,
* as you'd otherwise leave traces (e.g. showing up in Object.getOwnPropertyNames(obj)).
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
*
* @example
* replaceProperty(WebGLRenderingContext.prototype, 'getParameter', { value: "alice" })
* // or
* replaceProperty(Object.getPrototypeOf(navigator), 'languages', { get: () => ['en-US', 'en'] })
*
* @param {object} obj - The object which has the property to replace
* @param {string} propName - The property name to replace
* @param {object} descriptorOverrides - e.g. { value: "alice" }
*/
utils.replaceProperty = (obj, propName, descriptorOverrides = {}) => {
return Object.defineProperty(obj, propName, {
// Copy over the existing descriptors (writable, enumerable, configurable, etc)
...(Object.getOwnPropertyDescriptor(obj, propName) || {}),
// Add our overrides (e.g. value, get())
...descriptorOverrides,
});
};
/**
* Preload a cache of function copies and data.
*
* For a determined enough observer it would be possible to overwrite and sniff usage of functions
* we use in our internal Proxies, to combat that we use a cached copy of those functions.
*
* This is evaluated once per execution context (e.g. window)
*/
utils.preloadCache = () => {
if (utils.cache) {
return;
}
utils.cache = {
// Used in our proxies
Reflect: {
get: Reflect.get.bind(Reflect),
apply: Reflect.apply.bind(Reflect),
},
// Used in `makeNativeString`
nativeToStringStr: Function.toString.toString(), // => `function toString() { [native code] }`
};
};
/**
* Utility function to generate a cross-browser `toString` result representing native code.
*
* There's small differences: Chromium uses a single line, whereas FF & Webkit uses multiline strings.
* To future-proof this we use an existing native toString result as the basis.
*
* The only advantage we have over the other team is that our JS runs first, hence we cache the result
* of the native toString result once, so they cannot spoof it afterwards and reveal that we're using it.
*
* Note: Whenever we add a `Function.prototype.toString` proxy we should preload the cache before,
* by executing `utils.preloadCache()` before the proxy is applied (so we don't cause recursive lookups).
*
* @example
* makeNativeString('foobar') // => `function foobar() { [native code] }`
*
* @param {string} [name] - Optional function name
*/
utils.makeNativeString = (name = "") => {
// Cache (per-window) the original native toString or use that if available
utils.preloadCache();
return utils.cache.nativeToStringStr.replace("toString", name || "");
};
/**
* Helper function to modify the `toString()` result of the provided object.
*
* Note: Use `utils.redirectToString` instead when possible.
*
* There's a quirk in JS Proxies that will cause the `toString()` result to differ from the vanilla Object.
* If no string is provided we will generate a `[native code]` thing based on the name of the property object.
*
* @example
* patchToString(WebGLRenderingContext.prototype.getParameter, 'function getParameter() { [native code] }')
*
* @param {object} obj - The object for which to modify the `toString()` representation
* @param {string} str - Optional string used as a return value
*/
utils.patchToString = (obj, str = "") => {
utils.preloadCache();
const toStringProxy = new Proxy(Function.prototype.toString, {
apply: function (target, ctx) {
// This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""`
if (ctx === Function.prototype.toString) {
return utils.makeNativeString("toString");
}
// `toString` targeted at our proxied Object detected
if (ctx === obj) {
// We either return the optional string verbatim or derive the most desired result automatically
return str || utils.makeNativeString(obj.name);
}
// Check if the toString protype of the context is the same as the global prototype,
// if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case
const hasSameProto = Object.getPrototypeOf(Function.prototype.toString).isPrototypeOf(ctx.toString); // eslint-disable-line no-prototype-builtins
if (!hasSameProto) {
// Pass the call on to the local Function.prototype.toString instead
return ctx.toString();
}
return target.call(ctx);
},
});
utils.replaceProperty(Function.prototype, "toString", {
value: toStringProxy,
});
};
/**
* Make all nested functions of an object native.
*
* @param {object} obj
*/
utils.patchToStringNested = (obj = {}) => {
return utils.execRecursively(obj, ["function"], utils.patchToString);
};
/**
* Redirect toString requests from one object to another.
*
* @param {object} proxyObj - The object that toString will be called on
* @param {object} originalObj - The object which toString result we wan to return
*/
utils.redirectToString = (proxyObj, originalObj) => {
utils.preloadCache();
const toStringProxy = new Proxy(Function.prototype.toString, {
apply: function (target, ctx) {
// This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""`
if (ctx === Function.prototype.toString) {
return utils.makeNativeString("toString");
}
// `toString` targeted at our proxied Object detected
if (ctx === proxyObj) {
const fallback = () =>
originalObj && originalObj.name
? utils.makeNativeString(originalObj.name)
: utils.makeNativeString(proxyObj.name);
// Return the toString representation of our original object if possible
return originalObj + "" || fallback();
}
// Check if the toString protype of the context is the same as the global prototype,
// if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case
const hasSameProto = Object.getPrototypeOf(Function.prototype.toString).isPrototypeOf(ctx.toString); // eslint-disable-line no-prototype-builtins
if (!hasSameProto) {
// Pass the call on to the local Function.prototype.toString instead
return ctx.toString();
}
return target.call(ctx);
},
});
utils.replaceProperty(Function.prototype, "toString", {
value: toStringProxy,
});
};
/**
* All-in-one method to replace a property with a JS Proxy using the provided Proxy handler with traps.
*
* Will stealthify these aspects (strip error stack traces, redirect toString, etc).
* Note: This is meant to modify native Browser APIs and works best with prototype objects.
*
* @example
* replaceWithProxy(WebGLRenderingContext.prototype, 'getParameter', proxyHandler)
*
* @param {object} obj - The object which has the property to replace
* @param {string} propName - The name of the property to replace
* @param {object} handler - The JS Proxy handler to use
*/
utils.replaceWithProxy = (obj, propName, handler) => {
utils.preloadCache();
const originalObj = obj[propName];
const proxyObj = new Proxy(obj[propName], utils.stripProxyFromErrors(handler));
utils.replaceProperty(obj, propName, { value: proxyObj });
utils.redirectToString(proxyObj, originalObj);
return true;
};
/**
* All-in-one method to mock a non-existing property with a JS Proxy using the provided Proxy handler with traps.
*
* Will stealthify these aspects (strip error stack traces, redirect toString, etc).
*
* @example
* mockWithProxy(chrome.runtime, 'sendMessage', function sendMessage() {}, proxyHandler)
*
* @param {object} obj - The object which has the property to replace
* @param {string} propName - The name of the property to replace or create
* @param {object} pseudoTarget - The JS Proxy target to use as a basis
* @param {object} handler - The JS Proxy handler to use
*/
utils.mockWithProxy = (obj, propName, pseudoTarget, handler) => {
utils.preloadCache();
const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler));
utils.replaceProperty(obj, propName, { value: proxyObj });
utils.patchToString(proxyObj);
return true;
};
/**
* All-in-one method to create a new JS Proxy with stealth tweaks.
*
* This is meant to be used whenever we need a JS Proxy but don't want to replace or mock an existing known property.
*
* Will stealthify certain aspects of the Proxy (strip error stack traces, redirect toString, etc).
*
* @example
* createProxy(navigator.mimeTypes.__proto__.namedItem, proxyHandler) // => Proxy
*
* @param {object} pseudoTarget - The JS Proxy target to use as a basis
* @param {object} handler - The JS Proxy handler to use
*/
utils.createProxy = (pseudoTarget, handler) => {
utils.preloadCache();
const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler));
utils.patchToString(proxyObj);
return proxyObj;
};
/**
* Helper function to split a full path to an Object into the first part and property.
*
* @example
* splitObjPath(`HTMLMediaElement.prototype.canPlayType`)
* // => {objName: "HTMLMediaElement.prototype", propName: "canPlayType"}
*
* @param {string} objPath - The full path to an object as dot notation string
*/
utils.splitObjPath = (objPath) => ({
// Remove last dot entry (property) ==> `HTMLMediaElement.prototype`
objName: objPath.split(".").slice(0, -1).join("."),
// Extract last dot entry ==> `canPlayType`
propName: objPath.split(".").slice(-1)[0],
});
/**
* Convenience method to replace a property with a JS Proxy using the provided objPath.
*
* Supports a full path (dot notation) to the object as string here, in case that makes it easier.
*
* @example
* replaceObjPathWithProxy('WebGLRenderingContext.prototype.getParameter', proxyHandler)
*
* @param {string} objPath - The full path to an object (dot notation string) to replace
* @param {object} handler - The JS Proxy handler to use
*/
utils.replaceObjPathWithProxy = (objPath, handler) => {
const { objName, propName } = utils.splitObjPath(objPath);
const obj = eval(objName); // eslint-disable-line no-eval
return utils.replaceWithProxy(obj, propName, handler);
};
/**
* Traverse nested properties of an object recursively and apply the given function on a whitelist of value types.
*
* @param {object} obj
* @param {array} typeFilter - e.g. `['function']`
* @param {Function} fn - e.g. `utils.patchToString`
*/
utils.execRecursively = (obj = {}, typeFilter = [], fn) => {
function recurse(obj) {
for (const key in obj) {
if (obj[key] === undefined) {
continue;
}
if (obj[key] && typeof obj[key] === "object") {
recurse(obj[key]);
} else {
if (obj[key] && typeFilter.includes(typeof obj[key])) {
fn.call(this, obj[key]);
}
}
}
}
recurse(obj);
return obj;
};
/**
* Everything we run through e.g. `page.evaluate` runs in the browser context, not the NodeJS one.
* That means we cannot just use reference variables and functions from outside code, we need to pass everything as a parameter.
*
* Unfortunately, the data we can pass is only allowed to be of primitive types, regular functions don't survive the built-in serialization process.
* This utility function will take an object with functions and stringify them, so we can pass them down unharmed as strings.
*
* We use this to pass down our utility functions as well as any other functions (to be able to split up code better).
*
* @see utils.materializeFns
*
* @param {object} fnObj - An object containing functions as properties
*/
utils.stringifyFns = (fnObj = { hello: () => "world" }) => {
// Object.fromEntries() polyfill (in 6 lines) - supported only in Node v12+, modern browsers are fine
// https://github.com/feross/fromentries
return (Object.fromEntries || fromEntries)(
Object.entries(fnObj)
.filter(([key, value]) => typeof value === "function")
.map(([key, value]) => [key, value.toString()]) // eslint-disable-line no-eval
);
};
/**
* Utility function to reverse the process of `utils.stringifyFns`.
* Will materialize an object with stringified functions (supports classic and fat arrow functions).
*
* @param {object} fnStrObj - An object containing stringified functions as properties
*/
utils.materializeFns = (fnStrObj = { hello: "() => 'world'" }) => {
return Object.fromEntries(
Object.entries(fnStrObj).map(([key, value]) => {
if (value.startsWith("function")) {
// some trickery is necessary to make oldschool functions work :-)
return [key, eval(`() => ${value}`)()]; // eslint-disable-line no-eval
} else {
// arrow functions just work
return [key, eval(value)]; // eslint-disable-line no-eval
}
})
);
};
utils.arrayEqual = (arr1, arr2) => arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index]);
const log = (...args) => opts.script_logging && console.log("[playwright-stealth]:", ...args);
const warn = (...args) => opts.script_logging && console.warn("[playwright-stealth]:", ...args);
log(JSON.stringify(opts));
@@ -0,0 +1,587 @@
# -*- coding: utf-8 -*-
import inspect
import json
import random
import re
import warnings
from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
from typing import Dict, List, Union, Any, Tuple, Optional
from playwright import async_api, sync_api
from playwright_stealth.case_insensitive_dict import CaseInsensitiveDict
from playwright_stealth.context_managers import (
AsyncWrappingContextManager,
SyncWrappingContextManager,
)
def from_file(name) -> str:
return (Path(__file__).parent / "js" / name).read_text()
SCRIPTS: Dict[str, str] = {
"generate_magic_arrays": from_file("generate.magic.arrays.js"),
"utils": from_file("utils.js"),
"chrome_app": from_file("evasions/chrome.app.js"),
"chrome_csi": from_file("evasions/chrome.csi.js"),
"chrome_hairline": from_file("evasions/chrome.hairline.js"),
"chrome_load_times": from_file("evasions/chrome.load.times.js"),
"chrome_runtime": from_file("evasions/chrome.runtime.js"),
"iframe_content_window": from_file("evasions/iframe.contentWindow.js"),
"media_codecs": from_file("evasions/media.codecs.js"),
"navigator_hardware_concurrency": from_file("evasions/navigator.hardwareConcurrency.js"),
"navigator_languages": from_file("evasions/navigator.languages.js"),
"navigator_permissions": from_file("evasions/navigator.permissions.js"),
"navigator_platform": from_file("evasions/navigator.platform.js"),
"navigator_plugins": from_file("evasions/navigator.plugins.js"),
"navigator_user_agent": from_file("evasions/navigator.userAgent.js"),
"navigator_user_agent_data": from_file("evasions/navigator.userAgentData.js"),
"navigator_vendor": from_file("evasions/navigator.vendor.js"),
"navigator_webdriver": from_file("evasions/navigator.webdriver.js"),
"error_prototype": from_file("evasions/error.prototype.js"),
"webgl_vendor": from_file("evasions/webgl.vendor.js"),
}
class Stealth:
"""
Playwright stealth configuration that applies stealth strategies to Playwright.
The stealth strategies are contained in ./js package and are basic javascript scripts that are executed
on every page.goto() called.
Note:
All init scripts are combined by playwright into one script and then executed this means
the scripts should not have conflicting constants/variables etc. !
This also means scripts can be extended by overriding enabled_scripts generator:
```
@property
def enabled_scripts():
yield 'console.log("first script")'
yield from super().enabled_scripts()
yield 'console.log("last script")'
```
"""
_USER_AGENT_OVERRIDE_PIGGYBACK_KEY = "_stealth_user_agent"
_SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY = "_stealth_sec_ch_ua"
_STEALTH_APPLIED_KEY = "_playwright_stealth_applied"
def __init__(
self,
*,
chrome_app: bool = True,
chrome_csi: bool = True,
chrome_load_times: bool = True,
chrome_runtime: bool = False,
hairline: bool = True,
iframe_content_window: bool = True,
media_codecs: bool = True,
navigator_hardware_concurrency: bool = True,
navigator_languages: bool = True,
navigator_permissions: bool = True,
navigator_platform: bool = True,
navigator_plugins: bool = True,
navigator_user_agent: bool = True,
navigator_user_agent_data: bool = True,
navigator_vendor: bool = True,
navigator_webdriver: bool = True,
error_prototype: bool = True,
sec_ch_ua: bool = True,
webgl_vendor: bool = True,
navigator_languages_override: Tuple[str, str] = ("en-US", "en"),
navigator_platform_override: str = "Win32",
navigator_user_agent_override: Optional[str] = None,
navigator_vendor_override: str = None,
sec_ch_ua_override: Optional[str] = None,
webgl_renderer_override: str = None,
webgl_vendor_override: str = None,
init_scripts_only: bool = False,
script_logging: bool = False,
):
# scripts to load
self.chrome_app: bool = chrome_app
self.chrome_csi: bool = chrome_csi
self.chrome_load_times: bool = chrome_load_times
self.chrome_runtime: bool = chrome_runtime
self.hairline: bool = hairline
self.iframe_content_window: bool = iframe_content_window
self.media_codecs: bool = media_codecs
self.navigator_hardware_concurrency: int = navigator_hardware_concurrency
self.navigator_languages: bool = navigator_languages
self.navigator_permissions: bool = navigator_permissions
self.navigator_platform: bool = navigator_platform
self.navigator_plugins: bool = navigator_plugins
self.navigator_user_agent: bool = navigator_user_agent
self.navigator_user_agent_data: bool = navigator_user_agent_data
self.navigator_vendor: bool = navigator_vendor
self.navigator_webdriver: bool = navigator_webdriver
self.error_prototype: bool = error_prototype
self.sec_ch_ua: bool = sec_ch_ua
self.webgl_vendor: bool = webgl_vendor
# warn if an override was provided for a disabled option
self._check_for_disabled_options_overridden(locals())
# evasion options
self.navigator_languages_override: Tuple[str, str] = navigator_languages_override or ("en-US", "en")
self.navigator_platform_override: Optional[str] = navigator_platform_override
self.navigator_user_agent_override: Optional[str] = navigator_user_agent_override
self.navigator_vendor_override: str = navigator_vendor_override or None
if sec_ch_ua_override is None and self.navigator_user_agent_override is not None:
# we can get sec_ch_ua override for "free" here if we can parse the Chrome version string
self.sec_ch_ua_override = self._get_greased_chrome_sec_ua_ch(self.navigator_user_agent_override)
else:
self.sec_ch_ua_override: Optional[str] = sec_ch_ua_override
self.webgl_renderer_override: str = webgl_renderer_override or "Intel Iris OpenGL Engine"
self.webgl_vendor_override: str = webgl_vendor_override or "Intel Inc."
# other options
self.init_scripts_only: bool = init_scripts_only
self.script_logging = script_logging
@property
def script_payload(self) -> str:
"""
Generates an immediately invoked function expression for all enabled scripts
Returns: string of enabled scripts in IIFE
"""
scripts_block = "\n".join(self.enabled_scripts)
if len(scripts_block) == 0:
return ""
return "(() => {\n" + scripts_block + "\n})();"
@property
def options_payload(self) -> str:
opts = {
"navigator_hardware_concurrency": self.navigator_hardware_concurrency,
"navigator_languages_override": self.navigator_languages_override,
"navigator_platform": self.navigator_platform_override,
"navigator_user_agent": self.navigator_user_agent_override,
"navigator_vendor": self.navigator_vendor_override,
"webgl_renderer": self.webgl_renderer_override,
"webgl_vendor": self.webgl_vendor_override,
"script_logging": self.script_logging,
}
return f"const opts = {json.dumps(opts)};"
@property
def enabled_scripts(self):
evasion_script_block = "\n".join(self._evasion_scripts)
if len(evasion_script_block) == 0:
return ""
yield self.options_payload
yield SCRIPTS["utils"]
yield SCRIPTS["generate_magic_arrays"]
yield evasion_script_block
@property
def _evasion_scripts(self) -> str:
if self.chrome_app:
yield SCRIPTS["chrome_app"]
if self.chrome_csi:
yield SCRIPTS["chrome_csi"]
if self.hairline:
yield SCRIPTS["chrome_hairline"]
if self.chrome_load_times:
yield SCRIPTS["chrome_load_times"]
if self.chrome_runtime:
yield SCRIPTS["chrome_runtime"]
if self.iframe_content_window:
yield SCRIPTS["iframe_content_window"]
if self.media_codecs:
yield SCRIPTS["media_codecs"]
if self.navigator_languages:
yield SCRIPTS["navigator_languages"]
if self.navigator_permissions:
yield SCRIPTS["navigator_permissions"]
if self.navigator_platform:
yield SCRIPTS["navigator_platform"]
if self.navigator_plugins:
yield SCRIPTS["navigator_plugins"]
if self.navigator_user_agent:
yield SCRIPTS["navigator_user_agent"]
if self.navigator_user_agent_data:
yield SCRIPTS["navigator_user_agent_data"]
if self.navigator_vendor:
yield SCRIPTS["navigator_vendor"]
if self.navigator_webdriver:
yield SCRIPTS["navigator_webdriver"]
if self.error_prototype:
yield SCRIPTS["error_prototype"]
if self.webgl_vendor:
yield SCRIPTS["webgl_vendor"]
def warn_if_stealth_applied(self, obj: Any) -> bool:
if hasattr(obj, self._STEALTH_APPLIED_KEY):
warnings.warn(
"Stealth has already been applied to this page or context. Skipping duplicate application.",
category=UserWarning,
)
return True
return False
def use_async(self, ctx: async_api.PlaywrightContextManager) -> AsyncWrappingContextManager:
"""
Instruments the playwright context manager.
Any browser connected to or any page created with any method from
the patched context should have stealth evasions applied automatically.
async with Stealth().use_async(async_playwright()) as p:
...
"""
return AsyncWrappingContextManager(self, ctx)
def use_sync(self, ctx: sync_api.PlaywrightContextManager) -> SyncWrappingContextManager:
"""
Instruments the playwright context manager.
Any browser connected to or any page created with any method from
the patched context should have stealth evasions applied automatically.
with Stealth().use_sync(sync_playwright()) as p:
...
"""
return SyncWrappingContextManager(self, ctx)
async def apply_stealth_async(self, page_or_context: Union[async_api.Page, async_api.BrowserContext]) -> None:
if len(self.script_payload) > 0 and not self.warn_if_stealth_applied(page_or_context):
await page_or_context.add_init_script(self.script_payload)
setattr(page_or_context, self._STEALTH_APPLIED_KEY, True)
def apply_stealth_sync(self, page_or_context: Union[sync_api.Page, sync_api.BrowserContext]) -> None:
if len(self.script_payload) > 0 and not self.warn_if_stealth_applied(page_or_context):
page_or_context.add_init_script(self.script_payload)
setattr(page_or_context, self._STEALTH_APPLIED_KEY, True)
def hook_playwright_context(self, ctx: Union[async_api.Playwright, sync_api.Playwright]) -> None:
"""
Given a Playwright context object, hooks all the browser type object methods that return a Browser object.
Can be used with sync and async methods contexts
"""
browser_class_name = sync_api.Browser.__name__
for browser_type in (ctx.chromium, ctx.firefox, ctx.webkit):
chromium_mode = browser_type.name == "chromium"
for name, hooked_method in inspect.getmembers(browser_type, predicate=inspect.ismethod):
# todo: ctx.browser.launch_persistent_context
if hooked_method.__annotations__.get("return") == browser_class_name:
hooked_method = self._generate_hooked_method_that_returns_browser(hooked_method, chromium_mode)
setattr(browser_type, name, hooked_method)
def _kwargs_with_patched_cli_arg(
self, method: Callable, packed_kwargs: Dict[str, Any], chromium_mode: bool
) -> Dict[str, Any]:
signature = inspect.signature(method).parameters
args_parameter = signature.get("args")
# deep just in case
new_kwargs = deepcopy(packed_kwargs)
if args_parameter is not None:
if chromium_mode and not self.init_scripts_only:
new_cli_args = new_kwargs.get("args", args_parameter.default)
if self.navigator_webdriver:
new_cli_args = self._patch_blink_features_cli_args(new_cli_args or [])
if self.navigator_languages:
languages_cli_flag = f"--accept-lang={','.join(self.navigator_languages_override)}"
new_cli_args = self._patch_cli_arg(new_cli_args or [], languages_cli_flag)
new_kwargs["args"] = new_cli_args
return new_kwargs
def _generate_hooked_method_that_returns_browser(self, method: Callable, chromium_mode: bool):
async def async_hooked_method(*args, **kwargs) -> async_api.Browser:
browser = await method(
*args,
**self._kwargs_with_patched_cli_arg(method, kwargs, chromium_mode),
)
self._reassign_new_page_new_context(browser)
return browser
def sync_hooked_method(*args, **kwargs) -> sync_api.Browser:
browser = method(
*args,
**self._kwargs_with_patched_cli_arg(method, kwargs, chromium_mode),
)
self._reassign_new_page_new_context(browser)
return browser
if inspect.iscoroutinefunction(method):
return async_hooked_method
return sync_hooked_method
def _generate_hooked_new_context(self, new_context_method: Callable, new_page_method: Callable) -> Callable:
async def hooked_new_context_async(*args, **kwargs):
context = await new_context_method(
*args,
**(await self._kwargs_new_page_context_with_patches_async(new_page_method, kwargs)),
)
await self.apply_stealth_async(context)
return context
def hooked_browser_method_sync(*args, **kwargs):
context = new_context_method(
*args,
**(self._kwargs_new_page_context_with_patches_sync(new_page_method, kwargs)),
)
self.apply_stealth_sync(context)
return context
if inspect.iscoroutinefunction(new_context_method):
return hooked_new_context_async
return hooked_browser_method_sync
def _generate_hooked_new_page(self, new_page_method: Callable, patch_kwargs: bool) -> Callable:
"""
Returns a hooked method (async or sync) for new_page.
*args and **kwargs even though these methods may not take any number of arguments,
we want to preserve accurate stack traces when caller passes args improperly.
If patch_kwargs is true, we patch kwargs the caller passes to enhance evasions. This only applies
to Browser.new_page, so we pass false when passing in a BrowserContext.new_page method
"""
async def hooked_new_page_async(*args, **kwargs):
kwargs = (
await self._kwargs_new_page_context_with_patches_async(new_page_method, kwargs)
if patch_kwargs
else kwargs
)
page = await new_page_method(*args, **kwargs)
await self.apply_stealth_async(page)
return page
def hooked_new_page_sync(*args, **kwargs):
kwargs = (
self._kwargs_new_page_context_with_patches_sync(new_page_method, kwargs) if patch_kwargs else kwargs
)
page = new_page_method(*args, **kwargs)
self.apply_stealth_sync(page)
return page
if inspect.iscoroutinefunction(new_page_method):
return hooked_new_page_async
return hooked_new_page_sync
async def _kwargs_new_page_context_with_patches_async(
self, unpatched_new_page: Callable, packed_kwargs: Dict[str, Any]
) -> Dict[str, Any]:
"""
This returns kwargs with arguments added based on enabled evasions, while respecting any kwargs the caller
has passed in. If enabled and overrides aren't set for either navigator_user_agent or sec_ch_ua,
and we're dealing with a Chromium browser, we create a temporary page to get up-to-date UA information.
This function is suitable for patching kwargs for new_context and new_page, though in either case, an unpatched
new_page method must be provided.
Args:
unpatched_new_page: new_page
packed_kwargs: kwargs the caller has passed in
Returns:
patched kwargs
"""
browser_or_context = unpatched_new_page.__self__
if isinstance(browser_or_context, async_api.BrowserContext):
browser_instance = browser_or_context.browser
else:
browser_instance = browser_or_context
is_chromium = browser_instance.browser_type.name == "chromium"
async def get_user_agent_and_sec_ch_ua_async() -> Tuple[str, str]:
temp_page: Optional[async_api.Page]
stealth_user_agent = getattr(browser_instance, self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY, None)
sec_ch_ua = getattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, None)
if stealth_user_agent is None or sec_ch_ua is None:
temp_page = await unpatched_new_page()
stealth_user_agent = (await temp_page.evaluate("navigator.userAgent")).replace(
"HeadlessChrome", "Chrome"
)
await temp_page.close(reason="playwright_stealth internal temp utility page")
sec_ch_ua = self._get_greased_chrome_sec_ua_ch(stealth_user_agent)
setattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, sec_ch_ua)
setattr(
browser_instance,
self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY,
stealth_user_agent,
)
return stealth_user_agent, sec_ch_ua
new_kwargs = deepcopy(packed_kwargs)
if self.navigator_user_agent and packed_kwargs.get("user_agent") is None:
resolved_user_agent_override = self.navigator_user_agent_override
if resolved_user_agent_override is None and is_chromium:
(
resolved_user_agent_override,
_,
) = await get_user_agent_and_sec_ch_ua_async()
new_kwargs["user_agent"] = resolved_user_agent_override
extra_http_headers = packed_kwargs.get("extra_http_headers", {})
if self.sec_ch_ua and CaseInsensitiveDict(extra_http_headers).get("sec-ch-ua") is None:
resolved_sec_ch_ua_override = self.sec_ch_ua_override
if resolved_sec_ch_ua_override is None and is_chromium:
(
_,
resolved_sec_ch_ua_override,
) = await get_user_agent_and_sec_ch_ua_async()
if resolved_sec_ch_ua_override is not None:
extra_http_headers["sec-ch-ua"] = resolved_sec_ch_ua_override
new_kwargs["extra_http_headers"] = extra_http_headers
return new_kwargs
def _kwargs_new_page_context_with_patches_sync(
self, unpatched_new_page: Callable, packed_kwargs: Dict[str, Any]
) -> Dict[str, Any]:
"""see self._kwargs_new_page_context_with_patches_async for docs."""
browser_or_context = unpatched_new_page.__self__
if isinstance(browser_or_context, sync_api.BrowserContext):
browser_instance = browser_or_context.browser
else:
browser_instance = browser_or_context
is_chromium = browser_instance.browser_type.name == "chromium"
def get_user_agent_and_sec_ch_ua_sync() -> Tuple[str, str]:
temp_page: Optional[sync_api.Page]
stealth_user_agent = getattr(browser_instance, self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY, None)
sec_ch_ua = getattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, None)
if stealth_user_agent is None or sec_ch_ua is None:
temp_page = unpatched_new_page()
stealth_user_agent = temp_page.evaluate("navigator.userAgent").replace("HeadlessChrome", "Chrome")
sec_ch_ua = self._get_greased_chrome_sec_ua_ch(stealth_user_agent)
temp_page.close(reason="playwright_stealth internal temp utility page")
setattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, sec_ch_ua)
setattr(
browser_instance,
self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY,
stealth_user_agent,
)
return stealth_user_agent, sec_ch_ua
new_kwargs = deepcopy(packed_kwargs)
if self.navigator_user_agent and packed_kwargs.get("user_agent") is None:
resolved_user_agent_override = self.navigator_user_agent_override
if resolved_user_agent_override is None and is_chromium:
resolved_user_agent_override, _ = get_user_agent_and_sec_ch_ua_sync()
new_kwargs["user_agent"] = resolved_user_agent_override
extra_http_headers = packed_kwargs.get("extra_http_headers", {})
if self.sec_ch_ua and CaseInsensitiveDict(extra_http_headers).get("sec-ch-ua") is None:
resolved_sec_ch_ua_override = self.sec_ch_ua_override
if resolved_sec_ch_ua_override is None and is_chromium:
_, resolved_sec_ch_ua_override = get_user_agent_and_sec_ch_ua_sync()
if resolved_sec_ch_ua_override is not None:
extra_http_headers["sec-ch-ua"] = resolved_sec_ch_ua_override
new_kwargs["extra_http_headers"] = extra_http_headers
return new_kwargs
def _reassign_new_page_new_context(self, browser: Union[async_api.Browser, sync_api.Browser]) -> None:
if isinstance(browser, (async_api.Browser, sync_api.Browser)):
browser.new_context = self._generate_hooked_new_context(browser.new_context, browser.new_page)
browser.new_page = self._generate_hooked_new_page(browser.new_page, patch_kwargs=True)
else:
raise TypeError(f"unexpected type from function (bug): returned {browser}")
@staticmethod
def _get_greased_chrome_sec_ua_ch(user_agent: str) -> Optional[str]:
"""
From the major version in user_agent, generate a Sec-CH-UA header value. An example of the data in this
header can be generated from navigator.userAgentData.brands (requires secure context). We could query that
ourselves, but since it requires a secure context, there's no performant way to do that, so instead we
re-implement the greasing algorithm from Chrome.
See Also:
https://wicg.github.io/ua-client-hints/#grease
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc
Args:
user_agent: Chrome UA
Returns:
greased Sec-CH-UA header value, None if the Chrome version cannot be parsed
"""
greased_versions = [8, 99, 24]
greasy_chars = " ():-./;=?_"
greasy_brand = f"Not{random.choice(greasy_chars)}A{random.choice(greasy_chars)}Brand"
version = re.search(r"Chrome/(\d+)[\d.]+", user_agent, re.IGNORECASE)
if len(version.groups()) == 0:
return None
major_version = version.group(1)
brands = [
("Chromium", major_version),
("Chrome", major_version),
(greasy_brand, random.choice(greased_versions)),
]
random.shuffle(brands)
return ", ".join(f'"{brand}";v="{version}"' for brand, version in brands)
@staticmethod
def _patch_blink_features_cli_args(existing_args: Optional[List[str]]) -> List[str]:
"""Patches CLI args list to disable AutomationControlled blink feature, while preserving other args"""
new_args = []
disable_blink_features_prefix = "--disable-blink-features="
automation_controlled_feature_name = "AutomationControlled"
for arg in existing_args or []:
stripped_arg = arg.strip()
if stripped_arg.startswith(disable_blink_features_prefix):
if automation_controlled_feature_name not in stripped_arg:
stripped_arg += f",{automation_controlled_feature_name}"
new_args.append(stripped_arg)
else:
new_args.append(arg)
else: # no break
# the user has specified no extra blink features disabled,
# so no need to be careful how we modify the command line
new_args.append(f"{disable_blink_features_prefix}{automation_controlled_feature_name}")
return new_args
@staticmethod
def _patch_cli_arg(existing_args: List[str], flag: str) -> List[str]:
"""Patches CLI args list with any arg, warns if the user passed their own value in themselves"""
new_args = []
switch_name = re.search("(.*)=?", flag).group(1)
for arg in existing_args:
stripped_arg = arg.strip()
if stripped_arg.startswith(switch_name):
warnings.warn(
"playwright-stealth is trying to modify a flag you have set yourself already."
f"Either disable the mitigation or don't specify this flag manually {flag=}"
f"to avoid this warning. playwright-stealth has overridden your flag",
category=UserWarning,
)
new_args.append(flag)
break
else:
new_args.append(arg)
else: # no break
# none of the existing switches overlap with the one we're trying to set
new_args.append(flag)
return new_args
@staticmethod
def _check_for_disabled_options_overridden(packed_kwargs: Dict[str, Any]) -> None:
for key in ALL_EVASIONS_DISABLED_KWARGS.keys():
if not packed_kwargs.get(key) and packed_kwargs.get(f"{key}_override") is not None:
warnings.warn(
f"{key} is False, but an override ({key}_override) was provided, "
f"which is probably not what you intended to do",
stacklevel=3,
category=UserWarning,
)
ALL_EVASIONS_DISABLED_KWARGS = {
"chrome_app": False,
"chrome_csi": False,
"chrome_load_times": False,
"chrome_runtime": False,
"hairline": False,
"iframe_content_window": False,
"media_codecs": False,
"navigator_hardware_concurrency": False,
"navigator_languages": False,
"navigator_permissions": False,
"navigator_platform": False,
"navigator_plugins": False,
"navigator_user_agent": False,
"navigator_user_agent_data": False,
"navigator_vendor": False,
"navigator_webdriver": False,
"error_prototype": False,
"sec_ch_ua": False,
"webgl_vendor": False,
}