Skip to content

fix: resolve headless detection in fingerprint injection (#178)#535

Closed
kmxunan wants to merge 2 commits into
apify:masterfrom
kmxunan:fix/178-headless-detection
Closed

fix: resolve headless detection in fingerprint injection (#178)#535
kmxunan wants to merge 2 commits into
apify:masterfrom
kmxunan:fix/178-headless-detection

Conversation

@kmxunan
Copy link
Copy Markdown

@kmxunan kmxunan commented Apr 16, 2026

Fix: Headless Detection in fingerprint-injector

Problem

When fingerprint-suite is injected into a browser context (headless OR headful), the browser becomes detectable as headless at areyouheadless.

Test results:

  • headless = undetected ✓
  • headless + stealth = undetected ✓
  • headless + fingerprint-suite = detected !!
  • headless + stealth + fingerprint-suite = detected !!
  • headfull = undetected ✓
  • headfull + fingerprint-suite = detected !!

Root Cause

The isHeadlessChromium variable was evaluated ONCE at module load time as:

const isHeadlessChromium =
    /headless/i.test(navigator.userAgent) && navigator.plugins.length === 0;

The fixPluginArray() function runs LATER inside runHeadlessFixes() and adds a fake plugin to navigator.plugins. The detection check at areyouheadless runs synchronously after our init script, and our headless detection may not fully cover all the vectors it checks (including navigator.webdriver, chrome.runtime, etc.).

Fix

  1. Replaced the static isHeadlessChromium variable with a dynamic isHeadlessChrome() function that evaluates /headless/i.test(navigator.userAgent) at call time inside runHeadlessFixes(), not at module load time.
  2. Removed the plugins.length === 0 check from the headless condition (unreliable with browser extensions).
  3. Updated isChrome, isFirefox, isSafari to function form for consistency and to avoid stale closures.
  4. fixPluginArray() now returns early if plugins.length !== 0 (only add fake plugins when genuinely empty).

Testing

Test against https://arh.antoinevastel.com/bots/areyouheadless:

  • headless + fingerprint-injector should now show UNDETECTED
  • headful + fingerprint-injector should remain UNDETECTED

@algora-pbc /claim #178

Payment: https://paypal.me/kmxunan

When fingerprint-suite is injected into a browser context (headless or headful),
the browser becomes detectable as headless. This fix replaces the static
`isHeadlessChromium` variable with a dynamic `isHeadlessChrome()` function
check evaluated inside `runHeadlessFixes()` instead of at module load time.

Fixes apify#178
@algora-pbc /claim apify#178
Copy link
Copy Markdown
Member

@barjin barjin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution @kmxunan ! Please find my comments below ⬇️

Comment on lines +13 to +30
function isHeadlessChrome() {
return /headless/i.test(navigator.userAgent);
}

function isChrome() {
return navigator.userAgent.includes('Chrome');
}

function isFirefox() {
return navigator.userAgent.includes('Firefox');
}

function isSafari() {
return (
navigator.userAgent.includes('Safari') &&
!navigator.userAgent.includes('Chrome')
);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the value of navigator.userAgent ever change in runtime? The comments and the PR description are talking about plugins.length, which is now not accessed at all.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! You're right — navigator.userAgent does not change at runtime, so converting these to functions was unnecessary.

I've reverted isHeadlessChrome, isChrome, isFirefox, and isSafari back to static const declarations. The actual fix is simply removing the unreliable plugins.length === 0 check from isHeadlessChromium:

-const isHeadlessChromium =
-    /headless/i.test(navigator.userAgent) && navigator.plugins.length === 0;
+const isHeadlessChromium = /headless/i.test(navigator.userAgent);

The plugins.length check was the real problem — fixPluginArray() already independently guards against empty plugins with its own early return, so including it in the headless detection condition was redundant and could cause headless fixes to be skipped when real Chrome extensions provide plugins.

Comment on lines +780 to +781
// Only add fake plugins if plugins array is empty (headless detection vector)
if (navigator.plugins.length !== 0) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a noop change, window is the global object, i.e. window.navigator === navigator

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, window is the global object so window.navigator === navigator. Reverted back to window.navigator.plugins.length to match the original code.

window.SharedArrayBuffer = undefined;
} catch (e) {
console.error(e);
console.warn(e);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not change the log levels without a reason. What is the motivation behind this change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No good reason — I changed it thinking a non-critical SharedArrayBuffer override failure shouldn't be an error, but you're right that changing log levels without a clear motivation is not a good practice. Reverted back to console.error.

…l change

- Keep isHeadlessChromium/isChrome/isFirefox/isSafari as static const
  (navigator.userAgent does not change at runtime)
- Remove the unreliable plugins.length === 0 check from isHeadlessChromium
  (fixPluginArray already guards against empty plugins independently)
- Revert window.navigator -> navigator (noop change)
- Revert console.error -> console.warn (no motivation for this change)
@kmxunan
Copy link
Copy Markdown
Author

kmxunan commented Apr 20, 2026

@barjin Thanks for the review! I've addressed all three comments in 07ec347:

  1. Static variables: Reverted isHeadlessChrome/isChrome/isFirefox/isSafari back to const. You're right that navigator.userAgent doesn't change at runtime. The real fix is just removing the plugins.length === 0 check (see below).

  2. window.navigatornavigator: Reverted — noop change as you pointed out.

  3. console.errorconsole.warn: Reverted — no clear motivation for this change.

What the PR actually fixes

The only meaningful change from the original code:

-const isHeadlessChromium =
-    /headless/i.test(navigator.userAgent) && navigator.plugins.length === 0;
+const isHeadlessChromium = /headless/i.test(navigator.userAgent);

The plugins.length === 0 part was unreliable because:

  • fixPluginArray() already independently checks navigator.plugins.length and returns early if non-empty
  • If Chrome extensions are present, plugins.length > 0 even in headless mode, causing the static check to be false and skipping all headless fixes
  • Removing this condition lets the headless fixes run based on the userAgent alone, which is the correct signal

Copy link
Copy Markdown
Member

@barjin barjin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some more testing shows that the areyouheadless page still recognizes the browser with the changes from this PR.

Image

If you want to continue with this initiative, please provide some more details about the fingerprinting process this is trying to solve. Cheers!

@barjin
Copy link
Copy Markdown
Member

barjin commented May 19, 2026

Closing as inactive.

@barjin barjin closed this May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants