use crate::canvas::CanvasNoise;
use crate::navigator::{NavigatorProfile, ScreenProfile};
use crate::profile::{
BatteryConfig, ClientRectsConfig, ConnectionConfig, FontConfig, IframeConfig,
MediaDevicesConfig, PermissionsConfig, PluginConfig, ScreenDisplayConfig, SpeechConfig,
TimingConfig, WebGLContextConfig, WebRtcMode,
};
use crate::webgl_audio::{AudioProfile, WebGLProfile};
macro_rules! stealth_hook_segments {
( $first:ident $(, $rest:ident )* $(,)? ) => {
pub struct StealthHooks {
$first: String,
$( $rest: String, )*
}
impl StealthHooks {
pub fn $first(&self) -> &str {
&self.$first
}
$(
pub fn $rest(&self) -> &str {
&self.$rest
}
)*
pub fn combined_js(&self) -> String {
let mut out =
String::with_capacity(16 + self.$first.len() $( + self.$rest.len() )*);
out.push_str(&self.$first);
$(
out.push('\n');
out.push_str(&self.$rest);
)*
out
}
}
};
}
stealth_hook_segments! {
audio_js,
navigator_js,
font_js,
battery_js,
webrtc_js,
timing_js,
clientrects_js,
screen_display_js,
plugin_js,
speech_js,
media_devices_js,
permissions_js,
webgl_context_js,
connection_js,
iframe_js,
}
impl StealthHooks {
pub fn from_profile(
_canvas: &CanvasNoise,
audio: &AudioProfile,
navigator: &NavigatorProfile,
screen: &ScreenProfile,
webgl: &WebGLProfile,
font: &FontConfig,
battery: &BatteryConfig,
webrtc_mode: WebRtcMode,
timing: &TimingConfig,
clientrects: &ClientRectsConfig,
screen_display: &ScreenDisplayConfig,
plugin: &PluginConfig,
speech: &SpeechConfig,
media_devices: &MediaDevicesConfig,
permissions: &PermissionsConfig,
webgl_context: &WebGLContextConfig,
connection: &ConnectionConfig,
iframe: &IframeConfig,
) -> Self {
StealthHooks {
audio_js: Self::build_audio_js(audio),
navigator_js: Self::build_navigator_js(navigator, screen, webgl),
font_js: Self::build_font_js(font),
battery_js: Self::build_battery_js(battery),
webrtc_js: Self::build_webrtc_js(webrtc_mode),
timing_js: Self::build_timing_js(timing),
clientrects_js: Self::build_clientrects_js(clientrects),
screen_display_js: Self::build_screen_display_js(screen_display),
plugin_js: Self::build_plugin_js(plugin, navigator),
speech_js: Self::build_speech_js(speech),
media_devices_js: Self::build_media_devices_js(media_devices),
permissions_js: Self::build_permissions_js(permissions),
webgl_context_js: Self::build_webgl_context_js(webgl_context),
connection_js: Self::build_connection_js(connection),
iframe_js: Self::build_iframe_js(iframe),
}
}
fn build_audio_js(audio: &AudioProfile) -> String {
let seed = audio.seed();
let amplitude = audio.noise_amplitude();
format!(
r#"(function() {{
var seed = {seed}n;
var amplitude = {amplitude};
function detNoise(index) {{
var state = seed;
state ^= BigInt(index) * 0x517CC1B727220A95n;
state = BigInt.asUintN(64, state * 0x2545F4914F6CDD1Dn);
state ^= state >> 33n;
return Number(BigInt.asUintN(64, state)) / 18446744073709551615 - 0.5;
}}
// Worker-realm guard (REQ-BRW-004 C13/C15): AudioBuffer is [Exposed=Window]
// — a bare prototype reference throws ReferenceError in a worker realm and
// kills the rest of the hooks blob. OfflineAudioContext below is already
// typeof-guarded.
if (typeof AudioBuffer !== 'undefined') {{
var origGetChannelData = AudioBuffer.prototype.getChannelData;
AudioBuffer.prototype.getChannelData = function(channel) {{
var data = origGetChannelData.call(this, channel);
if (seed > 0n && amplitude > 0) {{
for (var i = 0; i < data.length; i++) {{
data[i] += detNoise(i) * amplitude;
}}
}}
return data;
}};
}}
if (typeof OfflineAudioContext !== 'undefined') {{
var origStartRendering = OfflineAudioContext.prototype.startRendering;
OfflineAudioContext.prototype.startRendering = function() {{
var self = this;
return origStartRendering.call(this).then(function(buffer) {{
if (seed > 0n && amplitude > 0) {{
for (var ch = 0; ch < buffer.numberOfChannels; ch++) {{
var data = buffer.getChannelData(ch);
for (var i = 0; i < data.length; i++) {{
data[i] += detNoise(ch * 1000000 + i) * amplitude;
}}
}}
}}
return buffer;
}});
}};
}}
}})();"#,
seed = seed,
amplitude = amplitude,
)
}
fn build_navigator_js(
nav: &NavigatorProfile,
screen: &ScreenProfile,
webgl: &WebGLProfile,
) -> String {
let languages_json =
serde_json::to_string(&nav.languages).unwrap_or_else(|_| r#"["en-US","en"]"#.into());
let extensions_json =
serde_json::to_string(&webgl.extensions).unwrap_or_else(|_| "[]".into());
format!(
r#"(function() {{
// BCE (error.rs:74 residue): every defineProperty below must tolerate a
// refused redefine — on servo's Page Realm the WebIDL [LegacyUnforgeable]
// navigator/screen members (and any property already installed
// configurable:false by a prior install) throw
// "TypeError: can't redefine non-configurable property". A mid-template
// throw aborted the whole hooks blob AND left the exception pending on
// the ScriptThread cx (detonating servo's error.rs:74 assert). A refused
// define means the target state is already in effect — swallow it.
var __bao_def = function(obj, name, desc) {{
try {{ Object.defineProperty(obj, name, desc); }} catch (e) {{ /* refused redefine — target state already in effect */ }}
}};
// Chromium-parity placement (e148 oracle, Chrome 150): navigator/screen
// members are accessors on the INTERFACE PROTOTYPE with enumerable and
// configurable both true; the instances carry zero own names. Defining on
// the instance instead put every covered member into
// Object.getOwnPropertyNames(navigator) — an engine-distinguishing
// signal. Plain-object realms (no DOM): the prototype IS Object.prototype
// — defining there would pollute every object, so fall back to the
// instance.
var __bao_target = function(o) {{
try {{
var p = Object.getPrototypeOf(o);
return (p && p !== Object.prototype) ? p : o;
}} catch (e) {{ return o; }}
}};
var nav = (typeof navigator !== 'undefined') ? navigator : null;
var scr = (typeof screen !== 'undefined') ? screen : null;
var win = (typeof window !== 'undefined') ? window : (typeof globalThis !== 'undefined' ? globalThis : null);
var __nav_t = nav ? __bao_target(nav) : null;
var __scr_t = scr ? __bao_target(scr) : null;
// e148-R1: the proto targeting stays (with Face A PERMANENT these defines
// throw "can't redefine non-configurable property" and are swallowed by
// __bao_def — the native getters survive, and the navigator INSTANCE
// never gains own props; with configurable targets this define REPLACED
// the native getters and SIGSEGV'd under PagePool churn — see
// define_stealth_getter's bisect note).
if (__nav_t) {{
__bao_def(__nav_t, 'userAgent', {{ get: function() {{ return {ua:?}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'platform', {{ get: function() {{ return {platform:?}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'hardwareConcurrency', {{ get: function() {{ return {hwc}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'language', {{ get: function() {{ return {lang:?}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'languages', {{ get: function() {{ return {langs}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'vendor', {{ get: function() {{ return {vendor:?}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'deviceMemory', {{ get: function() {{ return {dm}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'maxTouchPoints', {{ get: function() {{ return {mtp}; }}, enumerable: true, configurable: true }});
__bao_def(__nav_t, 'webdriver', {{ get: function() {{ return false; }}, enumerable: true, configurable: true }});
}}
if (__scr_t) {{
__bao_def(__scr_t, 'width', {{ get: function() {{ return {sw}; }}, enumerable: true, configurable: true }});
__bao_def(__scr_t, 'height', {{ get: function() {{ return {sh}; }}, enumerable: true, configurable: true }});
__bao_def(__scr_t, 'availWidth', {{ get: function() {{ return {aw}; }}, enumerable: true, configurable: true }});
__bao_def(__scr_t, 'availHeight', {{ get: function() {{ return {ah}; }}, enumerable: true, configurable: true }});
__bao_def(__scr_t, 'colorDepth', {{ get: function() {{ return {cd}; }}, enumerable: true, configurable: true }});
__bao_def(__scr_t, 'pixelDepth', {{ get: function() {{ return {pd}; }}, enumerable: true, configurable: true }});
}}
// devicePixelRatio: OWN accessor on window — Chrome parity (the global's
// own descriptor), same attrs.
if (win) __bao_def(win, 'devicePixelRatio', {{ get: function() {{ return {dpr}; }}, enumerable: true, configurable: true }});
if (typeof WebGLRenderingContext === 'undefined') return;
var origGetParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(param) {{
var dbgRenderer = 0x9246;
var dbgVendor = 0x9245;
if (param === dbgRenderer) return {renderer:?};
if (param === dbgVendor) return {wgl_vendor:?};
var maxTexSize = 0x0D33;
var maxRbSize = 0x84E8;
var maxViewport = 0x0D3A;
if (param === maxTexSize) return {mts};
if (param === maxRbSize) return {mrbs};
if (param === maxViewport) return new Int32Array([{vp0}, {vp1}]);
return origGetParameter.call(this, param);
}};
var origGetSupportedExtensions = WebGLRenderingContext.prototype.getSupportedExtensions;
WebGLRenderingContext.prototype.getSupportedExtensions = function() {{
return {exts};
}};
if (typeof WebGL2RenderingContext !== 'undefined') {{
var origGetParameter2 = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function(param) {{
var dbgRenderer = 0x9246;
var dbgVendor = 0x9245;
if (param === dbgRenderer) return {renderer:?};
if (param === dbgVendor) return {wgl_vendor:?};
var maxTexSize = 0x0D33;
var maxRbSize = 0x84E8;
var maxViewport = 0x0D3A;
if (param === maxTexSize) return {mts};
if (param === maxRbSize) return {mrbs};
if (param === maxViewport) return new Int32Array([{vp0}, {vp1}]);
return origGetParameter2.call(this, param);
}};
var origGetSupportedExtensions2 = WebGL2RenderingContext.prototype.getSupportedExtensions;
WebGL2RenderingContext.prototype.getSupportedExtensions = function() {{
return {exts};
}};
}}
}})();"#,
ua = nav.user_agent,
platform = nav.platform,
hwc = nav.hardware_concurrency,
lang = nav.language,
langs = languages_json,
vendor = nav.vendor,
dm = nav.device_memory,
mtp = nav.max_touch_points,
sw = screen.width,
sh = screen.height,
aw = screen.avail_width,
ah = screen.avail_height,
cd = screen.color_depth,
pd = screen.pixel_depth,
dpr = screen.device_pixel_ratio,
renderer = webgl.renderer,
wgl_vendor = webgl.vendor,
mts = webgl.max_texture_size,
mrbs = webgl.max_renderbuffer_size,
vp0 = webgl.max_viewport_dims[0],
vp1 = webgl.max_viewport_dims[1],
exts = extensions_json,
)
}
fn build_font_js(font: &FontConfig) -> String {
let seed = font.seed;
let extra_count = font.extra_font_count;
let hidden_fonts_json =
serde_json::to_string(&font.hidden_fonts).unwrap_or_else(|_| "[]".into());
format!(
r#"(function() {{
var SEED = {seed}n;
var EXTRA_COUNT = {extra_count};
var HIDDEN_FONTS = {hidden_fonts};
// Deterministic xorshift64 PRNG
function xorshift64(state) {{
state = BigInt.asUintN(64, state);
state ^= state << 13n;
state = BigInt.asUintN(64, state);
state ^= state >> 7n;
state = BigInt.asUintN(64, state);
state ^= state << 17n;
return BigInt.asUintN(64, state);
}}
function detRand(index) {{
var s = SEED ^ (BigInt(index) * 0x2545F4914F6CDD1Dn);
s = xorshift64(s);
return Number(s) / 18446744073709551615;
}}
if (typeof document !== 'undefined' && document.fonts) {{
var origCheck = document.fonts.check.bind(document.fonts);
document.fonts.check = function(font, text) {{
for (var i = 0; i < HIDDEN_FONTS.length; i++) {{
if (font.indexOf(HIDDEN_FONTS[i]) !== -1) return false;
}}
return origCheck(font, text);
}};
var origSize = Object.getOwnPropertyDescriptor(Document.prototype, 'fonts');
if (origSize && origSize.get) {{
var origGetter = origSize.get;
Object.defineProperty(document, 'fonts', {{
get: function() {{
var fontsObj = origGetter.call(document);
var origFontsSize = Object.getOwnPropertyDescriptor(FontData.prototype, 'size');
if (origFontsSize && origFontsSize.get) {{
var origSizeGetter = origFontsSize.get;
Object.defineProperty(fontsObj, 'size', {{
get: function() {{
return origSizeGetter.call(fontsObj) + EXTRA_COUNT;
}},
configurable: true
}});
}}
return fontsObj;
}},
configurable: true
}});
}}
}}
}})();"#,
seed = seed,
extra_count = extra_count,
hidden_fonts = hidden_fonts_json,
)
}
fn build_battery_js(battery: &BatteryConfig) -> String {
let charging = battery.charging;
let level = battery.level;
let charging_time = battery.charging_time;
let discharging_time = if battery.discharging_time.is_infinite() {
"Infinity".to_string()
} else {
format!("{}", battery.discharging_time)
};
format!(
r#"(function() {{
var fixedBattery = {{
charging: {charging},
chargingTime: {charging_time},
dischargingTime: {discharging_time},
level: {level},
addEventListener: function() {{}},
removeEventListener: function() {{}},
dispatchEvent: function() {{ return true; }},
onchargingchange: null,
onchargingtimechange: null,
ondischargingtimechange: null,
onlevelchange: null
}};
if (typeof navigator !== 'undefined' && navigator.getBattery) {{
navigator.getBattery = function() {{
return Promise.resolve(fixedBattery);
}};
}}
}})();"#,
charging = charging,
charging_time = charging_time,
discharging_time = discharging_time,
level = level,
)
}
fn build_webrtc_js(mode: WebRtcMode) -> String {
match mode {
WebRtcMode::None => r#"(function() {
if (typeof RTCPeerConnection !== 'undefined') {
window.RTCPeerConnection = function() {
throw new DOMException('WebRTC is disabled', 'NotAllowedError');
};
Object.defineProperty(window, 'RTCPeerConnection', {
value: function() {
throw new DOMException('WebRTC is disabled', 'NotAllowedError');
},
configurable: false,
writable: false
});
}
if (typeof webkitRTCPeerConnection !== 'undefined') {
window.webkitRTCPeerConnection = function() {
throw new DOMException('WebRTC is disabled', 'NotAllowedError');
};
}
})();"#
.to_string(),
WebRtcMode::Default => r#"(function() {
if (typeof RTCPeerConnection !== 'undefined') {
var origAddEventListener = RTCPeerConnection.prototype.addEventListener;
RTCPeerConnection.prototype.addEventListener = function(type, listener, options) {
if (type === 'icecandidate') {
var filteredListener = function(event) {
if (event.candidate) {
var c = event.candidate.candidate || '';
// Allow mDNS candidates (end with .local) and relay candidates
if (c.indexOf('.local') !== -1 || c.indexOf('relay') !== -1) {
listener.call(this, event);
}
// Block srflx and host candidates to prevent IP leaks
} else {
listener.call(this, event);
}
};
return origAddEventListener.call(this, type, filteredListener, options);
}
return origAddEventListener.call(this, type, listener, options);
};
}
})();"#
.to_string(),
WebRtcMode::Strict => r#"(function() {
if (typeof RTCPeerConnection !== 'undefined') {
var origAddEventListener = RTCPeerConnection.prototype.addEventListener;
RTCPeerConnection.prototype.addEventListener = function(type, listener, options) {
if (type === 'icecandidate') {
var filteredListener = function(event) {
if (event.candidate) {
var c = event.candidate.candidate || '';
// Strict: only allow relay candidates
if (c.indexOf('relay') !== -1) {
listener.call(this, event);
}
} else {
listener.call(this, event);
}
};
return origAddEventListener.call(this, type, filteredListener, options);
}
return origAddEventListener.call(this, type, listener, options);
};
// Also override createOffer/createAnswer to strip local IPs from SDP
var origCreateOffer = RTCPeerConnection.prototype.createOffer;
RTCPeerConnection.prototype.createOffer = function(options) {
return origCreateOffer.call(this, options).then(function(desc) {
desc.sdp = desc.sdp.replace(/a=candidate:.*typ\s+(srflx|host).*\r?\n/g, '');
return desc;
});
};
var origCreateAnswer = RTCPeerConnection.prototype.createAnswer;
RTCPeerConnection.prototype.createAnswer = function(options) {
return origCreateAnswer.call(this, options).then(function(desc) {
desc.sdp = desc.sdp.replace(/a=candidate:.*typ\s+(srflx|host).*\r?\n/g, '');
return desc;
});
};
}
})();"#
.to_string(),
}
}
fn build_timing_js(timing: &TimingConfig) -> String {
let precision_us = timing.precision_us;
let precision_ms = precision_us as f64 / 1000.0;
format!(
r#"(function() {{
var PRECISION_MS = {precision_ms};
function roundToPrecision(t) {{
return Math.round(t / PRECISION_MS) * PRECISION_MS;
}}
if (typeof performance !== 'undefined') {{
var origNow = performance.now.bind(performance);
performance.now = function() {{
return roundToPrecision(origNow());
}};
}}
var origDateNow = Date.now;
Date.now = function() {{
return Math.round(roundToPrecision(origDateNow()));
}};
if (typeof Event !== 'undefined') {{
var origTimeStamp = Object.getOwnPropertyDescriptor(Event.prototype, 'timeStamp');
if (origTimeStamp && origTimeStamp.get) {{
var origGetter = origTimeStamp.get;
Object.defineProperty(Event.prototype, 'timeStamp', {{
get: function() {{
return roundToPrecision(origGetter.call(this));
}},
configurable: true
}});
}}
}}
}})();"#,
precision_ms = precision_ms,
)
}
fn build_clientrects_js(config: &ClientRectsConfig) -> String {
let delta = config.noise_delta;
let seed = config.seed;
format!(
r#"(function() {{
var DELTA = {delta};
var SEED = {seed}n;
function xorshift64(state) {{
state = BigInt.asUintN(64, state);
state ^= state << 13n;
state = BigInt.asUintN(64, state);
state ^= state >> 7n;
state = BigInt.asUintN(64, state);
state ^= state << 17n;
return BigInt.asUintN(64, state);
}}
function detNoise(element, field) {{
var hash = SEED;
if (typeof element === 'object' && element !== null) {{
var id = element.id || element.tagName || '';
for (var i = 0; i < id.length; i++) {{
hash ^= BigInt(id.charCodeAt(i)) * BigInt(i + 1 + 0x517CC1B727220A95n);
hash = xorshift64(hash);
}}
}}
hash ^= BigInt(field) * 0x6C62272E07BB0142n;
hash = xorshift64(hash);
return (Number(hash) / 18446744073709551615 - 0.5) * 2 * DELTA;
}}
var cache = new WeakMap();
var frameCounter = 0;
function applyNoiseToRect(rect, element) {{
if (!element || DELTA <= 0) return rect;
var result = {{
x: rect.x + detNoise(element, 0),
y: rect.y + detNoise(element, 1),
width: rect.width + detNoise(element, 2),
height: rect.height + detNoise(element, 3),
top: rect.top + detNoise(element, 4),
right: rect.right + detNoise(element, 5),
bottom: rect.bottom + detNoise(element, 6),
left: rect.left + detNoise(element, 7),
toJSON: function() {{
return {{ x: this.x, y: this.y, width: this.width, height: this.height, top: this.top, right: this.right, bottom: this.bottom, left: this.left }};
}}
}};
return result;
}}
if (typeof Element !== 'undefined') {{
var origGetBoundingClientRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function() {{
var rect = origGetBoundingClientRect.call(this);
return applyNoiseToRect(rect, this);
}};
var origGetClientRects = Element.prototype.getClientRects;
Element.prototype.getClientRects = function() {{
var rectList = origGetClientRects.call(this);
var result = [];
for (var i = 0; i < rectList.length; i++) {{
result.push(applyNoiseToRect(rectList[i], this));
}}
return result;
}};
}}
}})();"#,
delta = delta,
seed = seed,
)
}
fn build_screen_display_js(config: &ScreenDisplayConfig) -> String {
let width = config.width;
let height = config.height;
let color_depth = config.color_depth;
let dpr = config.device_pixel_ratio;
format!(
r#"(function() {{
var WIDTH = {width};
var HEIGHT = {height};
var COLOR_DEPTH = {color_depth};
var DPR = {dpr};
if (typeof screen !== 'undefined') {{
Object.defineProperty(screen, 'width', {{ get: function() {{ return WIDTH; }}, configurable: true }});
Object.defineProperty(screen, 'height', {{ get: function() {{ return HEIGHT; }}, configurable: true }});
Object.defineProperty(screen, 'availWidth', {{ get: function() {{ return WIDTH; }}, configurable: true }});
Object.defineProperty(screen, 'availHeight', {{ get: function() {{ return HEIGHT - 40; }}, configurable: true }});
Object.defineProperty(screen, 'colorDepth', {{ get: function() {{ return COLOR_DEPTH; }}, configurable: true }});
Object.defineProperty(screen, 'pixelDepth', {{ get: function() {{ return COLOR_DEPTH; }}, configurable: true }});
}}
if (typeof window !== 'undefined') {{
Object.defineProperty(window, 'devicePixelRatio', {{ get: function() {{ return DPR; }}, configurable: true }});
Object.defineProperty(window, 'outerWidth', {{ get: function() {{ return WIDTH; }}, configurable: true }});
Object.defineProperty(window, 'outerHeight', {{ get: function() {{ return HEIGHT; }}, configurable: true }});
Object.defineProperty(window, 'innerWidth', {{ get: function() {{ return WIDTH; }}, configurable: true }});
Object.defineProperty(window, 'innerHeight', {{ get: function() {{ return HEIGHT - 80; }}, configurable: true }});
Object.defineProperty(window, 'screenX', {{ get: function() {{ return 0; }}, configurable: true }});
Object.defineProperty(window, 'screenY', {{ get: function() {{ return 0; }}, configurable: true }});
Object.defineProperty(window, 'screenLeft', {{ get: function() {{ return 0; }}, configurable: true }});
Object.defineProperty(window, 'screenTop', {{ get: function() {{ return 0; }}, configurable: true }});
}}
}})();"#,
width = width,
height = height,
color_depth = color_depth,
dpr = dpr,
)
}
fn build_plugin_js(config: &PluginConfig, navigator_profile: &NavigatorProfile) -> String {
let plugins_json = serde_json::to_string(&config.plugins).unwrap_or_else(|_| "[]".into());
let mime_types_json =
serde_json::to_string(&config.mime_types).unwrap_or_else(|_| "[]".into());
let plugin_count = config.plugin_count.min(config.plugins.len() as u32);
let chrome_surface = navigator_profile.user_agent.contains("Chrome");
format!(
r#"(function() {{
var PLUGIN_COUNT = {plugin_count};
var PLUGIN_NAMES = {plugins};
var MIME_TYPES = {mime_types};
function makePlugin(name, filename, description, mimes) {{
var p = Object.create(Plugin.prototype);
Object.defineProperty(p, 'name', {{ get: function() {{ return name; }}, enumerable: true }});
Object.defineProperty(p, 'filename', {{ get: function() {{ return filename; }}, enumerable: true }});
Object.defineProperty(p, 'description', {{ get: function() {{ return description; }}, enumerable: true }});
Object.defineProperty(p, 'length', {{ get: function() {{ return mimes.length; }}, enumerable: true, configurable: true }});
for (let mi = 0; mi < mimes.length; mi++) {{
let m = mimes[mi];
Object.defineProperty(p, String(mi), {{ get: function() {{ return m; }}, enumerable: true, configurable: true }});
}}
try {{ Object.defineProperty(p, Symbol.toStringTag, {{ value: 'Plugin' }}); }} catch (e) {{}}
return p;
}}
function makeMimeType(type, suffixes, description, enabledPlugin) {{
var m = Object.create(MimeType.prototype);
Object.defineProperty(m, 'type', {{ get: function() {{ return type; }}, enumerable: true }});
Object.defineProperty(m, 'suffixes', {{ get: function() {{ return suffixes; }}, enumerable: true }});
Object.defineProperty(m, 'description', {{ get: function() {{ return description; }}, enumerable: true }});
if (enabledPlugin) {{
Object.defineProperty(m, 'enabledPlugin', {{ get: function() {{ return enabledPlugin; }}, enumerable: true }});
}}
try {{ Object.defineProperty(m, Symbol.toStringTag, {{ value: 'MimeType' }}); }} catch (e) {{}}
return m;
}}
if (typeof navigator !== 'undefined' && typeof Plugin !== 'undefined' && typeof MimeType !== 'undefined') {{
var pluginObjs = PLUGIN_NAMES.map(function(name) {{
var mimesForPlugin = MIME_TYPES.slice(0, Math.min(MIME_TYPES.length, PLUGIN_NAMES.length > 1 ? 1 : MIME_TYPES.length));
return makePlugin(name, 'internal-pdf-viewer', name, mimesForPlugin);
}});
// Wire each MimeType's enabledPlugin back to its owning fake (Chrome
// shape); the mime fakes are rebuilt so every plugin gets its own pair.
for (let pi = 0; pi < pluginObjs.length; pi++) {{
let plugin = pluginObjs[pi];
let ownMimes = MIME_TYPES.map(function(t) {{
return makeMimeType(t, t === 'application/pdf' ? 'pdf' : 'pdf', '', plugin);
}});
Object.defineProperty(plugin, 'length', {{ get: function() {{ return ownMimes.length; }}, enumerable: true }});
for (let oi = 0; oi < ownMimes.length; oi++) {{
let om = ownMimes[oi];
Object.defineProperty(plugin, String(oi), {{ get: function() {{ return om; }}, enumerable: true }});
}}
}}
var pluginsCarrier = Object.create(PluginArray.prototype);
Object.defineProperty(pluginsCarrier, 'length', {{ get: function() {{ return PLUGIN_COUNT; }}, enumerable: true }});
for (let i = 0; i < pluginObjs.length; i++) {{
let entry = pluginObjs[i];
Object.defineProperty(pluginsCarrier, String(i), {{ get: function() {{ return entry; }}, enumerable: true }});
try {{
Object.defineProperty(pluginsCarrier, entry.name, {{ get: function() {{ return entry; }}, enumerable: true }});
}} catch (e) {{}}
}}
pluginsCarrier.item = function(i) {{ return pluginObjs[i] || null; }};
pluginsCarrier.namedItem = function(name) {{
for (var j = 0; j < pluginObjs.length; j++) {{
if (pluginObjs[j] && pluginObjs[j].name === name) return pluginObjs[j];
}}
return null;
}};
pluginsCarrier.refresh = function() {{}};
try {{ Object.defineProperty(pluginsCarrier, Symbol.toStringTag, {{ value: 'PluginArray' }}); }} catch (e) {{}}
var mimeCarrier = Object.create(MimeTypeArray.prototype);
var allMimes = [];
for (let p = 0; p < pluginObjs.length; p++) {{
let pl = pluginObjs[p];
for (let q = 0; q < pl.length; q++) {{ allMimes.push(pl[q]); }}
}}
Object.defineProperty(mimeCarrier, 'length', {{ get: function() {{ return allMimes.length; }}, enumerable: true }});
for (let k = 0; k < allMimes.length; k++) {{
let km = allMimes[k];
Object.defineProperty(mimeCarrier, String(k), {{ get: function() {{ return km; }}, enumerable: true }});
try {{
Object.defineProperty(mimeCarrier, km.type, {{ get: function() {{ return km; }}, enumerable: true }});
}} catch (e) {{}}
}}
mimeCarrier.item = function(i) {{ return allMimes[i] || null; }};
mimeCarrier.namedItem = function(type) {{
for (var r = 0; r < allMimes.length; r++) {{
if (allMimes[r] && allMimes[r].type === type) return allMimes[r];
}}
return null;
}};
try {{ Object.defineProperty(mimeCarrier, Symbol.toStringTag, {{ value: 'MimeTypeArray' }}); }} catch (e) {{}}
Object.defineProperty(navigator, 'plugins', {{
get: function() {{ return pluginsCarrier; }},
configurable: true
}});
Object.defineProperty(navigator, 'mimeTypes', {{
get: function() {{ return mimeCarrier; }},
configurable: true
}});
}}
{chrome_section}
}})();"#,
plugin_count = plugin_count,
plugins = plugins_json,
mime_types = mime_types_json,
chrome_section = if chrome_surface { Self::CHROME_SURFACE_JS.trim() } else { "" },
)
}
const CHROME_SURFACE_JS: &str = r#"
// window.chrome (e130 shape, e135 oracle real-valued): bot.sannysoft.com's
// "Chrome (New)" row fails on plain absence; deeper probes (creepjs et al.)
// compare the member set, so a bare {} is not enough. Member set and every
// timestamp mirror the real vanilla-page API — navigation-timing deep
// cross-validation cannot separate them from the native one.
if (typeof window !== 'undefined' && typeof Plugin !== 'undefined' && typeof MimeType !== 'undefined'
&& !(typeof window.chrome !== 'undefined' && window.chrome)) {
function baoChromeLoadTimes() {
var t = (typeof performance !== 'undefined' && performance.timing) ? performance.timing : null;
if (!t) { return {}; }
var firstPaintMs = 0;
try {
var paints = performance.getEntriesByType('paint');
for (var i = 0; i < paints.length; i++) {
if (paints[i].name === 'first-paint') { firstPaintMs = t.navigationStart + paints[i].startTime; break; }
}
} catch (e) {}
// connection family follows the page's egress protocol: https rides
// the stealth TLS stack (h2 ALPN), plain http is the HTTP/1.1 bridge,
// non-network schemes carry no negotiated protocol
var proto = (window.location && window.location.protocol) || '';
var https = proto === 'https:';
var network = proto === 'https:' || proto === 'http:';
var navType = 'Other';
try {
var nt = (performance.navigation && typeof performance.navigation.type === 'number') ? performance.navigation.type : 0;
if (nt === 1) { navType = 'Reload'; } else if (nt === 2) { navType = 'BackForward'; }
} catch (e) {}
return {
requestTime: t.navigationStart / 1000,
startLoadTime: t.navigationStart / 1000,
commitLoadTime: t.responseStart / 1000,
finishDocumentLoadTime: t.domContentLoadedEventEnd / 1000,
finishLoadTime: t.loadEventEnd / 1000,
firstPaintTime: firstPaintMs / 1000,
firstPaintAfterLoadTime: 0,
navigationType: navType,
wasFetchedViaSpdy: https,
wasNpnNegotiated: https,
npnNegotiatedProtocol: https ? 'h2' : (network ? 'unknown' : ''),
wasAlternateProtocolAvailable: false,
connectionInfo: network ? (https ? 'h2' : 'http/1.1') : 'unknown'
};
}
function baoChromeCsi() {
var t = (typeof performance !== 'undefined' && performance.timing) ? performance.timing : null;
var start = t ? t.navigationStart : 0;
var onload = t ? t.loadEventEnd : 0;
var pageT = (typeof performance !== 'undefined' && performance.now)
? performance.now() / 1000
: (start ? Math.max(0, Date.now() - start) / 1000 : 0);
return { startE: start, onloadT: onload, pageT: pageT, tran: 15 };
}
// e141 native-toString facade carrier: every spoofed function is
// exposed as a Proxy over a shape-matched plain target. The engine
// itself renders the anonymous [native code] form for callable proxies
// on every channel — Function.prototype.toString.call(f), f.toString()
// and ''+f are all engine-generated and mutually equal, so the
// adversarial F.p.toString.call channel (which bypasses any own
// toString a per-function patch would install) is covered with zero
// patch text in the page. The trap-less proxy forwards every
// introspection trap to the target: constructible carriers carry own
// [name, prototype, length] with name '' (oracle: chrome.loadTimes /
// chrome.csi — `new` yields a plain object); method carriers use an
// arrow target (own [name, length], no prototype, `new` throws —
// oracle: the four chrome.app methods).
function baoMakeNative(impl, name, constructible) {
var target;
if (constructible) {
// plain-function target: own [name, prototype, length], `new` works
target = function() {};
} else {
// arrow target: own [name, length] only — no prototype property,
// not constructible (`new` throws), mirroring the app methods
target = () => undefined;
}
Object.defineProperty(target, 'name', { value: name, configurable: true });
return new Proxy(target, {
apply: function(t, thisArg, args) { return impl.apply(thisArg, args); }
});
}
var baoChrome = {
loadTimes: baoMakeNative(baoChromeLoadTimes, '', true),
csi: baoMakeNative(baoChromeCsi, '', true),
app: {
isInstalled: false,
getDetails: baoMakeNative(function() { return null; }, 'getDetails', false),
getIsInstalled: baoMakeNative(function() { return false; }, 'getIsInstalled', false),
installState: baoMakeNative(function(callback) {
if (typeof callback === 'function') {
setTimeout(function() { callback('not_installed'); }, 0);
}
}, 'installState', false),
runningState: baoMakeNative(function() { return 'cannot_run'; }, 'runningState', false),
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }
}
};
try {
Object.defineProperty(window, 'chrome', { value: baoChrome, writable: true, enumerable: true, configurable: false });
} catch (e) {}
}"#;
fn build_speech_js(config: &SpeechConfig) -> String {
if !config.enabled {
return String::new();
}
let voices_json = serde_json::to_string(&config.voices).unwrap_or_else(|_| "[]".into());
format!(
r#"(function() {{
var VOICE_NAMES = {voices};
function makeVoice(name, lang, localService, isDefault) {{
var v = Object.create(SpeechSynthesisVoice.prototype);
Object.defineProperty(v, 'voiceURI', {{ get: function() {{ return name; }}, enumerable: true }});
Object.defineProperty(v, 'name', {{ get: function() {{ return name; }}, enumerable: true }});
Object.defineProperty(v, 'lang', {{ get: function() {{ return lang; }}, enumerable: true }});
Object.defineProperty(v, 'localService', {{ get: function() {{ return localService; }}, enumerable: true }});
Object.defineProperty(v, 'isDefault', {{ get: function() {{ return isDefault; }}, enumerable: true }});
return v;
}}
if (typeof navigator !== 'undefined' && navigator.speechSynthesis) {{
var voiceList = VOICE_NAMES.map(function(name, idx) {{
var langMap = {{
'Google US English': 'en-US',
'Google UK English Female': 'en-GB',
'Google UK English Male': 'en-GB',
'Google Deutsch': 'de-DE',
'Google Français': 'fr-FR',
'Google Español': 'es-ES',
'Google Italiano': 'it-IT',
'Google Japanese': 'ja-JP',
'Google Nederlands': 'nl-NL',
'Google Polski': 'pl-PL',
'Google Português do Brasil': 'pt-BR',
'Google Pútonghuà': 'zh-CN'
}};
var lang = langMap[name] || 'en-US';
return makeVoice(name, lang, false, idx === 0);
}});
voiceList.push(makeVoice('Microsoft David - English (United States)', 'en-US', true, false));
voiceList.push(makeVoice('Microsoft Zira - English (United States)', 'en-US', true, false));
var origGetVoices = navigator.speechSynthesis.getVoices;
navigator.speechSynthesis.getVoices = function() {{
return voiceList;
}};
Object.defineProperty(navigator.speechSynthesis, 'speaking', {{ get: function() {{ return false; }}, configurable: true }});
Object.defineProperty(navigator.speechSynthesis, 'pending', {{ get: function() {{ return false; }}, configurable: true }});
}}
}})();"#,
voices = voices_json,
)
}
fn build_media_devices_js(config: &MediaDevicesConfig) -> String {
if !config.enabled {
return String::new();
}
let audio_input_count = config.audio_input_count;
let video_input_count = config.video_input_count;
let audio_output_count = config.audio_output_count;
format!(
r#"(function() {{
var AUDIO_IN = {audio_input_count};
var VIDEO_IN = {video_input_count};
var AUDIO_OUT = {audio_output_count};
function makeDevice(kind, label, deviceId, groupId) {{
var d = Object.create(MediaDeviceInfo.prototype);
Object.defineProperty(d, 'kind', {{ get: function() {{ return kind; }}, enumerable: true }});
Object.defineProperty(d, 'label', {{ get: function() {{ return label; }}, enumerable: true }});
Object.defineProperty(d, 'deviceId', {{ get: function() {{ return deviceId; }}, enumerable: true }});
Object.defineProperty(d, 'groupId', {{ get: function() {{ return groupId; }}, enumerable: true }});
d.toJSON = function() {{
return {{ kind: kind, label: label, deviceId: deviceId, groupId: groupId }};
}};
return d;
}}
if (typeof navigator !== 'undefined' && navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {{
var origEnumerate = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices);
navigator.mediaDevices.enumerateDevices = function() {{
return origEnumerate().then(function(realDevices) {{
var hasLabels = realDevices.some(function(d) {{ return d.label && d.label.length > 0; }});
if (hasLabels) return realDevices;
var devices = [];
for (var i = 0; i < AUDIO_IN; i++) {{
devices.push(makeDevice('audioinput', '', 'audioinput-' + i, 'group-' + i));
}}
for (var i = 0; i < VIDEO_IN; i++) {{
devices.push(makeDevice('videoinput', '', 'videoinput-' + i, 'group-' + (AUDIO_IN + i)));
}}
for (var i = 0; i < AUDIO_OUT; i++) {{
devices.push(makeDevice('audiooutput', '', 'audiooutput-' + i, 'group-' + (AUDIO_IN + VIDEO_IN + i)));
}}
return devices;
}}).catch(function() {{
var devices = [];
for (var i = 0; i < AUDIO_IN; i++) {{
devices.push(makeDevice('audioinput', '', 'audioinput-' + i, 'group-' + i));
}}
for (var i = 0; i < VIDEO_IN; i++) {{
devices.push(makeDevice('videoinput', '', 'videoinput-' + i, 'group-' + (AUDIO_IN + i)));
}}
for (var i = 0; i < AUDIO_OUT; i++) {{
devices.push(makeDevice('audiooutput', '', 'audiooutput-' + i, 'group-' + (AUDIO_IN + VIDEO_IN + i)));
}}
return devices;
}});
}};
}}
}})();"#,
audio_input_count = audio_input_count,
video_input_count = video_input_count,
audio_output_count = audio_output_count,
)
}
fn build_permissions_js(_config: &PermissionsConfig) -> String {
String::new()
}
fn build_webgl_context_js(config: &WebGLContextConfig) -> String {
if !config.enabled {
return String::new();
}
let antialias = config.antialias;
let depth = config.depth;
let stencil = config.stencil;
let alpha = config.alpha;
let premultiplied_alpha = config.premultiplied_alpha;
let preserve_drawing_buffer = config.preserve_drawing_buffer;
let power_preference = &config.power_preference;
let fail_if_major_performance_caveat = config.fail_if_major_performance_caveat;
format!(
r#"(function() {{
var CONTEXT_ATTRS = {{
antialias: {antialias},
depth: {depth},
stencil: {stencil},
alpha: {alpha},
premultipliedAlpha: {premultiplied_alpha},
preserveDrawingBuffer: {preserve_drawing_buffer},
powerPreference: {power_preference:?},
failIfMajorPerformanceCaveat: {fail_if_major_performance_caveat}
}};
if (typeof WebGLRenderingContext !== 'undefined') {{
var origGetContextAttributes = WebGLRenderingContext.prototype.getContextAttributes;
WebGLRenderingContext.prototype.getContextAttributes = function() {{
if (origGetContextAttributes) {{
try {{
var real = origGetContextAttributes.call(this);
if (real) {{
return {{
antialias: CONTEXT_ATTRS.antialias,
depth: CONTEXT_ATTRS.depth,
stencil: CONTEXT_ATTRS.stencil,
alpha: CONTEXT_ATTRS.alpha,
premultipliedAlpha: CONTEXT_ATTRS.premultipliedAlpha,
preserveDrawingBuffer: CONTEXT_ATTRS.preserveDrawingBuffer,
powerPreference: CONTEXT_ATTRS.powerPreference,
failIfMajorPerformanceCaveat: CONTEXT_ATTRS.failIfMajorPerformanceCaveat
}};
}}
}} catch(e) {{}}
}}
return CONTEXT_ATTRS;
}};
}}
if (typeof WebGL2RenderingContext !== 'undefined') {{
var origGetContextAttributes2 = WebGL2RenderingContext.prototype.getContextAttributes;
WebGL2RenderingContext.prototype.getContextAttributes = function() {{
if (origGetContextAttributes2) {{
try {{
var real = origGetContextAttributes2.call(this);
if (real) {{
return {{
antialias: CONTEXT_ATTRS.antialias,
depth: CONTEXT_ATTRS.depth,
stencil: CONTEXT_ATTRS.stencil,
alpha: CONTEXT_ATTRS.alpha,
premultipliedAlpha: CONTEXT_ATTRS.premultipliedAlpha,
preserveDrawingBuffer: CONTEXT_ATTRS.preserveDrawingBuffer,
powerPreference: CONTEXT_ATTRS.powerPreference,
failIfMajorPerformanceCaveat: CONTEXT_ATTRS.failIfMajorPerformanceCaveat
}};
}}
}} catch(e) {{}}
}}
return CONTEXT_ATTRS;
}};
}}
}})();"#,
antialias = antialias,
depth = depth,
stencil = stencil,
alpha = alpha,
premultiplied_alpha = premultiplied_alpha,
preserve_drawing_buffer = preserve_drawing_buffer,
power_preference = power_preference,
fail_if_major_performance_caveat = fail_if_major_performance_caveat,
)
}
fn build_connection_js(config: &ConnectionConfig) -> String {
if !config.enabled {
return String::new();
}
let effective_type = &config.effective_type;
let downlink = config.downlink;
let rtt = config.rtt;
let save_data = config.save_data;
format!(
r#"(function() {{
var EFFECTIVE_TYPE = {effective_type:?};
var DOWNLINK = {downlink};
var RTT = {rtt};
var SAVE_DATA = {save_data};
if (typeof navigator !== 'undefined') {{
var connObj = {{
effectiveType: EFFECTIVE_TYPE,
downlink: DOWNLINK,
rtt: RTT,
saveData: SAVE_DATA,
type: 'wifi',
onchange: null,
addEventListener: function(type, listener) {{
if (type === 'change') this.onchange = listener;
}},
removeEventListener: function(type) {{
if (type === 'change') this.onchange = null;
}},
dispatchEvent: function() {{ return true; }}
}};
if (navigator.connection) {{
try {{
Object.defineProperty(navigator.connection, 'effectiveType', {{ get: function() {{ return EFFECTIVE_TYPE; }}, configurable: true }});
Object.defineProperty(navigator.connection, 'downlink', {{ get: function() {{ return DOWNLINK; }}, configurable: true }});
Object.defineProperty(navigator.connection, 'rtt', {{ get: function() {{ return RTT; }}, configurable: true }});
Object.defineProperty(navigator.connection, 'saveData', {{ get: function() {{ return SAVE_DATA; }}, configurable: true }});
}} catch(e) {{
Object.defineProperty(navigator, 'connection', {{ get: function() {{ return connObj; }}, configurable: true }});
}}
}} else {{
Object.defineProperty(navigator, 'connection', {{ get: function() {{ return connObj; }}, configurable: true }});
}}
}}
}})();"#,
effective_type = effective_type,
downlink = downlink,
rtt = rtt,
save_data = save_data,
)
}
fn build_iframe_js(config: &IframeConfig) -> String {
if !config.enabled {
return String::new();
}
r#"(function() {
if (typeof HTMLIFrameElement === 'undefined') return;
var origContentWindow = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
var origContentDocument = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentDocument');
if (origContentWindow && origContentWindow.get) {
var origCWGetter = origContentWindow.get;
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
get: function() {
var win = origCWGetter.call(this);
if (!win) return null;
try {
var doc = win.document;
return win;
} catch(e) {
return win;
}
},
configurable: true
});
}
if (origContentDocument && origContentDocument.get) {
var origCDGetter = origContentDocument.get;
Object.defineProperty(HTMLIFrameElement.prototype, 'contentDocument', {
get: function() {
try {
return origCDGetter.call(this);
} catch(e) {
return null;
}
},
configurable: true
});
}
})();"#.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canvas::CanvasNoise;
use crate::navigator::{NavigatorProfile, ScreenProfile};
use crate::profile::StealthProfile;
use crate::webgl_audio::{AudioProfile, WebGLProfile};
fn firefox_hooks() -> StealthHooks {
let profile = StealthProfile::firefox_default();
StealthHooks::from_profile(
&profile.canvas,
&profile.audio,
&profile.navigator,
&profile.screen,
&profile.webgl,
&profile.font,
&profile.battery,
profile.webrtc_mode,
&profile.timing,
&profile.clientrects,
&profile.screen_display,
&profile.plugin,
&profile.speech,
&profile.media_devices,
&profile.permissions,
&profile.webgl_context,
&profile.connection,
&profile.iframe,
)
}
fn chrome_hooks() -> StealthHooks {
let profile = StealthProfile::chrome_default();
StealthHooks::from_profile(
&profile.canvas,
&profile.audio,
&profile.navigator,
&profile.screen,
&profile.webgl,
&profile.font,
&profile.battery,
profile.webrtc_mode,
&profile.timing,
&profile.clientrects,
&profile.screen_display,
&profile.plugin,
&profile.speech,
&profile.media_devices,
&profile.permissions,
&profile.webgl_context,
&profile.connection,
&profile.iframe,
)
}
#[test]
fn canvas_js_segment_retired_zero_interception_in_combined() {
let hooks = firefox_hooks();
let combined = hooks.combined_js();
for residue in [
"HTMLCanvasElement.prototype.toDataURL",
"origToDataURL",
"HTMLCanvasElement.prototype.toBlob",
"origToBlob",
"CanvasRenderingContext2D.prototype.getImageData",
"origGetImageData",
"OffscreenCanvasRenderingContext2D.prototype.getImageData",
"origOffscreenGetImageData",
"addNoiseToImageData",
] {
assert!(
!combined.contains(residue),
"retired canvas JS hook residue in combined blob: {residue}"
);
}
}
#[test]
fn canvas_js_segment_retired_worker_and_window_blobs_clean() {
let hooks = firefox_hooks();
let combined = hooks.combined_js();
assert!(
!combined.contains("typeof HTMLCanvasElement"),
"canvas blob typeof guards retired with the segment"
);
assert!(
!combined.contains("typeof OffscreenCanvasRenderingContext2D"),
"Offscreen canvas patch retired with the segment"
);
assert!(
combined.contains("typeof AudioBuffer !== 'undefined'"),
"audio worker-realm guard must survive the canvas retirement"
);
}
#[test]
fn from_profile_keeps_canvas_seed_parameter_for_paint_layer() {
let canvas = CanvasNoise::new(4242);
let audio = AudioProfile::new(42);
let nav = NavigatorProfile::firefox();
let screen = ScreenProfile::new(1920, 1080, 1.0);
let webgl = WebGLProfile::firefox();
let font = FontConfig::new(42);
let battery = BatteryConfig::default();
let timing = TimingConfig::default();
let clientrects = ClientRectsConfig::default();
let screen_display = ScreenDisplayConfig::default();
let plugin = PluginConfig::default();
let speech = SpeechConfig::default();
let media_devices = MediaDevicesConfig::default();
let permissions = PermissionsConfig::default();
let webgl_context = WebGLContextConfig::default();
let connection = ConnectionConfig::default();
let iframe = IframeConfig::default();
let hooks = StealthHooks::from_profile(
&canvas,
&audio,
&nav,
&screen,
&webgl,
&font,
&battery,
WebRtcMode::Default,
&timing,
&clientrects,
&screen_display,
&plugin,
&speech,
&media_devices,
&permissions,
&webgl_context,
&connection,
&iframe,
);
assert_eq!(canvas.seed(), 4242);
assert!(
!hooks.combined_js().is_empty(),
"non-canvas hooks must still generate"
);
}
#[test]
fn audio_js_contains_getchanneldata_override() {
let hooks = firefox_hooks();
let js = hooks.audio_js();
assert!(
js.contains("AudioBuffer.prototype.getChannelData"),
"audio JS must override getChannelData"
);
assert!(
js.contains("origGetChannelData"),
"audio JS must store original getChannelData"
);
}
#[test]
fn audio_js_contains_startrendering_override() {
let hooks = firefox_hooks();
let js = hooks.audio_js();
assert!(
js.contains("OfflineAudioContext.prototype.startRendering"),
"audio JS must override startRendering"
);
}
#[test]
fn audio_js_contains_noise_algorithm() {
let hooks = firefox_hooks();
let js = hooks.audio_js();
assert!(
js.contains("detNoise"),
"audio JS must have deterministic noise function"
);
assert!(
js.contains("0x517CC1B727220A95n"),
"audio JS must contain the index-multiply constant"
);
}
#[test]
fn audio_js_worker_safe_guards_audiobuffer() {
let hooks = firefox_hooks();
let js = hooks.audio_js();
assert!(
js.contains("typeof AudioBuffer !== 'undefined'"),
"audio JS must guard AudioBuffer behind typeof (worker realm has no such global)"
);
}
#[test]
fn navigator_js_contains_user_agent_defineproperty() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("__bao_def(__nav_t, 'userAgent'"),
"navigator JS must define userAgent"
);
assert!(
js.contains("var __bao_def = function(obj, name, desc)"),
"navigator JS defines must be guarded by __bao_def"
);
}
#[test]
fn navigator_js_contains_platform_defineproperty() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("__bao_def(__nav_t, 'platform'"),
"navigator JS must define platform"
);
}
#[test]
fn navigator_js_contains_hardware_concurrency_defineproperty() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("__bao_def(__nav_t, 'hardwareConcurrency'"),
"navigator JS must define hardwareConcurrency"
);
}
#[test]
fn navigator_js_contains_webdriver_false() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("__bao_def(__nav_t, 'webdriver'") && js.contains("return false"),
"navigator JS must set webdriver to false"
);
}
#[test]
fn navigator_js_contains_screen_overrides() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("__bao_def(__scr_t, 'width'"),
"navigator JS must define screen.width"
);
assert!(
js.contains("__bao_def(__scr_t, 'height'"),
"navigator JS must define screen.height"
);
assert!(
js.contains("__bao_def(win, 'devicePixelRatio'"),
"navigator JS must define window.devicePixelRatio"
);
}
#[test]
fn navigator_js_contains_webgl_getparameter_override() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("WebGLRenderingContext.prototype.getParameter"),
"navigator JS must override WebGL getParameter"
);
}
#[test]
fn navigator_js_contains_webgl_getsupportedextensions_override() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("WebGLRenderingContext.prototype.getSupportedExtensions"),
"navigator JS must override getSupportedExtensions"
);
}
#[test]
fn navigator_js_contains_webgl2_overrides() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("WebGL2RenderingContext.prototype.getParameter"),
"navigator JS must override WebGL2 getParameter"
);
assert!(
js.contains("WebGL2RenderingContext.prototype.getSupportedExtensions"),
"navigator JS must override WebGL2 getSupportedExtensions"
);
}
#[test]
fn combined_js_concatenates_all() {
let hooks = firefox_hooks();
let combined = hooks.combined_js();
assert!(
!combined.contains("HTMLCanvasElement.prototype.toDataURL"),
"combined JS must NOT contain retired canvas hooks"
);
assert!(
combined.contains("AudioBuffer.prototype.getChannelData"),
"combined JS must contain audio hooks"
);
assert!(
combined.contains("__bao_def(__nav_t, 'userAgent'"),
"combined JS must contain navigator hooks"
);
assert!(
combined.contains("document.fonts.check"),
"combined JS must contain font hooks"
);
assert!(
combined.contains("navigator.getBattery"),
"combined JS must contain battery hooks"
);
assert!(
combined.contains("RTCPeerConnection"),
"combined JS must contain WebRTC hooks"
);
assert!(
combined.contains("performance.now"),
"combined JS must contain timing hooks"
);
assert!(
combined.contains("getBoundingClientRect"),
"combined JS must contain clientrects hooks"
);
assert!(
combined.contains("screen_display_js") || combined.contains("screen, 'width'"),
"combined JS must contain screen/display hooks"
);
assert!(
combined.contains("PLUGIN_COUNT"),
"combined JS must contain plugin hooks"
);
assert!(
combined.contains("getVoices"),
"combined JS must contain speech hooks"
);
assert!(
combined.contains("enumerateDevices"),
"combined JS must contain media devices hooks"
);
assert!(
!combined.contains("navigator.permissions.query ="),
"combined JS must NOT wrap the native navigator.permissions.query (e130 retirement)"
);
assert!(
combined.contains("getContextAttributes"),
"combined JS must contain WebGL context hooks"
);
assert!(
combined.contains("effectiveType"),
"combined JS must contain connection hooks"
);
assert!(
combined.contains("contentWindow"),
"combined JS must contain iframe hooks"
);
assert!(
!combined.contains("Object.defineProperty(window, 'chrome'"),
"combined JS (firefox profile) must NOT define window.chrome"
);
}
#[test]
fn chrome_profile_plugin_js_carries_window_chrome() {
let hooks = chrome_hooks();
let js = hooks.plugin_js();
assert!(
js.contains("Object.defineProperty(window, 'chrome'"),
"chrome-profile plugin payload must define window.chrome (sannysoft Chrome row)"
);
for member in ["loadTimes", "csi", "app"] {
assert!(
js.contains(&format!("{}:", member)),
"window.chrome spoof must carry the {} member (real Chrome shape)",
member
);
}
let load_times_idx = js.find("loadTimes:").expect("loadTimes key");
let csi_idx = js.find("csi:").expect("csi key");
let app_idx = js.find("app:").expect("app key");
assert!(
load_times_idx < csi_idx && csi_idx < app_idx,
"window.chrome member order must be loadTimes, csi, app (real own-key order)"
);
assert!(
!js.contains("runtime:"),
"window.chrome must NOT carry a runtime member — real vanilla-page Chrome has \
none and its presence is an automation signal (engine_props deletes it)"
);
assert!(
js.contains("installState"),
"window.chrome.app must carry installState (documented vanilla-page shape)"
);
assert!(
js.contains("runningState"),
"window.chrome.app must carry runningState (e135 oracle member)"
);
assert!(
js.contains("'cannot_run'"),
"window.chrome.app.runningState must return 'cannot_run' (vanilla page)"
);
assert!(
js.contains("typeof window !== 'undefined'"),
"window.chrome section must guard on typeof window (worker realms)"
);
}
#[test]
fn chrome_surface_loadtimes_csi_derive_from_real_timing() {
let hooks = chrome_hooks();
let js = hooks.plugin_js();
for source in [
"t.navigationStart / 1000",
"t.responseStart / 1000",
"t.domContentLoadedEventEnd / 1000",
"t.loadEventEnd / 1000",
] {
assert!(
js.contains(source),
"loadTimes must derive its timestamps from the real timing field ({})",
source
);
}
assert!(
js.contains("getEntriesByType('paint')"),
"firstPaintTime must come from the paint-timing entries (0 before first paint)"
);
assert!(
js.contains("onloadT:"),
"csi must use the real key onloadT (oracle: startE/onloadT/pageT/tran)"
);
assert!(
!js.contains("onloadE:"),
"csi must NOT use the wrong key onloadE — real Chrome has onloadT"
);
assert!(
js.contains("performance.now() / 1000"),
"csi.pageT must be the elapsed time at call (performance.now based)"
);
for synthetic in ["+ 0.048", "+ 0.212", "+ 0.247", "+ 0.156"] {
assert!(
!js.contains(synthetic),
"loadTimes must not carry the synthetic fixed offset {} (navigation-timing \
cross-validation detects it)",
synthetic
);
}
assert!(
js.contains("'http/1.1'") && js.contains("'unknown'"),
"loadTimes connection family must cover the plain-http shape (http/1.1 + unknown)"
);
assert!(
js.contains("setTimeout(function() { callback('not_installed'); }, 0);"),
"app.installState must invoke its callback asynchronously (oracle form)"
);
}
#[test]
fn firefox_profile_plugin_js_has_no_window_chrome() {
let hooks = firefox_hooks();
let js = hooks.plugin_js();
assert!(
!js.contains("Object.defineProperty(window, 'chrome'"),
"firefox-profile plugin payload must NOT define window.chrome — real Firefox \
exposes none, presence under a Firefox UA is itself a detection signal"
);
}
#[test]
fn permissions_js_retired_empty() {
assert!(
chrome_hooks().permissions_js().is_empty(),
"permissions_js must be retired (empty) for the chrome profile"
);
assert!(
firefox_hooks().permissions_js().is_empty(),
"permissions_js must be retired (empty) for the firefox profile"
);
}
#[test]
fn different_profiles_produce_different_audio_js() {
let ff = firefox_hooks();
let ch = chrome_hooks();
assert_ne!(
ff.audio_js(),
ch.audio_js(),
"Firefox and Chrome should produce different audio JS (different seeds)"
);
}
#[test]
fn different_profiles_produce_different_navigator_js() {
let ff = firefox_hooks();
let ch = chrome_hooks();
assert_ne!(
ff.navigator_js(),
ch.navigator_js(),
"Firefox and Chrome should produce different navigator JS (different UA/vendor)"
);
}
#[test]
fn firefox_profile_user_agent_in_js() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("Firefox/128.0"),
"Firefox navigator JS must contain Firefox user agent string"
);
}
#[test]
fn chrome_profile_user_agent_in_js() {
let hooks = chrome_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("Chrome/128.0.0.0"),
"Chrome navigator JS must contain Chrome user agent string"
);
}
#[test]
fn firefox_vendor_empty_in_js() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("return \"\"") || js.contains("return ''"),
"Firefox navigator JS must have empty vendor"
);
}
#[test]
fn chrome_vendor_google_in_js() {
let hooks = chrome_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("Google Inc."),
"Chrome navigator JS must have Google Inc. vendor"
);
}
#[test]
fn audio_noise_algorithm_matches_rust_constants() {
let hooks = firefox_hooks();
let js = hooks.audio_js();
assert!(
js.contains("0x517CC1B727220A95n"),
"Audio JS must use same index-multiply constant as Rust AudioProfile"
);
assert!(
js.contains("0x2545F4914F6CDD1Dn"),
"Audio JS must use same multiply constant as Rust AudioProfile"
);
}
#[test]
fn audio_js_amplitude_matches_profile() {
let audio = AudioProfile::new(42);
let js = StealthHooks::build_audio_js(&audio);
assert!(
js.contains("1e-7") || js.contains("0.0000001"),
"Audio JS must contain the default noise amplitude 1e-7"
);
}
#[test]
fn custom_audio_seed_appears_in_js() {
let audio = AudioProfile::new(7777);
let js = StealthHooks::build_audio_js(&audio);
assert!(
js.contains("7777n"),
"Audio JS must contain the custom seed as BigInt"
);
}
#[test]
fn navigator_js_firefox_screen_values() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("1920") && js.contains("1080"),
"Navigator JS must contain default screen dimensions"
);
}
#[test]
fn navigator_js_firefox_webgl_vendor() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("Mozilla"),
"Firefox navigator JS must contain WebGL vendor 'Mozilla'"
);
}
#[test]
fn navigator_js_chrome_webgl_vendor() {
let hooks = chrome_hooks();
let js = hooks.navigator_js();
assert!(
js.contains("Google Inc. (NVIDIA)"),
"Chrome navigator JS must contain WebGL vendor"
);
}
#[test]
fn hooks_from_profile_custom_screen() {
let canvas = CanvasNoise::new(42);
let audio = AudioProfile::new(42);
let nav = NavigatorProfile::firefox();
let screen = ScreenProfile::new(800, 600, 2.0);
let webgl = WebGLProfile::firefox();
let font = FontConfig::new(42);
let battery = BatteryConfig::default();
let timing = TimingConfig::default();
let clientrects = ClientRectsConfig::default();
let screen_display = ScreenDisplayConfig::default();
let plugin = PluginConfig::default();
let speech = SpeechConfig::default();
let media_devices = MediaDevicesConfig::default();
let permissions = PermissionsConfig::default();
let webgl_context = WebGLContextConfig::default();
let connection = ConnectionConfig::default();
let iframe = IframeConfig::default();
let hooks = StealthHooks::from_profile(
&canvas,
&audio,
&nav,
&screen,
&webgl,
&font,
&battery,
WebRtcMode::Default,
&timing,
&clientrects,
&screen_display,
&plugin,
&speech,
&media_devices,
&permissions,
&webgl_context,
&connection,
&iframe,
);
let js = hooks.navigator_js();
assert!(
js.contains("800") && js.contains("600"),
"Custom screen dimensions must appear in navigator JS"
);
assert!(
js.contains("2"),
"Custom device pixel ratio must appear in navigator JS"
);
}
#[test]
fn audio_js_is_valid_iife() {
let hooks = firefox_hooks();
let js = hooks.audio_js();
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"Audio JS must be an IIFE"
);
assert!(js.ends_with("})();"), "Audio JS must end with IIFE closure");
}
#[test]
fn navigator_js_is_valid_iife() {
let hooks = firefox_hooks();
let js = hooks.navigator_js();
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"Navigator JS must be an IIFE"
);
assert!(
js.ends_with("})();"),
"Navigator JS must end with IIFE closure"
);
}
#[test]
fn font_js_contains_fonts_check_override() {
let hooks = firefox_hooks();
let js = hooks.font_js();
assert!(
js.contains("document.fonts.check"),
"font JS must override document.fonts.check"
);
}
#[test]
fn font_js_contains_seed() {
let hooks = firefox_hooks();
let js = hooks.font_js();
assert!(
js.contains("42n"),
"font JS must contain the seed as BigInt"
);
}
#[test]
fn font_js_is_valid_iife() {
let hooks = firefox_hooks();
let js = hooks.font_js();
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"Font JS must be an IIFE"
);
assert!(js.ends_with("})();"), "Font JS must end with IIFE closure");
}
#[test]
fn battery_js_contains_getbattery_override() {
let hooks = firefox_hooks();
let js = hooks.battery_js();
assert!(
js.contains("navigator.getBattery"),
"battery JS must override navigator.getBattery"
);
assert!(
js.contains("Promise.resolve"),
"battery JS must return a resolved Promise"
);
}
#[test]
fn battery_js_default_values() {
let hooks = firefox_hooks();
let js = hooks.battery_js();
assert!(
js.contains("charging: true"),
"battery JS must have charging=true by default"
);
assert!(
js.contains("level: 1"),
"battery JS must have level=1 by default"
);
assert!(
js.contains("Infinity"),
"battery JS must have dischargingTime=Infinity by default"
);
}
#[test]
fn battery_js_is_valid_iife() {
let hooks = firefox_hooks();
let js = hooks.battery_js();
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"Battery JS must be an IIFE"
);
assert!(
js.ends_with("})();"),
"Battery JS must end with IIFE closure"
);
}
#[test]
fn webrtc_js_default_mode_filters_candidates() {
let hooks = firefox_hooks();
let js = hooks.webrtc_js();
assert!(
js.contains("RTCPeerConnection"),
"WebRTC JS must reference RTCPeerConnection"
);
assert!(
js.contains("icecandidate"),
"WebRTC Default mode must filter ICE candidates"
);
}
#[test]
fn webrtc_js_none_mode_throws_error() {
let js = StealthHooks::build_webrtc_js(WebRtcMode::None);
assert!(
js.contains("NotAllowedError"),
"WebRTC None mode must throw NotAllowedError"
);
assert!(
js.contains("WebRTC is disabled"),
"WebRTC None mode must have descriptive error message"
);
}
#[test]
fn webrtc_js_strict_mode_strips_sdp() {
let js = StealthHooks::build_webrtc_js(WebRtcMode::Strict);
assert!(
js.contains("createOffer"),
"WebRTC Strict mode must override createOffer"
);
assert!(
js.contains("createAnswer"),
"WebRTC Strict mode must override createAnswer"
);
assert!(
js.contains("srflx"),
"WebRTC Strict mode must strip srflx candidates from SDP"
);
}
fn assert_iife_js(js: &str, label: &str) {
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"{label} JS must be an IIFE"
);
assert!(js.ends_with("})();"), "{label} JS must end with IIFE closure");
}
#[test]
fn webrtc_and_timing_js_are_valid_iife() {
let hooks = firefox_hooks();
assert_iife_js(&hooks.webrtc_js(), "WebRTC");
assert_iife_js(&hooks.timing_js(), "Timing");
}
#[test]
fn timing_js_contains_performance_now_override() {
let hooks = firefox_hooks();
let js = hooks.timing_js();
assert!(
js.contains("performance.now"),
"timing JS must override performance.now"
);
assert!(js.contains("Date.now"), "timing JS must override Date.now");
assert!(
js.contains("timeStamp"),
"timing JS must override Event.timeStamp"
);
}
#[test]
fn timing_js_default_precision() {
let hooks = firefox_hooks();
let js = hooks.timing_js();
assert!(
js.contains("0.1"),
"timing JS must contain default precision 0.1ms"
);
}
#[test]
fn clientrects_js_contains_getboundingclientrect_override() {
let hooks = firefox_hooks();
let js = hooks.clientrects_js();
assert!(
js.contains("getBoundingClientRect"),
"clientrects JS must override getBoundingClientRect"
);
assert!(
js.contains("getClientRects"),
"clientrects JS must override getClientRects"
);
}
#[test]
fn clientrects_js_contains_noise_delta() {
let hooks = firefox_hooks();
let js = hooks.clientrects_js();
assert!(
js.contains("0.5"),
"clientrects JS must contain default noise delta 0.5"
);
}
#[test]
fn clientrects_js_is_valid_iife() {
let hooks = firefox_hooks();
let js = hooks.clientrects_js();
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"ClientRects JS must be an IIFE"
);
assert!(
js.ends_with("})();"),
"ClientRects JS must end with IIFE closure"
);
}
#[test]
fn screen_display_js_contains_screen_overrides() {
let hooks = firefox_hooks();
let js = hooks.screen_display_js();
assert!(
js.contains("screen, 'width'"),
"screen display JS must override screen.width"
);
assert!(
js.contains("screen, 'height'"),
"screen display JS must override screen.height"
);
assert!(
js.contains("devicePixelRatio"),
"screen display JS must override devicePixelRatio"
);
}
#[test]
fn screen_display_js_default_values() {
let hooks = firefox_hooks();
let js = hooks.screen_display_js();
assert!(
js.contains("1920") && js.contains("1080"),
"screen display JS must contain default 1920x1080"
);
assert!(
js.contains("24"),
"screen display JS must contain default colorDepth 24"
);
}
#[test]
fn screen_display_js_is_valid_iife() {
let hooks = firefox_hooks();
let js = hooks.screen_display_js();
assert!(
js.starts_with("(function() {") || js.starts_with("(function(){{"),
"Screen display JS must be an IIFE"
);
assert!(
js.ends_with("})();"),
"Screen display JS must end with IIFE closure"
);
}
#[test]
fn different_profiles_produce_different_font_js() {
let ff = firefox_hooks();
let ch = chrome_hooks();
assert_ne!(
ff.font_js(),
ch.font_js(),
"Firefox and Chrome should produce different font JS (different seeds)"
);
}
#[test]
fn different_profiles_produce_different_clientrects_js() {
let ff = firefox_hooks();
let ch = chrome_hooks();
assert_ne!(
ff.clientrects_js(),
ch.clientrects_js(),
"Firefox and Chrome should produce different clientrects JS (different seeds)"
);
}
#[test]
fn chrome_surface_spoofed_functions_ride_native_carriers() {
let hooks = chrome_hooks();
let js = hooks.plugin_js();
for carrier in [
"baoMakeNative(baoChromeLoadTimes, '', true)",
"baoMakeNative(baoChromeCsi, '', true)",
] {
assert!(
js.contains(carrier),
"loadTimes/csi must ride the constructible native carrier ({})",
carrier
);
}
for method in ["getDetails", "getIsInstalled", "installState", "runningState"] {
assert!(
js.contains(&format!("'{}', false)", method)),
"app.{} must ride the non-constructible native carrier",
method
);
}
let code_only: String = StealthHooks::CHROME_SURFACE_JS
.lines()
.map(|l| match l.find("//") {
Some(idx) => &l[..idx],
None => l,
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!code_only.contains("toString"),
"the chrome surface must not install any toString facade of its own — \
the carrier's engine-rendered native form covers every channel"
);
assert!(
!code_only.contains("Function.prototype.toString"),
"the chrome surface must not touch Function.prototype.toString"
);
}
#[test]
fn chrome_surface_native_tostring_facade_oracle_parity() {
use bao_engine::context::JsContext;
use bao_engine::value::JsValue;
let mut ctx = JsContext::for_test().expect("JsContext");
let probe = format!(
r#"(function() {{
var window = {{ location: {{ protocol: 'https:' }} }};
var Plugin = function() {{}};
var MimeType = function() {{}};
var performance = {{
timing: {{ navigationStart: 1759000000000, responseStart: 1759000000100,
domContentLoadedEventEnd: 1759000000500, loadEventEnd: 1759000000900 }},
getEntriesByType: function(t) {{ return t === 'paint' ? [{{ name: 'first-paint', startTime: 200 }}] : []; }},
navigation: {{ type: 1 }},
now: function() {{ return 1234.5; }}
}};
var pumped = [];
var setTimeout = function(fn, ms) {{ pumped.push(fn); }};
{section}
var ch = window.chrome;
var fns = {{
loadTimes: ch.loadTimes, csi: ch.csi,
getDetails: ch.app.getDetails, getIsInstalled: ch.app.getIsInstalled,
installState: ch.app.installState, runningState: ch.app.runningState
}};
var R = {{ channels: {{}}, constructible: {{}}, nameShape: {{}} }};
var leaks = [];
Object.keys(fns).forEach(function(k) {{
var f = fns[k];
var call = Function.prototype.toString.call(f);
var method = f.toString();
var coerce = '' + f;
R.channels[k] = {{
native: call.indexOf('[native code]') !== -1 && method.indexOf('[native code]') !== -1
&& coerce.indexOf('[native code]') !== -1,
equal: call === method && method === coerce,
ownToString: !!Object.getOwnPropertyDescriptor(f, 'toString')
}};
if (call.indexOf('bao') !== -1 || method.indexOf('bao') !== -1 || coerce.indexOf('bao') !== -1) {{
leaks.push(k);
}}
R.constructible[k] = {{
new: (function() {{ try {{ new f(); return 'yes'; }} catch (e) {{ return 'throws'; }} }})(),
hasPrototype: Object.getOwnPropertyNames(f).indexOf('prototype') !== -1
}};
R.nameShape[k] = {{
name: f.name, length: f.length,
own: JSON.stringify(Object.getOwnPropertyNames(f))
}};
}});
R.leaks = leaks;
R.chromeKeys = Object.keys(ch);
R.appKeys = Object.keys(ch.app);
R.values = {{
loadTimes: ch.loadTimes(),
csi: ch.csi(),
getDetails: ch.app.getDetails(),
getIsInstalled: ch.app.getIsInstalled(),
runningState: ch.app.runningState()
}};
ch.app.installState(function(s) {{ R.installStateCb = s; }});
R.installStateAsync = pumped.length === 1;
if (pumped.length) {{ pumped[0](); }}
R.globalUntouched = {{
plainSrcVisible: Function.prototype.toString.call(function marker() {{ return 2; }})
.indexOf('return 2') !== -1,
selfNative: Function.prototype.toString.call(Function.prototype.toString)
.indexOf('[native code]') !== -1
}};
return JSON.stringify(R);
}})();"#,
section = StealthHooks::CHROME_SURFACE_JS.trim()
);
let raw = match ctx.eval(&probe, "<e141-chrome-facade>") {
Ok(JsValue::String(s)) => s,
other => panic!("probe eval failed: {:?}", other),
};
JsContext::shutdown_thread_sm();
let r: serde_json::Value = serde_json::from_str(&raw).expect("probe JSON");
for k in [
"loadTimes",
"csi",
"getDetails",
"getIsInstalled",
"installState",
"runningState",
] {
let c = &r["channels"][k];
assert_eq!(
c["native"].as_bool(),
Some(true),
"{}: every toString channel must render the [native code] form",
k
);
assert_eq!(
c["equal"].as_bool(),
Some(true),
"{}: F.p.toString.call / f.toString() / ''+f must be mutually equal",
k
);
assert_eq!(
c["ownToString"].as_bool(),
Some(false),
"{}: toString must stay inherited (real Chrome has no own toString)",
k
);
}
assert_eq!(
r["leaks"].as_array().map(Vec::as_slice),
Some(&[] as &[serde_json::Value]),
"no surface function may leak the bao* identifier through any channel"
);
for k in ["loadTimes", "csi"] {
assert_eq!(
r["constructible"][k]["new"].as_str(),
Some("yes"),
"{}: real Chrome's `new` yields a plain object (oracle round2)",
k
);
assert_eq!(
r["constructible"][k]["hasPrototype"].as_bool(),
Some(true),
"{}: own props carry [name, prototype, length] (oracle)",
k
);
assert_eq!(
r["nameShape"][k]["name"].as_str(),
Some(""),
"{}: real name is ''",
k
);
assert_eq!(
r["nameShape"][k]["length"].as_u64(),
Some(0),
"{}: real length is 0",
k
);
}
for (k, name) in [
("getDetails", "getDetails"),
("getIsInstalled", "getIsInstalled"),
("installState", "installState"),
("runningState", "runningState"),
] {
assert_eq!(
r["constructible"][k]["new"].as_str(),
Some("throws"),
"{}: real app methods are not constructors (oracle round2)",
k
);
assert_eq!(
r["constructible"][k]["hasPrototype"].as_bool(),
Some(false),
"{}: own props are [name, length] only (oracle round3)",
k
);
assert_eq!(
r["nameShape"][k]["name"].as_str(),
Some(name),
"{}: real name",
k
);
assert_eq!(
r["nameShape"][k]["length"].as_u64(),
Some(0),
"{}: real length is 0",
k
);
}
assert_eq!(
r["chromeKeys"],
serde_json::json!(["loadTimes", "csi", "app"]),
"chrome own-key order must stay loadTimes, csi, app"
);
assert_eq!(
r["appKeys"],
serde_json::json!(["isInstalled", "getDetails", "getIsInstalled", "installState",
"runningState", "InstallState", "RunningState"]),
"app member set must stay the e135 shape"
);
assert_eq!(
r["values"]["loadTimes"]["requestTime"].as_f64(),
Some(1759000000.0),
"requestTime = navigationStart/1000"
);
assert_eq!(
r["values"]["loadTimes"]["navigationType"], "Reload",
"navigationType follows performance.navigation.type"
);
let csi = &r["values"]["csi"];
assert_eq!(csi["startE"].as_u64(), Some(1759000000000u64));
assert_eq!(csi["onloadT"].as_u64(), Some(1759000000900u64));
assert_eq!(csi["tran"].as_u64(), Some(15));
assert_eq!(r["values"]["getDetails"], serde_json::Value::Null);
assert_eq!(
r["values"]["getIsInstalled"].as_bool(),
Some(false),
"getIsInstalled() must return false (vanilla page)"
);
assert_eq!(
r["values"]["runningState"].as_str(),
Some("cannot_run"),
"runningState() must return 'cannot_run' (vanilla page)"
);
assert_eq!(
r["installStateAsync"].as_bool(),
Some(true),
"installState must queue its callback asynchronously"
);
assert_eq!(
r["installStateCb"].as_str(),
Some("not_installed"),
"installState callback fires with 'not_installed'"
);
assert_eq!(
r["globalUntouched"]["plainSrcVisible"].as_bool(),
Some(true),
"plain functions must still render their source (no global toString patch)"
);
assert_eq!(
r["globalUntouched"]["selfNative"].as_bool(),
Some(true),
"Function.prototype.toString itself must stay the pristine native"
);
}
}