Back to Knowledge Hub
Fingerprint

Browser Patch Plugins: What They Change and Cannot Reach

Read the public evasion list of a popular Puppeteer extra plugin, then see which three surfaces page-JavaScript patches cannot reach and how to prove a patch ran.

BotBrowser Team

Documentation

Want the structured docs for Fingerprint?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

A plugin of this kind is a set of page-JavaScript patches, and the shape of that delivery decides both what it can change and what it cannot. The popular puppeteer-extra-plugin-stealth ships a directory of evasions, each one a small evaluateOnNewDocument script that rewrites one navigator property, one chrome namespace member or one WebGL getter before the page's own code runs. That is a real and inspectable mechanism, and it has a boundary: a patch that runs inside the page can be read from inside the page, and three surfaces never pass through page JavaScript at all. Reading the public source of that plugin is enough to list what it patches, name the three surfaces it cannot reach, and build a fixture that reads value, descriptor and toString side by side, so a reader can tell whether a patch actually applied. The Puppeteer and Playwright plugin variants are separate third-party projects with their own release cycles, so neither is endorsed. A pass rate is not something a local change can promise.

BotBrowser provides a different delivery path for the same document values: profile settings are applied inside the browser core, so a value and the code path that produces it come from one layer, which the automation consistency reference documents. That layer still sits on one side of the boundary described below, so it does not guarantee a third-party verdict, and the network and process surfaces stay outside its reach.

TL;DR

  • The plugin's evasions are page scripts injected before the document's own code, not browser configuration. Each one rewrites a JS-visible value, and a page can read the result back.
  • The public evasion directory contains 19 entries: chrome.app, chrome.csi, chrome.loadTimes, chrome.runtime, defaultArgs, iframe.contentWindow, media.codecs, navigator.hardwareConcurrency, navigator.languages, navigator.permissions, navigator.plugins, navigator.vendor, navigator.webdriver, sourceurl, user-agent-override, webgl.vendor, window.outerdimensions, plus the shared _template and _utils folders.
  • navigator.webdriver is handled twice: the argument layer adds --disable-blink-features=AutomationControlled, and the page script deletes the prototype property on older Chromium builds where it is still present.
  • A patched value and an unpatched value are indistinguishable by console.log. Reading Object.getOwnPropertyDescriptor and Function.prototype.toString is what separates a rewritten property from a native one.
  • Three surfaces never reach page JavaScript: network-layer fingerprints, TLS handshakes and HTTP/2 settings; the browser's own process and command line; and anything a server decides from the connection rather than the document.

A layered diagram shows page JavaScript patches reaching three in-document surfaces, while three other surfaces sit below the JavaScript boundary: the network and TLS layer, the browser process and command line, and the server-side decision.

Contents

What the public evasion list actually contains

The plugin's source is a directory named evasions. Reading it is enough to know what the plugin touches, because each subdirectory is one named evasion with its own index.js.

EvasionTarget surfaceWhat the script changes
navigator.webdriverAutomation flagDeletes the prototype property on older builds; adds a launch argument on newer ones
navigator.vendorNavigator identityRewrites the vendor string
navigator.pluginsPlugin enumerationReplaces the plugin array contents
navigator.languagesLanguage listRewrites the reported language array
navigator.permissionsPermission queryWraps the query result
navigator.hardwareConcurrencyCore countRewrites the reported integer
webgl.vendorWebGL parameterWraps getParameter for the vendor and renderer strings
chrome.app, chrome.csi, chrome.loadTimes, chrome.runtimeChrome namespaceAdds or rewrites members the page can read
media.codecsCodec supportRewrites the codec answer
iframe.contentWindowCross-frame identityAligns the iframe window's reported values
window.outerdimensionsWindow geometryReports the outer size a normal window would have
user-agent-overrideUser agent header and valueSets the header and the page-visible string
sourceurlScript source URLStrips the sourceURL marker Puppeteer adds to its injected scripts
defaultArgsLaunch argumentsDrops Puppeteer defaults that make the browser easier to identify

Two structural facts matter more than the individual rows. First, every row is a rewrite of a value that a page can read, which means the rewrite is also a value a page can inspect. Second, the list is finite and public, so a detector author can read the same directory and probe exactly these surfaces.

The delivery mechanism is the binding constraint

evaluateOnNewDocument runs a function before any page script executes. That timing is what makes the patch invisible to a naive read: by the time the page's code runs, the property already has the rewritten value. But the patch does not change the browser; it changes what the document sees after the browser has produced a value.

