EV-WebKit
view release on metacpan or search on metacpan
lib/EV/WebKit/Fingerprint.pm view on Meta::CPAN
35724 => 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)',
3379 => 16384, 34076 => 16384, 34024 => 16384, 3386 => [16384,16384],
34921 => 16, 36347 => 256, 36348 => 31, 36349 => 224,
35660 => 16, 34930 => 16, 35661 => 32,
33902 => [1,8], 33901 => [1,1023], 34047 => 16,
},
params2 => {
7938 => 'WebGL 2.0 (OpenGL ES 3.0 Chromium)',
35724 => 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)',
32883 => 2048, 35071 => 2048, 36063 => 8, 34852 => 8, 36183 => 4,
35376 => 65536, 34045 => 2, 36203 => 4294967294,
# combined uniform blocks must be at least vertex+fragment (24+24)
35373 => 24, 35371 => 24, 35375 => 72, 35374 => 48,
# params1 has vertex=256, fragment=224, varying=31 vectors -> x4.
35658 => 1024, 35657 => 896, 35659 => 124, 37157 => 124, 37154 => 124,
},
extensions1 => [qw(
ANGLE_instanced_arrays EXT_blend_minmax EXT_clip_control EXT_color_buffer_half_float
EXT_disjoint_timer_query EXT_float_blend EXT_frag_depth EXT_shader_texture_lod
EXT_texture_filter_anisotropic EXT_sRGB KHR_parallel_shader_compile
OES_element_index_uint OES_fbo_render_mipmap OES_standard_derivatives
OES_texture_float OES_texture_float_linear OES_texture_half_float
OES_texture_half_float_linear OES_vertex_array_object WEBGL_color_buffer_float
WEBGL_compressed_texture_astc WEBGL_compressed_texture_etc
WEBGL_compressed_texture_etc1 WEBGL_debug_renderer_info WEBGL_debug_shaders
WEBGL_depth_texture WEBGL_draw_buffers WEBGL_lose_context WEBGL_multi_draw
)],
extensions2 => [qw(
EXT_clip_control EXT_color_buffer_float EXT_color_buffer_half_float
EXT_disjoint_timer_query_webgl2 EXT_float_blend EXT_texture_filter_anisotropic
EXT_texture_norm16 KHR_parallel_shader_compile OES_draw_buffers_indexed
OES_sample_variables OES_shader_multisample_interpolation OES_texture_float_linear
OVR_multiview2 WEBGL_clip_cull_distance WEBGL_compressed_texture_astc
WEBGL_compressed_texture_etc WEBGL_debug_renderer_info WEBGL_debug_shaders
WEBGL_lose_context WEBGL_multi_draw WEBGL_provoking_vertex
)],
precision => {
'VERTEX.HIGH_FLOAT' => [127,127,23], 'VERTEX.MEDIUM_FLOAT' => [15,15,10],
'VERTEX.LOW_FLOAT' => [15,15,10], 'FRAGMENT.HIGH_FLOAT' => [127,127,23],
'FRAGMENT.MEDIUM_FLOAT'=> [15,15,10], 'FRAGMENT.LOW_FLOAT' => [15,15,10],
# every shader/precision combination must be listed, or the unlisted ones
# fall through and report the REAL host precision alongside spoofed ones
'VERTEX.HIGH_INT' => [31,30,0], 'FRAGMENT.HIGH_INT' => [31,30,0],
# fp16 mediump/lowp pairs with 16-bit ints, as ANGLE reports them
'VERTEX.MEDIUM_INT' => [15,14,0], 'FRAGMENT.MEDIUM_INT' => [15,14,0],
'VERTEX.LOW_INT' => [15,14,0], 'FRAGMENT.LOW_INT' => [15,14,0],
},
);
# Each preset declares ONLY the fields that real device exposes (sparse rule).
# 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.
ua_data => { platform => 'Windows', platformVersion => '10.0.0', architecture => 'x86', bitness => '64', model => '', uaFullVersion => '150.0.7871.189',
brands => [ {brand=>'Not;A=Brand',version=>'8'}, {brand=>'Chromium',version=>'150'}, {brand=>'Google Chrome',version=>'150'} ],
fullVersionList => [ {brand=>'Not;A=Brand',version=>'8.0.0.0'}, {brand=>'Chromium',version=>'150.0.7871.189'}, {brand=>'Google Chrome',version=>'150.0.7871.189'} ] },
},
'macos-safari' => {
user_agent => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15',
platform => 'MacIntel', vendor => 'Apple Computer, Inc.', languages => ['en-US','en'],
hardwareConcurrency => 10, maxTouchPoints => 0, # Safari omits deviceMemory
screen => [1512,982,1512,944,30], devicePixelRatio => 2,
webgl_vendor => 'Apple Inc.', webgl_renderer => 'Apple GPU',
webgl => \%WEBGL_APPLE,
# Safari exposes neither NetworkInformation, Battery, nor WebUSB/Bluetooth/HID/Serial.
features => [qw(storage rtc)],
},
'iphone-safari' => {
user_agent => 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1',
platform => 'iPhone', vendor => 'Apple Computer, Inc.', languages => ['en-US','en'],
hardwareConcurrency => 6, maxTouchPoints => 5,
screen => [390,844,390,844,24], devicePixelRatio => 3,
webgl_vendor => 'Apple Inc.', webgl_renderer => 'Apple GPU',
webgl => \%WEBGL_APPLE,
features => [qw(storage rtc)],
mobile => 1, # drives window sizing + touch + pointer/hover/resolution media queries (Safari: no ua_data)
},
'windows-firefox' => {
# Gecko, claimed from a WebKit engine: a bigger lie than the others, and
# the residual Gecko-only surface is documented in the POD. What IS
# coherent is the whole header + TLS + HTTP/2 stack, which is Firefox's.
user_agent => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0',
# Firefox reports an EMPTY navigator.vendor, where Chrome says
# "Google Inc." and WebKit "Apple Computer, Inc." -- one of the cheapest
# engine checks there is, so it has to be the empty string, not absent.
platform => 'Win32', vendor => '', languages => ['en-US','en'],
# Gecko's own navigator surface. productSub is the cheap engine check --
# every Gecko build reports 20100101 where WebKit and Chromium report
# 20030107 -- and oscpu/buildID exist in no other engine, so their
# ABSENCE is as much a tell as a wrong value. buildID has been frozen at
# this constant since Firefox 64 for exactly this reason.
productSub => '20100101',
oscpu => 'Windows NT 10.0; Win64; x64',
buildID => '20181001000000',
# Viewport origin in screen coordinates: 0 across, and down by the
# chrome above it for a maximised window.
mozInnerScreenX => 0, mozInnerScreenY => 74,
# No deviceMemory: Firefox has never shipped it.
hardwareConcurrency => 8, maxTouchPoints => 0,
screen => [1920,1080,1920,1040,24], devicePixelRatio => 1,
# Firefox on Windows renders through ANGLE too, so the unmasked strings
# are the same shape as Chrome's on the same GPU.
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,
# Same interface set as Safari, by coincidence rather than kinship:
# Firefox dropped Battery, never shipped NetworkInformation for content,
# and implements none of WebUSB/Bluetooth/HID/Serial.
features => [qw(storage rtc)],
# No ua_data: client hints are Chromium-only, so identity_headers emits
# no Sec-CH-UA for this profile, which is what Firefox does.
},
'pixel-chrome' => {
# Chrome 131's reduced/frozen Android UA (model "K", version "Android 10");
# the real device + OS version ride only in Sec-CH-UA-Model/-Platform-Version
# + navigator.userAgentData -- matching curl-impersonate's chrome131_android.
user_agent => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36',
platform => 'Linux armv8l', vendor => 'Google Inc.', languages => ['en-US','en'],
hardwareConcurrency => 8, deviceMemory => 8, maxTouchPoints => 5,
screen => [412,915,412,915,24], devicePixelRatio => 2.625,
webgl_vendor => 'Google Inc. (Qualcomm)',
webgl_renderer => 'ANGLE (Qualcomm, Adreno (TM) 730, OpenGL ES 3.2)',
webgl => \%WEBGL_ADRENO,
# Android Chrome has WebUSB + Web Bluetooth but NOT WebHID / Web Serial.
features => [qw(connection storage battery usb bluetooth scheduling rtc)],
mobile => 1,
# Chrome for Android had NO inline PDF viewer at 131, so it reports
# pdfViewerEnabled false and an EMPTY plugins/mimeTypes list, where every
# other preset (and WebKitGTK itself) reports true and the five plugin
# names the HTML spec hardcodes. Dates, because the margin is narrow:
# Chrome 131 for Android shipped 2024-11-06, the Android viewer first
# appeared behind a flag in 2024-12, and only became default-on in
# Chrome 135 (2025-04). So 131 predates it outright.
pdf_viewer => 0,
ua_data => { platform => 'Android', platformVersion => '14.0.0', architecture => '', bitness => '', model => 'Pixel 8', uaFullVersion => '131.0.6778.86',
brands => [ {brand=>'Google Chrome',version=>'131'}, {brand=>'Chromium',version=>'131'}, {brand=>'Not_A Brand',version=>'24'} ],
fullVersionList => [ {brand=>'Google Chrome',version=>'131.0.6778.86'}, {brand=>'Chromium',version=>'131.0.6778.86'}, {brand=>'Not_A Brand',version=>'24.0.0.0'} ] },
},
);
# field => validator: 'str'/'num'/'strv'/'screen'/'bool'/'uadata'.
my %FIELD = (
user_agent => 'str', platform => 'str', vendor => 'str',
productSub => 'str', oscpu => 'str', buildID => 'str',
webgl_vendor => 'str', webgl_renderer => 'str', languages => 'strv',
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.
'pixel-chrome' => 'chrome131_android',
);
sub curl_target { $CURL_TARGET{ $_[0] // '' } }
# The identity headers (User-Agent + Chrome client hints) a resolved profile
# should present to origins. The proxy forces these over curl's target defaults
# so the origin-seen OS/UA matches the JS layer -- e.g. Windows Chrome on the
# macOS-flavored chrome131 target. Safari profiles (no ua_data) carry just the UA.
# Chrome renders navigator.languages as Accept-Language "en-US,en;q=0.9,..." --
# the first tag unweighted, each later tag q = 1 - 0.1*i (floored at 0.1),
# formatted without trailing zeros. The proxy no longer forwards WebKit's own
# (possibly libsoup-formatted) Accept-Language, so we supply the exact form here.
sub _accept_language {
my ($langs) = @_;
my @out;
for my $i (0 .. $#$langs) {
if ($i == 0) { push @out, $langs->[$i]; next }
my $q = 1 - 0.1 * $i;
$q = 0.1 if $q < 0.1;
push @out, sprintf('%s;q=%s', $langs->[$i], sprintf('%g', $q));
}
return join ',', @out;
}
sub identity_headers {
my ($fp) = @_;
my %h = ('user-agent' => $fp->{user_agent});
$h{'accept-language'} = _accept_language($fp->{languages})
if $fp->{languages} && @{ $fp->{languages} };
if (my $u = $fp->{ua_data}) { # Chrome
$h{'sec-ch-ua'} = join ', ', map { qq{"$_->{brand}";v="$_->{version}"} } @{ $u->{brands} || [] };
$h{'sec-ch-ua-mobile'} = $fp->{mobile} ? '?1' : '?0';
$h{'sec-ch-ua-platform'} = qq{"$u->{platform}"};
}
return \%h;
}
# The HIGH-entropy client hints a resolved profile reports via
# getHighEntropyValues(). Real Chrome sends these only after an origin opts in
# with Accept-CH, so the proxy adds them per-host on demand (not on every
# request); this keeps the header layer consistent with the JS ua_data. Safari
# profiles (no ua_data) have none.
sub high_entropy_headers {
my ($fp) = @_;
my $u = $fp->{ua_data} or return {};
my %h = (
'sec-ch-ua-platform-version' => qq{"$u->{platformVersion}"},
'sec-ch-ua-arch' => qq{"$u->{architecture}"},
'sec-ch-ua-bitness' => qq{"$u->{bitness}"},
'sec-ch-ua-model' => qq{"$u->{model}"},
lib/EV/WebKit/Fingerprint.pm view on Meta::CPAN
# Build the a{sv} GVariant of present, non-user_agent keys. screen is flattened
# to screen_width/height/availWidth/availHeight/colorDepth/pixelDepth; every
# number is a double ('d'); languages is 'as'.
sub gvariant {
my ($p, $seed, $extra) = @_;
my %d = %{ $extra || {} }; # entries the caller needs regardless of profile
$p ||= {};
$d{$_} = Glib::Variant->new('s', $p->{$_}) for grep { defined $p->{$_} } qw/platform vendor webgl_vendor webgl_renderer productSub oscpu buildID/;
$d{languages} = Glib::Variant->new('as', $p->{languages}) if $p->{languages};
$d{$_} = Glib::Variant->new('d', $p->{$_} + 0) for grep { defined $p->{$_} } qw/hardwareConcurrency deviceMemory maxTouchPoints devicePixelRatio mozInnerScreenX mozInnerScreenY/;
if (my $s = $p->{screen}) {
my ($w,$h,$aw,$ah,$cd) = @$s == 5 ? @$s : ($s->[0],$s->[1],$s->[0],$s->[1],24);
$d{screen_width} = Glib::Variant->new('d', $w);
$d{screen_height} = Glib::Variant->new('d', $h);
$d{screen_availWidth} = Glib::Variant->new('d', $aw);
$d{screen_availHeight} = Glib::Variant->new('d', $ah);
$d{screen_colorDepth} = Glib::Variant->new('d', $cd);
$d{screen_pixelDepth} = Glib::Variant->new('d', $cd);
}
# Readback-noise seed (opt-in). Passed as its own double so the extension can
# read it without parsing the coherence JSON; folded to guint32 in C.
$d{seed} = Glib::Variant->new('d', $seed + 0) if defined $seed;
# JS-layer coherence (window.chrome, navigator.userAgentData, matchMedia,
# touch) is not a native getter -- it is a JSON config the extension evals
# as JS. Carried as one 's' blob, separate from the native-getter fields.
if (my $coh = _coherence($p)) {
require Cpanel::JSON::XS;
$d{coherence} = Glib::Variant->new('s', Cpanel::JSON::XS::encode_json($coh));
}
return Glib::Variant->new('a{sv}', \%d);
}
# Derive the JS-coherence config from the profile: window.chrome + userAgentData
# for a Chrome profile (has ua_data); touch + pointer/hover/resolution media
# overrides for a mobile profile. Returns undef when neither applies.
sub _coherence {
my ($p) = @_;
# No profile at all means a PLAIN browser, and new() promises the extension
# then "defines nothing, leaving a plain instance exactly as it was". Every
# other block below is gated on a field, but the orientation block is not --
# it deliberately does not key off `mobile`, so with an empty profile it was
# the one thing that still emitted a blob. The extension gates COHERENCE_JS
# and the WebGL getParameter wrapper on the blob's mere presence, so a
# default browser was shipping window.ScreenOrientation (which stock
# WebKitGTK does not expose) and a getParameter whose toString no longer
# said [native code] -- both measured against the extension forced off.
return undef unless $p && %$p;
my %c;
if (my $u = $p->{ua_data}) {
$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};
# Only emitted when the profile says there is NO inline PDF viewer, because
# that is the only case needing action: WebKitGTK already reports the
# viewer-present state (true + the five spec-hardcoded plugin names) that
# every other preset wants.
$c{no_pdf_viewer} = 1 if exists $p->{pdf_viewer} && !$p->{pdf_viewer};
return %c ? \%c : undef;
}
1;
__END__
=head1 NAME
EV::WebKit::Fingerprint - profile data behind EV::WebKit's fingerprint option
=head1 DESCRIPTION
The preset device profiles (C<windows-chrome>, C<macos-safari>,
C<iphone-safari>, C<pixel-chrome>), their validation, and the marshalling that
hands them to the bundled web-process extension. Used by L<EV::WebKit>. You do
not normally touch it directly: pass C<< fingerprint => >> to
L<EV::WebKit/new> and read L<EV::WebKit/fingerprint> back, which is also where
the profile keys, the override rules and the B<Ceiling> -- what this spoof does
and does not hide -- are documented.
Two of its functions are re-exported as methods for convenience:
C<< EV::WebKit->fingerprint_profiles >> lists the preset names, and
C<< EV::WebKit->fingerprint_available >> reports whether the compiled
extension was found. Prefer those.
=head1 FUNCTIONS
None of these are exported; call them fully qualified if you must.
=head2 available
my $ok = EV::WebKit::Fingerprint::available();
True if the compiled web-process extension (C<evwk_fp.so>) was located -- in
an installed dist via L<File::ShareDir>, or the in-tree C<share/> during
development. Without it C<< fingerprint => >> croaks.
=head2 profiles
my @names = EV::WebKit::Fingerprint::profiles();
The preset names, sorted.
=head2 resolve
( run in 0.511 second using v1.01-cache-2.11-cpan-b16cb0d3907 )