view release on metacpan or search on metacpan
it. Dies if the typelibs are unavailable or if no X display can be
determined (see "display" below). %options:
"window => [$width, $height]"
Initial window size in pixels. Default "[1280, 1024]".
A "fingerprint" profile overrides this where the two would
contradict each other, since a window larger than the screen it
claims to be on is itself a tell: a mobile profile sizes the window
to the profile's own screen and ignores "window" outright, and a
desktop profile caps each dimension at its screen's.
"display => ':N'"
Sets $ENV{DISPLAY} to this value before initializing GTK. If
omitted, an already-exported $DISPLAY is used; if neither is
available, "new" dies telling you to run under "xvfb-run" or pass
this option -- EV::WebKit never starts an X server itself (see
"LIMITATIONS").
One display per process. GTK connects to a display once and cannot
be moved to another, so every instance after the first shares the
"scheduling" and "RTCPeerConnection" (the Android profile correctly
omits "hid"/"serial"); a Safari profile exposes only "storage" and
"RTCPeerConnection". Every stub is installed only when the build
lacks the real API, so a WebKitGTK that ships one keeps it.
PDF viewer presence follows the profile as well. The HTML
specification hardcodes both states: a browser that displays PDFs
inline reports "navigator.pdfViewerEnabled" true and five fixed
plugin names, one that does not reports false and empty
"plugins"/"mimeTypes" lists. WebKitGTK reports the viewer-present
state, which is correct for desktop Chrome, desktop Safari and iOS
Safari -- but not for "pixel-chrome": Chrome for Android had no
inline PDF viewer at 131 (it shipped 2024-11, the Android viewer
appeared behind a flag in 2024-12 and became default-on only in
Chrome 135, 2025-04), so that profile reports the empty state.
Override per instance with "pdf_viewer => 0|1". The empty lists are
real "PluginArray"/ "MimeTypeArray" objects, cached like a real
browser's, with "length" left on the prototype where it belongs.
Ceiling: the spoof is thorough but not perfect, and these residuals
remain. Workers are not covered at all. The extension hooks
eg/browser.pl view on Meta::CPAN
# perl eg/browser.pl --control /path/to.sock [uri]
# perl eg/browser.pl --control # socket path chosen for you
#
# Give --control a path (attached or as the next word), or give it nothing AND
# no uri after it. `--control https://perl.org` is the one shape that misfires:
# Getopt::Long takes the next word as the path, so it would listen on a socket
# named after the URI and then load the default page. Spell that one
# `--control= https://perl.org`.
#
# Needs a real display -- EV::WebKit never starts an X server, it uses the one
# you give it. Just run it from a desktop session (DISPLAY is already set).
# Under xvfb-run you get the same browser with nobody to look at it.
#
# Type a URI in the address bar and press Enter; back / forward / reload work;
# right-click gives you Inspect Element. Close the window to exit.
#
# With --control it also listens on a unix socket, so another process can drive
# this same window while you watch (and while you click around in it yourself):
#
# perl eg/browser.pl --control /tmp/evwk.sock &
# perl eg/control.pl /tmp/evwk.sock https://perl.org
eg/browser.pl view on Meta::CPAN
use EV;
use EV::WebKit;
$| = 1; # so the running commentary shows up even when piped to a file
my $control;
GetOptions('control:s' => \$control)
or die "usage: browser.pl [--control[=path]] [uri]\n";
die "WebKitGTK 6.0 / GTK4 typelibs not available\n" unless EV::WebKit->available;
die "no \$DISPLAY -- run this from a desktop session (it needs a real screen)\n"
unless defined $ENV{DISPLAY} && length $ENV{DISPLAY};
my $uri = shift // 'https://example.com';
my $b;
$b = EV::WebKit->new(
window => [1100, 800],
chrome => 1, # header bar: back / forward / reload + address entry
title => 'EV::WebKit',
devtools => 1, # right-click -> Inspect Element
lib/EV/WebKit.pm view on Meta::CPAN
pending => undef, # pending nav [cb, timer, gen, target_uri, started_seen, committed_uri, doc_scheme_seen]
_superseded => {}, # uri => 1 -- identities of navs torn down mid-flight by _start_nav; see there and the load-changed/load-failed handlers
_ops => {}, # id => wrapped cb for every in-flight one-shot async op (_call_js/screenshot/pdf/cookie); quit() flushes these with 'browser closed' so none is silently dropped -- see _op_track
_pdf_queue => [], # serialized pdf() jobs [id, path, \%opt, cb] -- one PrintOperation runs at a time (see pdf/_pdf_pump)
_pdf_timers => {}, # id => watchdog for each pdf() job, armed at ENQUEUE so a job waiting behind a slow/stuck print is bounded too
}, $class;
# Keep the viewport geometrically possible: window.innerWidth/Height must not
# exceed the spoofed screen (no real device has that). A mobile fingerprint
# sizes the window TO its screen; any other fingerprint with a screen CAPS the
# window to it (a windowed desktop can be smaller, never larger).
my ($w, $h);
if ($fp && $fp->{mobile} && $fp->{screen}) {
($w, $h) = @{ $fp->{screen} }[0,1];
}
else {
($w, $h) = @{ $o{window} || [1280, 1024] };
if ($fp && $fp->{screen}) {
my ($sw, $sh) = @{ $fp->{screen} }[0,1];
$w = $sw if $w > $sw;
$h = $sh if $h > $sh;
lib/EV/WebKit.pm view on Meta::CPAN
if ($o{on_request} || $o{on_response}) {
eval { Proxy::Impersonate->VERSION('0.01'); 1 }
or Carp::croak("EV::WebKit: $why requires Proxy::Impersonate 0.01 or newer "
. '(found ' . (Proxy::Impersonate->VERSION // '?') . ')');
}
# The preset NAME, which is what curl_target keys on. The hashref form
# carries it in {profile}, and resolve() drops it, so it has to come
# from the caller's own argument. Handing curl_target the ref itself
# looked up its stringified address: under network_fingerprint that
# croaked, and under on_request alone it missed silently and fell back
# to desktop Chrome -- a Safari identity above with a Chrome JA3/JA4
# below. Derived once here so both branches agree.
my $fp_name = ref $o{fingerprint} eq 'HASH'
? $o{fingerprint}{profile}
: $o{fingerprint};
my $target;
if ($o{network_fingerprint}) {
$target = ($o{network_fingerprint} =~ /\D/)
? $o{network_fingerprint} # explicit target override
: EV::WebKit::Fingerprint::curl_target($fp_name); # derive from the profile name
Carp::croak("EV::WebKit: no curl target for fingerprint '"
lib/EV/WebKit.pm view on Meta::CPAN
=over 4
=item C<< window => [$width, $height] >>
Initial window size in pixels. Default C<[1280, 1024]>.
A C<fingerprint> profile overrides this where the two would contradict each
other, since a window larger than the screen it claims to be on is itself a
tell: a mobile profile sizes the window to the profile's own screen and ignores
C<window> outright, and a desktop profile caps each dimension at its screen's.
=item C<< display => ':N' >>
Sets C<$ENV{DISPLAY}> to this value before initializing GTK. If omitted, an
already-exported C<$DISPLAY> is used; if neither is available, C<new> dies
telling you to run under C<xvfb-run> or pass this option -- EV::WebKit
never starts an X server itself (see L</"LIMITATIONS">).
B<One display per process.> GTK connects to a display once and cannot be
moved to another, so every instance after the first shares the first one's
lib/EV/WebKit.pm view on Meta::CPAN
C<navigator.connection>, C<usb>, C<bluetooth>, C<getBattery>, C<scheduling> and
C<RTCPeerConnection> (the Android profile correctly omits C<hid>/C<serial>); a
Safari profile exposes only C<storage> and C<RTCPeerConnection>. Every stub is
installed only when the build lacks the real API, so a WebKitGTK that ships one
keeps it.
B<PDF viewer presence> follows the profile as well. The HTML specification
hardcodes both states: a browser that displays PDFs inline reports
C<navigator.pdfViewerEnabled> true and five fixed plugin names, one that does
not reports false and B<empty> C<plugins>/C<mimeTypes> lists. WebKitGTK reports
the viewer-present state, which is correct for desktop Chrome, desktop Safari
and iOS Safari -- but B<not> for C<pixel-chrome>: Chrome for Android had no
inline PDF viewer at 131 (it shipped 2024-11, the Android viewer appeared behind
a flag in 2024-12 and became default-on only in Chrome 135, 2025-04), so that
profile reports the empty state. Override per instance with
C<< pdf_viewer => 0|1 >>. The empty lists are real C<PluginArray>/
C<MimeTypeArray> objects, cached like a real browser's, with C<length> left on
the prototype where it belongs.
B<Ceiling:> the spoof is thorough but not perfect, and these residuals remain.
B<Workers are not covered at all.> The extension hooks
lib/EV/WebKit/Fingerprint.pm view on Meta::CPAN
# Numbers are plain scalars; screen is [w,h] or [w,h,availW,availH,colorDepth].
my %PRESET = (
'windows-chrome' => {
user_agent => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
platform => 'Win32', vendor => 'Google Inc.', languages => ['en-US','en'],
hardwareConcurrency => 8, deviceMemory => 8, maxTouchPoints => 0,
screen => [1920,1080,1920,1040,24], devicePixelRatio => 1,
webgl_vendor => 'Google Inc. (NVIDIA)',
webgl_renderer => 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)',
webgl => \%WEBGL_ANGLE_NVIDIA,
# Desktop Chrome's interface set; WebHID/Web Serial are desktop-only.
features => [qw(connection storage battery usb bluetooth hid serial scheduling rtc)],
# Chrome-only: drives window.chrome + navigator.userAgentData.
# Brand list, order and GREASE entry are curl-impersonate's chrome150
# template verbatim, so the Sec-CH-UA on the wire and navigator
# .userAgentData cannot disagree. The GREASE brand and its version move
# every release -- 150 uses "Not;A=Brand";v="8", not 131's "Not_A Brand"
# v24 -- so it is not a constant to carry forward.
# 150.0.7871.189 is the last stable 150 build (Chrome version history
# API), not a plausible-looking invention: it is what a site asking for
# high-entropy hints compares against.
lib/EV/WebKit/Fingerprint.pm view on Meta::CPAN
hardwareConcurrency => 'num', deviceMemory => 'num', maxTouchPoints => 'num',
devicePixelRatio => 'num', screen => 'screen',
mozInnerScreenX => 'num', mozInnerScreenY => 'num',
mobile => 'bool', ua_data => 'uadata', webgl => 'webgl', features => 'features',
pdf_viewer => 'bool',
);
sub profiles { return sort keys %PRESET }
# Map each preset to the curl-impersonate target with the matching TLS/HTTP2
# fingerprint. curl ships only macOS desktop Chrome + Android Chrome, but
# Windows/macOS Chrome share an identical ClientHello (JA4 is OS-independent), so
# windows-chrome also uses chrome131 -- the OS lives in override_headers, not the
# TLS. Consumed by EV::WebKit's network_fingerprint wiring.
my %CURL_TARGET = (
'windows-chrome' => 'chrome150',
'macos-safari' => 'safari26_0',
'iphone-safari' => 'safari26_0_ios',
'windows-firefox' => 'firefox147',
# chrome131_android is still the newest Android target upstream ships, so
# this one stays at 131 rather than claiming a Chrome the TLS cannot back.
lib/EV/WebKit/Fingerprint.pm view on Meta::CPAN
$c{chrome} = 1;
$c{ua_data} = { %$u, mobile => ($p->{mobile} ? 1 : 0) };
}
my %media;
if ($p->{mobile}) {
$c{touch} = 1;
$media{pointer} = 'coarse';
$media{hover} = 'none';
}
# Desktop Chrome 131 and Safari 18 both expose screen.orientation, so it must
# not be gated to mobile -- 'orientation' in screen was false on the desktop
# profiles, a one-expression presence probe.
# ... and the type follows the spoofed SCREEN's aspect, not the mobile flag.
# Keying it off mobile made a portrait desktop screen (or a landscape tablet
# profile) report an orientation its own screen.width/height contradict --
# screen.width > screen.height with type 'portrait-primary' is a two-property
# probe. Square counts as landscape, as the engines do.
my ($ow, $oh) = @{ $p->{screen} || [] }[0,1];
my $portrait = (defined $ow && defined $oh) ? ($oh > $ow) : $p->{mobile};
$c{orientation} = $portrait
? { type => 'portrait-primary', angle => 0 }
: { type => 'landscape-primary', angle => 0 };
# Resolution media queries must agree with the spoofed devicePixelRatio for
# ANY profile whose dpr differs from 1 (e.g. a Retina desktop), not just
# mobile -- otherwise matchMedia('(min-resolution: 2dppx)') contradicts
# window.devicePixelRatio===2.
my $dpr = ($p->{devicePixelRatio} // 1) + 0;
$media{dppx} = $dpr if $dpr != 1;
# Emit the media block whenever a SCREEN is spoofed, not only for mobile or a
# non-1 dpr. Otherwise a desktop dpr-1 profile got no matchMedia wrapper at
# all, so device-width/height fell through to the engine and a binary search
# over '(device-width: Npx)' recovered the real host geometry -- defeating the
# native screen spoof in two lines.
$c{media} = \%media if %media || $p->{screen};
# WebGL numeric capabilities / extension lists / shader precision are JS-layer
# config too -- the extension's WebGL wrapper reads them from this same blob.
$c{webgl} = $p->{webgl} if $p->{webgl};
# Which DOM feature-presence stub groups to install (each in-guarded in JS, so
# a build that ships the real API keeps it). See FEATURES_JS in the extension.
$c{features} = $p->{features} if $p->{features};
t/98-curl-target.t view on Meta::CPAN
my $id = EV::WebKit::Fingerprint::identity_headers($wc);
like($id->{'user-agent'}, qr{Chrome/150}, 'identity_headers carries the UA');
is($id->{'accept-language'}, 'en-US,en;q=0.9',
'identity Accept-Language is Chrome format (en-US,en;q=0.9), not libsoup-flavored');
# Both the order and the GREASE brand itself are per-release, taken from
# curl-impersonate's chrome150 template rather than assumed: 131 put
# "Not_A Brand";v="24" last, 150 puts "Not;A=Brand";v="8" first. What must
# hold is that this header and navigator.userAgentData come from one list.
is($id->{'sec-ch-ua'}, '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
'sec-ch-ua is the chrome150 template brand list, in its order');
is($id->{'sec-ch-ua-mobile'}, '?0', 'sec-ch-ua-mobile ?0 for a desktop profile');
# a Safari profile has no ua_data: UA + Accept-Language, but no sec-ch-ua
my $sid = EV::WebKit::Fingerprint::identity_headers($ms);
is($sid->{'accept-language'}, 'en-US,en;q=0.9', 'Safari identity still carries Chrome-format Accept-Language');
ok(!exists $sid->{'sec-ch-ua'}, 'Safari identity has no sec-ch-ua (no ua_data)');
# the q-weight sequence for 3+ languages: 0.9, 0.8, ...
my $three = EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', languages => ['de-DE','de','en'] });
is(EV::WebKit::Fingerprint::identity_headers($three)->{'accept-language'},
'de-DE,de;q=0.9,en;q=0.8', 'Accept-Language q-weights decrement (0.9, 0.8) for three languages');
t/99-fingerprint.t view on Meta::CPAN
my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
is($r->{vendor}, 'Google Inc.', 'override reaches the browser (vendor, discriminating: preset is Apple)');
is($r->{touch}, 9, 'override reaches the browser (maxTouchPoints=9, discriminating)');
is($r->{mem}, 16, 'override ADDS a preset-omitted field: deviceMemory reaches navigator');
$b->quit;
}
# --- negative control: a browser WITHOUT fingerprint reports the REAL platform.
# Guards against the process-global extension leaking into a non-fp instance. ---
#
# It also samples this HOST's pointer media. A desktop profile deliberately does
# NOT override pointer/hover, so those queries fall through to the engine -- which
# on a touchscreen machine legitimately answers (pointer: coarse). Hardcoding
# "fine matches" would therefore assert a property of the test machine, not of the
# spoof: green here, red there, and either way it never pinned "no flip". The
# desktop assertions below compare against these control values instead.
my ($HOST_FINE, $HOST_COARSE) = (1, 0); # fallback if the control cannot be read
{
my $b = EV::WebKit->new(window => [200,150]);
$b->mock_scheme('real', sub { ('<html><body>real</body></html>','text/html') });
my %g;
$b->go('real://host/p', sub {
$b->script('return JSON.stringify({platform:navigator.platform, chrome:typeof window.chrome, uaData:typeof navigator.userAgentData, mmNative:window.matchMedia.toString().includes("[native code]"), ptrFine:matchMedia("(pointer: fine)").matches...
sub { $g{r} = $_[0]; EV::break });
});
TWK::run_with_timeout(20);
t/99-fingerprint.t view on Meta::CPAN
chrome_app: typeof window.chrome.app, // real Chrome page has app/csi/loadTimes
chrome_csi: typeof window.chrome.csi,
chrome_load: typeof window.chrome.loadTimes,
chrome_rt: ('runtime' in window.chrome), // must be false (empty runtime is a tell)
brands: u.brands.map(b => b.brand).join(','),
uaPlatform: u.platform,
uaMobile: u.mobile,
hevArch: h.architecture,
hevHasPV: ('platformVersion' in h), // NOT requested -> must be absent
ghevProto: ('prototype' in u.getHighEntropyValues), // native-method-like: no .prototype
desktopPtr: matchMedia('(pointer: fine)').matches, // desktop chrome: media unchanged
desktopPtrC: matchMedia('(pointer: coarse)').matches, // ... in both directions
}));
JS
});
TWK::run_with_timeout(20);
my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
is($r->{chrome}, 'object', 'window.chrome present for a Chrome profile');
is($r->{chrome_app}, 'object', 'window.chrome.app present (real-Chrome shape)');
is($r->{chrome_csi}, 'function', 'window.chrome.csi present');
is($r->{chrome_load},'function', 'window.chrome.loadTimes present');
ok(!$r->{chrome_rt}, 'window.chrome has no bare runtime:{} (the puppeteer-stealth tell)');
# Order AND the GREASE entry are curl-impersonate's chrome150 template, not
# a constant: 131 sent "Not_A Brand";v="24" last, 150 sends
# "Not;A=Brand";v="8" first.
is($r->{brands}, 'Not;A=Brand,Chromium,Google Chrome',
'userAgentData.brands order matches real Chrome 150 (same source as the wire sec-ch-ua)');
is($r->{uaPlatform}, 'Windows', 'userAgentData.platform matches the profile');
ok(!$r->{uaMobile}, 'userAgentData.mobile false for a desktop profile');
is($r->{hevArch}, 'x86', 'getHighEntropyValues resolves the requested architecture');
ok(!$r->{hevHasPV}, 'getHighEntropyValues RESPECTS hints (unrequested platformVersion absent)');
ok(!$r->{ghevProto}, 'getHighEntropyValues has no .prototype (native-method-like)');
is($r->{desktopPtr} ? 1 : 0, $HOST_FINE,
'a desktop Chrome profile leaves (pointer: fine) exactly as the engine answers it');
is($r->{desktopPtrC} ? 1 : 0, $HOST_COARSE,
'a desktop Chrome profile does NOT flip pointer media to coarse');
$b->quit;
}
# --- stage 2: mobile coherence (iphone-safari): geometry, touch, media, and
# correctly NO chrome/userAgentData (it is Safari) ---
{
my $b = EV::WebKit->new(fingerprint => 'iphone-safari');
$b->mock_scheme('mob', sub { ('<html><body>mob</body></html>','text/html') });
my %g;
$b->go('mob://host/p', sub {
t/99-fingerprint.t view on Meta::CPAN
$b->script('return JSON.stringify({ dpr:window.devicePixelRatio, minRes:matchMedia("(min-resolution: 2dppx)").matches, exact:matchMedia("(resolution:2dppx)").matches, maxRes1:matchMedia("(max-resolution: 1dppx)").matches, coarse:matchMedia("(...
sub { $g{r} = $_[0]; EV::break });
});
TWK::run_with_timeout(20);
my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
is($r->{dpr}, 2, 'macos-safari devicePixelRatio 2');
ok($r->{minRes}, 'min-resolution:2dppx matches dpr=2 (resolution coherence on a NON-mobile Retina profile)');
ok($r->{exact}, 'exact resolution:2dppx matches');
ok(!$r->{maxRes1}, 'max-resolution:1dppx does NOT match dpr=2');
is($r->{coarse} ? 1 : 0, $HOST_COARSE,
'a desktop Retina profile does not touch pointer media (matches the unspoofed control)');
$b->quit;
}
# --- stage 2: pixel-chrome is BOTH mobile AND Chrome -- exercise the combination ---
{
my $b = EV::WebKit->new(fingerprint => 'pixel-chrome');
$b->mock_scheme('px', sub { ('<html><body>px</body></html>','text/html') });
my %g;
$b->go('px://host/p', sub {
$b->script('return JSON.stringify({ platform:navigator.platform, uaMobile:navigator.userAgentData.mobile, uaPlatform:navigator.userAgentData.platform, chrome:typeof window.chrome, ontouch:("ontouchstart" in window), coarse:matchMedia("(pointe...
t/99-fingerprint.t view on Meta::CPAN
my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
ok($r->{uaMobile}, 'pixel-chrome userAgentData.mobile is true (mobile Chrome)');
is($r->{uaPlatform}, 'Android', 'pixel-chrome userAgentData.platform Android');
is($r->{chrome}, 'object', 'pixel-chrome has window.chrome (it is Chrome)');
ok($r->{ontouch}, 'pixel-chrome has touch (it is mobile)');
ok($r->{coarse}, 'pixel-chrome pointer:coarse (mobile)');
ok($r->{innerLE}, 'pixel-chrome geometry coherent');
$b->quit;
}
# --- stage 2: desktop geometry -- a large window is capped to the spoofed screen
# so window.innerWidth never exceeds screen.width (the desktop impossibility) ---
{
my $b = EV::WebKit->new(window => [3000,2000], fingerprint => 'windows-chrome'); # window > screen (1920x1080)
$b->mock_scheme('dg', sub { ('<html><body>dg</body></html>','text/html') });
my %g;
$b->go('dg://host/p', sub {
$b->script('return JSON.stringify({ innerLEw:window.innerWidth<=screen.width, innerLEh:window.innerHeight<=screen.height, innerW:window.innerWidth, screenW:screen.width })',
sub { $g{r} = $_[0]; EV::break });
});
TWK::run_with_timeout(20);
my $r = eval { require Cpanel::JSON::XS; Cpanel::JSON::XS::decode_json($g{r}) } || {};
ok($r->{innerLEw}, "desktop window capped to screen: innerWidth ($r->{innerW}) <= screen.width ($r->{screenW})");
ok($r->{innerLEh}, 'desktop window height capped to screen too');
$b->quit;
}
# every preset loads and reports its own platform natively (coherence smoke).
for my $name (EV::WebKit::Fingerprint::profiles()) {
my $want = EV::WebKit::Fingerprint::resolve($name)->{platform};
my $b = EV::WebKit->new(window => [200,150], fingerprint => $name);
$b->mock_scheme('sm', sub { ('<html><body>x</body></html>','text/html') });
my $got;
$b->go('sm://host/p', sub { $b->script('return navigator.platform', sub { $got = $_[0]; EV::break }) });
t/99-fingerprint.t view on Meta::CPAN
my $cj = EV::WebKit::Fingerprint::gvariant(EV::WebKit::Fingerprint::resolve('windows-chrome'))->print(1);
like($cj, qr/features/, 'coherence JSON carries the features list');
eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', features => 'x' }) };
like($@, qr/features.*arrayref/, 'non-arrayref features override croaks');
eval { EV::WebKit::Fingerprint::resolve({ profile => 'windows-chrome', features => ['webusb'] }) };
like($@, qr/unknown features group/, 'a mistyped features group croaks (never a silent no-op stub)');
# --- screen.orientation.type follows the spoofed SCREEN, not the mobile flag.
# Keyed off `mobile` it contradicted the profile's own screen.width/height on any
# portrait desktop or landscape tablet -- a two-property probe. ---
for my $case (
[ 'a landscape screen' => [1920,1080], 0, 'landscape-primary' ],
[ 'a portrait screen' => [1080,1920], 0, 'portrait-primary' ],
[ 'a portrait MOBILE screen' => [ 390, 844], 1, 'portrait-primary' ],
# the case the old mobile-keyed rule got backwards in both directions
[ 'a LANDSCAPE mobile screen' => [ 844, 390], 1, 'landscape-primary' ],
[ 'a PORTRAIT desktop screen' => [1200,1600], 0, 'portrait-primary' ],
[ 'a square screen' => [1000,1000], 0, 'landscape-primary' ],
) {
my ($what, $screen, $mobile, $want) = @$case;
my $p = EV::WebKit::Fingerprint::resolve({
profile => $mobile ? 'iphone-safari' : 'windows-chrome', screen => $screen });
my $c = EV::WebKit::Fingerprint::_coherence($p);
is($c->{orientation}{type}, $want, "$what ($screen->[0]x$screen->[1]) reports $want");
}
# --- an EMPTY profile must produce NO coherence blob at all -----------------
t/99-fingerprint.t view on Meta::CPAN
$h->quit if $h;
my $s = eval {
EV::WebKit->new(window => [100,80], ephemeral => 1, timeout => 5,
fingerprint => 'windows-chrome', network_fingerprint => 1);
};
is($s && $s->network_fingerprint, 'chrome150', '...same target as the string form');
$s->quit if $s;
# on_request ALONE takes the same derivation, and this branch is the
# dangerous one: it does not croak on a miss, it falls back to desktop
# Chrome. So a hashref profile used to present a Safari (or Android Chrome)
# identity in JS and headers over a Chrome JA3/JA4 -- silently, and
# network_fingerprint() reported 'chrome131' as if that were the default.
for my $case (['macos-safari', 'safari26_0'], ['pixel-chrome', 'chrome131_android']) {
my ($preset, $want) = @$case;
my %made;
for my $form (['string', $preset],
['hashref', { profile => $preset }]) {
my ($name, $fp) = @$form;
my $r = eval {
t/A2-features.t view on Meta::CPAN
ok($s->{connIsET}, 'connection is a real EventTarget');
ok($s->{connSame}, 'connection returns the same object on every access');
ok($s->{rtcNoNewThrew}, 'RTCPeerConnection() without new throws TypeError, as the real one does');
ok(!$s->{windowPolluted}, 'calling RTCPeerConnection() does not leak state onto window');
ok($s->{batterySame}, 'getBattery() resolves the same BatteryManager each call');
is($s->{batteryTag}, '[object BatteryManager]', 'battery object reports its interface name');
}
my $c = feat('windows-chrome');
ok($c->{connection}, 'Chrome: navigator.connection present');
ok($c->{usb} && $c->{bluetooth} && $c->{hid} && $c->{serial}, 'Chrome desktop: usb/bluetooth/hid/serial present');
ok($c->{battery}, 'Chrome: navigator.getBattery present');
ok($c->{scheduling} && $c->{rtc} && $c->{storage}, 'Chrome: scheduling/rtc/storage present');
is($c->{effType}, '4g', 'connection.effectiveType is functional');
ok($c->{estimateOk}, 'storage.estimate() resolves a StorageEstimate');
is($c->{batteryLevel}, 1, 'getBattery() resolves a BatteryManager');
ok($c->{cfgGone}, '__evwk_cfg is deleted after injection (no window tell)');
my $s = feat('macos-safari');
# guard: feat() yields {} on any failure, which would make every absence
# assertion below pass vacuously.
ok(exists $s->{connection}, 'macos-safari probe ran (absence assertions are meaningful)');
ok(!$s->{connection}, 'Safari: no navigator.connection');
ok(!$s->{usb} && !$s->{bluetooth} && !$s->{battery}, 'Safari: no usb/bluetooth/battery');
ok(!$s->{scheduling}, 'Safari: no navigator.scheduling');
ok($s->{rtc} && $s->{storage}, 'Safari: rtc + storage present');
my $px = feat('pixel-chrome');
ok(exists $px->{hid}, 'pixel-chrome probe ran (absence assertions are meaningful)');
ok($px->{usb} && $px->{bluetooth}, 'Android Chrome: usb + bluetooth present');
ok(!$px->{hid} && !$px->{serial}, 'Android Chrome: no hid/serial (desktop-only)');
ok($px->{connection} && $px->{battery}, 'Android Chrome: connection + battery present');
# --- regressions from the round-2 adversarial review ---
sub coh {
my ($name) = @_;
my $b = EV::WebKit->new(window => [200,150], fingerprint => $name);
$b->mock_scheme('fp', sub { ('<html><body></body></html>','text/html') });
my $out;
$b->go('fp://host/p', sub {
$b->script(<<'JS', sub { $out = $_[0]; EV::break });
t/A2-features.t view on Meta::CPAN
is($r->{uadOwnKeys}, 0, 'userAgentData has no own enumerable keys (attributes on the prototype)');
ok($r->{uadIfaceGlobal}, 'window.NavigatorUAData interface object exists');
ok($r->{uadNoProto}, 'getHighEntropyValues has no .prototype (native-method-like)');
ok($r->{uadBrands}, 'userAgentData.brands is still a real array');
ok($r->{uadFrozen}, 'userAgentData.brands is frozen, as real Chrome returns it');
ok(!$r->{hevSyncThrew},
'getHighEntropyValues() with no argument does not throw synchronously');
ok($r->{hevRejected},
'getHighEntropyValues() with no argument returns a rejected promise');
}
# windows-chrome is a desktop profile: landscape-primary, never the mobile value
is($r->{orientType}, 'landscape-primary',
'desktop screen.orientation.type comes from the profile');
ok($r->{rtcEnumerable}, 'RTCPeerConnection members are enumerable, like real WebIDL and the sibling stubs');
}
{
my $r = coh('pixel-chrome'); # mobile: pointer coarse, hover none, dppx 2.625
ok($r->{mqlIdentity}, 'MediaQueryList method identity is stable (no per-access Proxy binding)');
ok($r->{mqlBrand}, 'MediaQueryList passes the platform-object brand check');
ok(!$r->{mqlOwnMatches}, 'matches stays a prototype accessor, not an own property');
ok($r->{mqSimple}, 'mobile: (pointer: coarse) matches');
ok($r->{mqCompound}, 'compound query with an unspoofed clause still matches');
ok($r->{mqList}, 'comma-separated query list matches');
t/A2-features.t view on Meta::CPAN
or diag('own toString: ' . join(', ', @{$r->{ownTS} || []}));
# The C-installed accessors must remain genuinely native: renaming them in
# place is fine, replacing them with a JS wrapper is not.
is_deeply($r->{srcOdd}, [],
"$profile: the native navigator/screen getters still report [native code]")
or diag('no longer native: ' . join(', ', @{$r->{srcOdd} || []}));
}
# --- round-7 regressions ---
# window.orientation is mobile-only; screen.orientation is universal. Emitting
# the orientation config for desktop (so it gets screen.orientation) must not
# also hand it window.orientation beside maxTouchPoints:0.
# And a (not X) clause must delegate the RAW inner text, or the calc() welding
# bug returns and a query and its negation are both true.
for my $prof (qw(windows-chrome pixel-chrome)) {
my $b = EV::WebKit->new(window => [200,150], fingerprint => $prof);
$b->mock_scheme('fp', sub { ('<html><body></body></html>','text/html') });
my $o;
$b->go('fp://host/p', sub { $b->script(<<'JS', sub { $o = $_[0]; EV::break }) });
const mm = q => window.matchMedia(q).matches;
return JSON.stringify({
t/A2-features.t view on Meta::CPAN
// whole query must be false, but either bug alone flips it to true.
calcCompound: mm('(pointer: coarse) and (not (min-width: calc(50px + 10px)))'),
});
JS
TWK::run_with_timeout(20); $b->quit;
require Cpanel::JSON::XS; my $r = Cpanel::JSON::XS::decode_json($o // '{}');
my $mobile = $prof eq 'pixel-chrome';
ok($r->{ran}, "$prof: orientation/media probe ran") or diag('probe returned nothing');
is($r->{winOrient}, $mobile ? 1 : 0, "$prof: window.orientation present only on mobile");
is($r->{onOrient}, $mobile ? 1 : 0, "$prof: onorientationchange tracks window.orientation");
ok($r->{screenOrient}, "$prof: screen.orientation is present on desktop and mobile alike");
isnt($r->{calcPos}, $r->{calcNeg}, "$prof: a calc() query and its negation cannot both hold");
is($r->{calcCompound}, 0,
"$prof: (pointer: coarse) and (not <true clause>) is false (pins each media fix separately)");
}
# --- round-8: the screen must not be recoverable through ANY media-query form ---
# Handling only the min-/max- prefixes left the MQ4 range form delegated to the
# engine, so a binary search over '(device-width >= Npx)' still returned the real
# host geometry -- the exact leak the min-/max- branch was added to close.
for my $prof (qw(windows-chrome pixel-chrome)) {
t/A2-features.t view on Meta::CPAN
ok(!$r->{andQ} || $r->{orQ}, "$prof: 'A or B' is not false while 'A and B' is true");
ok($r->{taut}, "$prof: a nested (A or B) tautology is true");
is($r->{nested}, $r->{bare},
"$prof: conjoining a tautology changes nothing (tokenizer respects paren depth)");
}
# --- round-11: the media evaluator parses each clause into (feature, operator,
# value) and dispatches by FEATURE NAME. Every defect below was a syntactic form
# of an ALREADY-spoofed feature that the old spelling-matcher did not recognise,
# so it delegated the clause and the host answered -- handing back the real
# geometry or the real DPR. Each is pinned in both a desktop and a mobile profile.
for my $prof (qw(windows-chrome pixel-chrome)) {
my $b = EV::WebKit->new(window => [200,150], fingerprint => $prof);
$b->mock_scheme('fp', sub { ('<html><body></body></html>','text/html') });
my $o;
$b->go('fp://host/p', sub { $b->script(<<'JS', sub { $o = $_[0]; EV::break }) });
const mm = q => window.matchMedia(q).matches;
const R = { ran:1, sw: screen.width, sh: screen.height, dpr: window.devicePixelRatio };
// 1em/1pt in px, resolved by the ENGINE (the viewport `width` feature is
// never spoofed, so this is the host's real font size, not ours).
const t = (e,n) => mm('(max-width:calc(' + e + ' - ' + n.toFixed(6) + 'px))');
wext/evwk_fp.c view on Meta::CPAN
" return {v:v,s:sp}; }"
" ps=splitTop(s,/^\\s+and\\s+/);"
" if(ps.length>1){ v=true;"
" for(i=0;i<ps.length;i++){ r=unit(ps[i],dep+1); if(r.s) sp=true; if(!r.v) v=false; }"
" return {v:v,s:sp}; }"
" return unit(s,dep); };"
/* Evaluate a COMPOUND query branch by branch and clause by clause: a spoofed
* clause uses our answer, any other is delegated to the real engine and the
* results combined. Matching whole literal strings meant
* '(pointer:coarse) and (min-width:1px)' and comma lists silently reported the
* real desktop answer. */
" var evalQuery=function(lq){"
" var ors=splitTop(lq, /^\\s*(?:,|\\bor\\b)\\s*/), spoofed=false, result=false, i, j;"
" for(i=0;i<ors.length;i++){"
/* `only <type>` is semantically identical to `<type>` (the keyword only hides
* the query from prehistoric parsers), and `not` negates the whole branch.
* Treating either as unparseable abandoned the spoof and returned the
* contradicting host answer for the most common real-world form. */
" var branch=ors[i].trim(), negate=false;"
" if(/^only\\s+/.test(branch)) branch=branch.replace(/^only\\s+/,'');"
" else if(/^not\\s+/.test(branch)){ negate=true; branch=branch.replace(/^not\\s+/,''); }"
wext/evwk_fp.c view on Meta::CPAN
* contradiction. */
" if(cfg.touch){"
" ['ontouchstart','ontouchend','ontouchmove','ontouchcancel'].forEach(function(h){"
" try{ if(h in window) return; var slot=null;"
" Object.defineProperty(window,h,{enumerable:true,configurable:true,"
" get(){ return slot; },"
" set(v){ slot = ((typeof v==='object'&&v!==null)||typeof v==='function') ? v : null; }}); }catch(e){} });"
" }"
" if(cfg.orientation){"
/* window.orientation is MOBILE-ONLY (and legacy); screen.orientation is
* universal. Emitting the orientation config for desktop profiles so they
* get screen.orientation must not also hand them window.orientation beside
* maxTouchPoints:0 and pointer:fine. Gate it on touch, and give it the
* onorientationchange sibling no real mobile browser omits. */
" if(cfg.touch){"
" try{ Object.defineProperty(window,'orientation',{enumerable:true,configurable:true,"
" get(){ return cfg.orientation.angle; }}); }catch(e){}"
" try{ if(!('onorientationchange' in window)){ var _ooc=null;"
" Object.defineProperty(window,'onorientationchange',{enumerable:true,configurable:true,"
" get(){ return _ooc; },"
" set(v){ _ooc=((typeof v==='object'&&v!==null)||typeof v==='function')?v:null; }}); } }catch(e){}"