That distinction explains the whole class of failure. A server that decides from the TLS handshake never runs the page script, so no amount of evaluateOnNewDocument reaches it. A CDP client that inspects the target's command line reads the process arguments, not the document, so a page-level rewrite is irrelevant there too.

The two plugin families do not share code

puppeteer-extra and playwright-extra are separate packages in the same repository, each with its own plugin interface. A plugin written against one does not load in the other without a shim, and the two packages update their injection points on different schedules. That matters for a comparison: an evasion list read from the Puppeteer package describes what that package patches in the Puppeteer launch path, and the Playwright variant is a different artifact with its own list.

The practical consequence is that a plugin claim needs a family and a version before it means anything. Two teams can both say their browser runs a plugin of that name and be running different code with different coverage, and the difference is not visible from the outside.

What the webdriver evasion actually does

The navigator.webdriver evasion has two halves, and reading both is instructive. The beforeLaunch hook modifies the launch arguments: if --disable-blink-features= is already present it appends AutomationControlled, and otherwise it adds the whole flag. The onPageCreated hook then runs a page script that checks the current value and deletes Object.getPrototypeOf(navigator).webdriver only on builds where the property is still present and true.

So the plugin is honest about its own boundary: on a Chromium build where the flag already yields false or undefined, the page script does nothing at all. That is a useful design detail for anyone reading a plugin claim, because it shows that even the plugin's own authors treat the launch argument as the primary fix and the page script as a compatibility patch.

What a page-JavaScript patch cannot reach

Three groups of surfaces sit below or beside the document, and no evaluateOnNewDocument script can change them.

The network and TLS layer

The TLS ClientHello, the cipher suite order, the extensions, the ALPN protocol list, the HTTP/2 SETTINGS frame and the header ordering are produced by the browser's network stack before any document exists. A server that fingerprints the connection sees a shape the page script never had access to. Adding or removing a property on navigator does not alter a single byte of that handshake.

The same reasoning covers DNS behaviour and connection reuse. A page-level patch script cannot change which resolver answered, whether a connection was reused, or how long the TCP handshake took, because those events complete before the first line of page code executes.

The browser process and command line

The process tree, the executable path, the arguments in /proc/<pid>/cmdline on Linux and the parent-child relation between a launcher and its browser are all observable from outside the document. A page script can rewrite what the document reports about itself, but it cannot rewrite the argument vector that started the process.

This is also why the plugin modifies args in beforeLaunch rather than trying to hide the flags from the page alone. The flags are visible to whoever can read the process, and the plugin's own comment about the infobar in Chrome acknowledges that the argument has a visible consequence.

The server-side decision

The last group is not a fingerprint at all; it is the model that consumes the readings. A challenge service can combine network shape, request headers, timing, account history and page-reported values into a score, and none of those inputs pass through the page script as a value it can edit. Even a perfect in-document patch leaves the score to the vendor. Which of those readings a given product samples is inventoried in what a detector actually reads, and two products need not sample the same set.

That is the deepest boundary, and it is the one that makes the phrase "undetected browser" a claim about a model rather than a property of a browser.

A fixture that reads value, descriptor and toString

A fixture that only reads navigator.webdriver cannot tell a patched value from a native one, because both return a boolean. Reading three things side by side separates them.

The three readings

The first is the value itself: navigator.webdriver, or navigator.vendor, or a WebGLRenderingContext.getParameter result. That is what a naive detector reads.

The second is the property descriptor: Object.getOwnPropertyDescriptor(Object.getPrototypeOf(navigator), 'webdriver'). A native accessor has a get function whose source text names the built-in getter. A deleted property returns undefined; a redefined one returns a descriptor whose get points at a wrapper, which may or may not carry the same flags as the native accessor.

The third is Function.prototype.toString on that getter. A native accessor's source text carries its own signature and ends in [native code]; on a Chromium build the Navigator.prototype.webdriver getter reads function get webdriver() { [native code] }. A wrapper defined in JavaScript shows its own body. The plugin's own _utils folder contains a withUtils helper that patches toString for this exact reason, which is itself evidence that the reading works. What a whitelisted toString can still reveal is worked through separately in native override lies and a toString whitelist, which lists the checks that survive a rewritten source text.

ReadingNative answersPatched answersWhat it cannot prove
Value readA boolean, a vendor string, a renderer stringThe rewritten valueWhich one produced it
getOwnPropertyDescriptorNative accessor with get and setundefined, or a redefined accessorWhether the page is otherwise consistent
Function.prototype.toString[native code]The wrapper's own sourceWhether the wrapper is convincing

Why the fixture belongs on an owned page

The reading has to run on a document the operator controls, because the point is to compare a known configuration against a known document, not to probe somebody else's site. A local data: URL or a static file on disk is enough: the fixture script reads the three values, prints them as JSON, and stores the result beside the run record.

The comparison is then trivial. If the value differs from what the browser build would report natively, the descriptor tells you whether the property was deleted or replaced, and toString tells you whether the wrapper advertises itself. A patch that leaves a [native code] string where a wrapper lives is a patch that also patched toString, which is exactly the case the plugin's _utils helper exists to handle.

Why the fixture needs a clean baseline

The comparison only works if the fixture has something to compare against. Read the same three values on the same build with the plugin disabled, and store that baseline beside the patched run. Without it, a [native code] string proves nothing, because the reader has no record of what the unpatched accessor looked like on this specific build.

A second baseline is worth keeping: the same reading from a plain, unpatched launch of a stock Chromium of the same major version. That pair shows which differences come from the plugin and which come from the build itself, because a Chromium version bump can change an accessor's descriptor flags without any plugin involved.

Reading a patch that claims to be native

The interesting case is a wrapper whose toString has also been patched to return [native code]. That does not make the reading useless; it changes what the reading proves. A descriptor whose get is not the engine's own getter still differs from the unpatched baseline recorded on the same build, and a wrapper that claims [native code] while the source text read in another realm differs from the unpatched baseline is inconsistent with itself. A cross-realm identity comparison is not the test for this: a fresh iframe's getter is a different function object even on a browser where nothing was patched, so that comparison reports a difference in both cases.

The fixture should therefore record the descriptor flags as well: configurable, enumerable, writable and whether set is present. A native navigator.webdriver is a non-writable accessor with a stable flag set, yet the flags are worth recording without being the discriminator: an evasion that deletes the property leaves no descriptor at all, while the plugin's replaceProperty helper spreads the existing descriptor and swaps only get, so a replaced getter keeps the native flags. That record does not require the page to be honest about toString.

Recording what the fixture saw

Store the three readings plus the browser build identifier, the injected script version, and the document URL, in one JSON object per run. Two runs with the same configuration should produce the same readings; a difference between them means the injection did not apply in one run, which is a configuration defect rather than a detector result.

What the fixture cannot settle

The fixture describes the document. It does not describe the connection, the process or the model. A perfect reading of a patched navigator.webdriver on an owned page says nothing about how a vendor will score the session, and that gap is the reason a per-run record has to name which surface it measured.

Keep that boundary in the record itself. A field labelled surface: document is honest; the same reading labelled verdict: pass is not, because the fixture never ran the vendor's model.

FAQ

Does a page-level patch change the browser's network fingerprint?

No. Its evasions are page scripts injected before the document's code, plus a launch-argument change. TLS ClientHello bytes, HTTP/2 SETTINGS and header ordering are produced by the network stack before any document exists, so a page-level script cannot reach them.

Is a patched navigator.webdriver enough to pass a detector?

No. A detector can read the value, the property descriptor, toString, the launch arguments or the network shape, and a vendor's model decides which it reads. The evasion touches one prototype property through two code paths, while the descriptor's flags, the source text read in another realm and the connection shape remain whatever the launch produced.

How do I tell a patched property from a native one?

Read three things on an owned page: the value, Object.getOwnPropertyDescriptor on the prototype, and Function.prototype.toString on the descriptor's get. Record the descriptor flags for the run, then read the source text inside the iframe realm with that realm's own Function.prototype.toString and compare it against the unpatched baseline recorded on the same build. A wrapper with a patched toString still differs from the baseline once the recorded source text is compared.

Practical conclusion

Start from the evasion list rather than from the label. The public directory names every surface the plugin touches, and every one of them is a value a page can read back. Then accept the three surfaces below the document: the network and TLS layer, the browser process and command line, and the server-side model that consumes all the readings.

The next action is one fixture run on an owned page. Read the value, the descriptor and toString for navigator.webdriver, print them as JSON beside the browser build and the injected script version, and repeat the run once. If both runs match, the patch applied consistently; if they differ, the injection is the thing to investigate, not the detector. Adjacent material: what a detector actually reads covers the surface list from the detector's side, and the BotD detector registry shows which checks one open-source detector runs.

Sources

#Browser Plugin#Puppeteer-Extra#Navigator.webdriver#Function toString#Descriptor

This article is part of the guide: What Is a Browser Fingerprint?

Run the same check in BotBrowser

Load a profile, keep the signals you just read about consistent inside the browser, and confirm the result on your own machine before a rollout.