feat(stealth): shrink detectable surface — lazy Runtime.enable, native timezone/WebRTC, opt-in canvas noise
Borrow anti-detection hardening from Scrapling/patchright, preferring native CDP/Chrome overrides over JS lies: - Runtime.enable is now opt-in via AGENT_BROWSER_CAPTURE_CONSOLE (default off). It was called on every session INCLUDING CdpAttach (the user's real Chrome), leaking the patchright/rebrowser "runtime" CDP signal and undermining the "real browser, no lies" guarantee. Runtime.evaluate/callFunctionOn and runIfWaitingForDebugger work without it; only console/error capture needs it. The console/errors commands now return a hint when capture is disabled. - Timezone alignment via native Emulation.setTimezoneOverride, opt-in with AGENT_BROWSER_TIMEZONE=<IANA>|auto (FullLaunch only). Intl and Date both follow with no JS artifact. - WebRTC IP-leak handling via the --force-webrtc-ip-handling-policy Chrome flag: auto disable_non_proxied_udp when a proxy is set (so the real IP can't leak past the proxy); AGENT_BROWSER_BLOCK_WEBRTC=1 hides the local IP when there is no proxy; =0 opts out. - Opt-in canvas/audio fingerprint noise via AGENT_BROWSER_HIDE_CANVAS=1 (FullLaunch only). Session-stable seed so reads stay consistent within a session while differing from the headless-stable hash. Adds 5 unit tests; full suite 751 passed, 0 failed.
This commit is contained in:
@@ -3690,13 +3690,38 @@ async fn handle_console(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
||||
state.event_tracker.clear_console();
|
||||
Ok(json!({ "cleared": true }))
|
||||
} else {
|
||||
let result = state.event_tracker.get_console_json();
|
||||
let mut result = state.event_tracker.get_console_json();
|
||||
if !console_capture_active(state) {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the active browser session has the CDP `Runtime` domain enabled
|
||||
/// (required to receive console/error events). OFF by default for stealth.
|
||||
fn console_capture_active(state: &DaemonState) -> bool {
|
||||
state
|
||||
.browser
|
||||
.as_ref()
|
||||
.map(|b| b.capture_console)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const CONSOLE_DISABLED_HINT: &str =
|
||||
"console/error capture is disabled for stealth (Runtime.enable is a detectable CDP \
|
||||
signal). Restart the session with AGENT_BROWSER_CAPTURE_CONSOLE=1 to capture page output.";
|
||||
|
||||
async fn handle_errors(state: &DaemonState) -> Result<Value, String> {
|
||||
Ok(state.event_tracker.get_errors_json())
|
||||
let mut result = state.event_tracker.get_errors_json();
|
||||
if !console_capture_active(state) {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert("hint".to_string(), json!(CONSOLE_DISABLED_HINT));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn handle_state_save(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
|
||||
@@ -305,6 +305,22 @@ pub struct BrowserManager {
|
||||
/// Origins visited during this session, used by save_state to collect cross-origin localStorage.
|
||||
visited_origins: HashSet<String>,
|
||||
next_tab_id: u32,
|
||||
/// Whether to enable the CDP `Runtime` domain (console / error / exception capture).
|
||||
/// OFF by default for stealth: a live `Runtime.enable` is a detectable CDP signal
|
||||
/// (the patchright / rebrowser "runtime leak") — even when attached to the user's
|
||||
/// real Chrome. Opt in via `AGENT_BROWSER_CAPTURE_CONSOLE=1` when you need the
|
||||
/// `console` / `errors` commands to return page output.
|
||||
pub capture_console: bool,
|
||||
}
|
||||
|
||||
/// Whether console/error capture (and thus `Runtime.enable`) is opted into for this
|
||||
/// daemon. Defaults to `false` so the common automation path leaves no Runtime-domain
|
||||
/// fingerprint. Set `AGENT_BROWSER_CAPTURE_CONSOLE=1` (or `true`) to turn it on.
|
||||
pub fn console_capture_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_CAPTURE_CONSOLE")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const LIGHTPANDA_CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -413,6 +429,7 @@ impl BrowserManager {
|
||||
ignore_https_errors,
|
||||
visited_origins: HashSet::new(),
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
manager.discover_and_attach_targets().await?;
|
||||
manager
|
||||
@@ -502,6 +519,7 @@ impl BrowserManager {
|
||||
ignore_https_errors: false,
|
||||
visited_origins: HashSet::new(),
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
|
||||
if direct_page {
|
||||
@@ -629,9 +647,14 @@ impl BrowserManager {
|
||||
self.client
|
||||
.send_command_no_params("Page.enable", Some(session_id))
|
||||
.await?;
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", Some(session_id))
|
||||
.await?;
|
||||
// `Runtime.enable` leaves a detectable CDP signal (the patchright/rebrowser
|
||||
// "runtime leak"), so only enable it when console/error capture is opted in.
|
||||
// `Runtime.evaluate` / `Runtime.callFunctionOn` work fine without it.
|
||||
if self.capture_console {
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", Some(session_id))
|
||||
.await?;
|
||||
}
|
||||
// Resume the target if it is paused waiting for the debugger.
|
||||
// This is needed for real browser sessions (Chrome 144+) where targets
|
||||
// are paused after attach until explicitly resumed. No-op otherwise.
|
||||
@@ -665,9 +688,12 @@ impl BrowserManager {
|
||||
self.client
|
||||
.send_command_no_params("Page.enable", None)
|
||||
.await?;
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", None)
|
||||
.await?;
|
||||
// See `enable_domains`: `Runtime.enable` is a CDP fingerprint, gated on opt-in.
|
||||
if self.capture_console {
|
||||
self.client
|
||||
.send_command_no_params("Runtime.enable", None)
|
||||
.await?;
|
||||
}
|
||||
let _ = self
|
||||
.client
|
||||
.send_command_no_params("Runtime.runIfWaitingForDebugger", None)
|
||||
@@ -1659,6 +1685,7 @@ async fn initialize_lightpanda_manager(
|
||||
ignore_https_errors: false,
|
||||
visited_origins: HashSet::new(),
|
||||
next_tab_id: 1,
|
||||
capture_console: console_capture_enabled(),
|
||||
};
|
||||
|
||||
match discover_and_attach_lightpanda_targets(&mut manager, deadline).await {
|
||||
|
||||
@@ -146,6 +146,30 @@ struct ChromeArgs {
|
||||
temp_user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Decide the `--force-webrtc-ip-handling-policy` value, if any, for a launched
|
||||
/// Chrome. Returns `None` to leave WebRTC at Chrome's default behavior.
|
||||
fn webrtc_ip_handling_policy(has_proxy: bool) -> Option<&'static str> {
|
||||
let opt_in = std::env::var("AGENT_BROWSER_BLOCK_WEBRTC").ok();
|
||||
let explicitly_off = opt_in
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == "0" || v.eq_ignore_ascii_case("false"));
|
||||
if explicitly_off {
|
||||
return None;
|
||||
}
|
||||
let explicitly_on = opt_in
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
|
||||
if has_proxy {
|
||||
// Force all WebRTC UDP through the proxy so the real IP can't leak.
|
||||
Some("disable_non_proxied_udp")
|
||||
} else if explicitly_on {
|
||||
// No proxy, but the user asked to hide the local network IP.
|
||||
Some("default_public_interface_only")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
let mut args = vec![
|
||||
"--remote-debugging-port=0".to_string(),
|
||||
@@ -204,6 +228,20 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
args.push(format!("--proxy-bypass-list={}", bypass));
|
||||
}
|
||||
|
||||
// WebRTC IP-leak handling. WebRTC enumerates ICE candidates that can expose
|
||||
// the machine's real local/public IP even when HTTP traffic goes through a
|
||||
// proxy — defeating the proxy. `--force-webrtc-ip-handling-policy` is a real
|
||||
// Chrome privacy switch (no detectable JS lie), applied here for launched
|
||||
// Chrome only (an attached real Chrome keeps the user's own flags).
|
||||
// - proxy set -> `disable_non_proxied_udp`: force WebRTC through
|
||||
// the proxy so the real IP can't leak.
|
||||
// - AGENT_BROWSER_BLOCK_WEBRTC=1 (no proxy) -> `default_public_interface_only`:
|
||||
// hide the local network IP (Brave/uBlock default).
|
||||
// Opt out entirely with AGENT_BROWSER_BLOCK_WEBRTC=0.
|
||||
if let Some(policy) = webrtc_ip_handling_policy(options.proxy.is_some()) {
|
||||
args.push(format!("--force-webrtc-ip-handling-policy={}", policy));
|
||||
}
|
||||
|
||||
let (user_data_dir, temp_user_data_dir) = if let Some(ref profile) = options.profile {
|
||||
let expanded = expand_tilde(profile);
|
||||
let dir = PathBuf::from(&expanded);
|
||||
@@ -1343,6 +1381,37 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_utils::EnvGuard;
|
||||
|
||||
#[test]
|
||||
fn webrtc_policy_forces_proxy_when_proxy_set() {
|
||||
let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
|
||||
g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
|
||||
// Proxy set, no env: always force WebRTC through the proxy.
|
||||
assert_eq!(
|
||||
webrtc_ip_handling_policy(true),
|
||||
Some("disable_non_proxied_udp")
|
||||
);
|
||||
// No proxy, no env: leave WebRTC at Chrome's default.
|
||||
assert_eq!(webrtc_ip_handling_policy(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webrtc_policy_opt_in_and_opt_out() {
|
||||
let g = EnvGuard::new(&["AGENT_BROWSER_BLOCK_WEBRTC"]);
|
||||
|
||||
g.set("AGENT_BROWSER_BLOCK_WEBRTC", "1");
|
||||
assert_eq!(
|
||||
webrtc_ip_handling_policy(false),
|
||||
Some("default_public_interface_only")
|
||||
);
|
||||
|
||||
// Explicit opt-out wins even when a proxy is set.
|
||||
g.set("AGENT_BROWSER_BLOCK_WEBRTC", "0");
|
||||
assert_eq!(webrtc_ip_handling_policy(true), None);
|
||||
assert_eq!(webrtc_ip_handling_policy(false), None);
|
||||
|
||||
g.remove("AGENT_BROWSER_BLOCK_WEBRTC");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_noop_child() -> Child {
|
||||
Command::new("/bin/sh")
|
||||
|
||||
+139
-2
@@ -51,13 +51,18 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
vec![locale, base_lang]
|
||||
};
|
||||
let config_line = format!(
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false }};"#,
|
||||
r#"const __abStealth = {{ locale: "{}", languages: {}, allowWebGLContextFallback: false, hideCanvas: {}, canvasSeed: {} }};"#,
|
||||
locale,
|
||||
serde_json::to_string(&languages).unwrap_or_else(|_| r#"["en-US","en"]"#.to_string()),
|
||||
hide_canvas_enabled(),
|
||||
canvas_noise_seed(),
|
||||
);
|
||||
|
||||
// NB: this prefix MUST match the first line of stealth_scripts.js verbatim,
|
||||
// otherwise the fallback below prepends a SECOND `const __abStealth`
|
||||
// declaration and the whole script dies with a redeclaration SyntaxError.
|
||||
if let Some(rest) = STEALTH_SCRIPTS_RAW.strip_prefix(
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };"#,
|
||||
r#"const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };"#,
|
||||
) {
|
||||
format!("{}{}", config_line, rest)
|
||||
} else {
|
||||
@@ -65,6 +70,35 @@ pub fn build_stealth_script(mode: StealthMode, locale: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether canvas/audio fingerprint noise is opted into (FullLaunch only).
|
||||
/// OFF by default: injecting noise is a deliberate "lie" that can itself be a
|
||||
/// tell, so it's reserved for users who explicitly want it via
|
||||
/// `AGENT_BROWSER_HIDE_CANVAS=1`.
|
||||
fn hide_canvas_enabled() -> bool {
|
||||
std::env::var("AGENT_BROWSER_HIDE_CANVAS")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A per-process seed so canvas/audio noise is STABLE within a session (a real
|
||||
/// device returns the same hash on repeated reads) but differs from the
|
||||
/// headless-stable default. 0 is avoided so the JS can treat it as "unset".
|
||||
fn canvas_noise_seed() -> u32 {
|
||||
use std::sync::OnceLock;
|
||||
static SEED: OnceLock<u32> = OnceLock::new();
|
||||
*SEED.get_or_init(|| {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0x9e3779b9);
|
||||
// mix the bits a little, then force non-zero
|
||||
let mixed = nanos ^ nanos.rotate_left(13).wrapping_mul(2654435761);
|
||||
mixed | 1
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply stealth patches to a browser session.
|
||||
///
|
||||
/// In `CdpAttach` mode (user's real Chrome): only removes `navigator.webdriver`.
|
||||
@@ -118,11 +152,114 @@ pub async fn apply_stealth(
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Align the timezone for fresh launches when explicitly requested.
|
||||
// Headless/launched Chrome often reports UTC (or the host's zone), which
|
||||
// can contradict a proxy's geolocation or a spoofed locale.
|
||||
// `Emulation.setTimezoneOverride` is a NATIVE override — Intl.DateTimeFormat
|
||||
// and Date both follow it with no detectable JS lie. Opt-in only:
|
||||
// AGENT_BROWSER_TIMEZONE=<IANA id> -> use that zone (e.g. align to proxy)
|
||||
// AGENT_BROWSER_TIMEZONE=auto -> derive a default from the locale
|
||||
// (unset) -> leave the real timezone untouched
|
||||
if let Some(tz) = resolve_timezone(locale) {
|
||||
let _ = client
|
||||
.send_command(
|
||||
"Emulation.setTimezoneOverride",
|
||||
Some(json!({ "timezoneId": tz })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the timezone to emulate for a fresh-launch session, if any.
|
||||
/// Controlled by `AGENT_BROWSER_TIMEZONE`: an explicit IANA id, or `auto` to
|
||||
/// derive a sensible default from the locale. Returns `None` (leave the real
|
||||
/// timezone) when unset, empty, or when `auto` can't map the locale.
|
||||
fn resolve_timezone(locale: Option<&str>) -> Option<String> {
|
||||
let raw = std::env::var("AGENT_BROWSER_TIMEZONE").ok()?;
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if raw.eq_ignore_ascii_case("auto") {
|
||||
return locale
|
||||
.and_then(locale_default_timezone)
|
||||
.map(str::to_string);
|
||||
}
|
||||
Some(raw.to_string())
|
||||
}
|
||||
|
||||
/// Best-effort IANA timezone for a locale. Used only for
|
||||
/// `AGENT_BROWSER_TIMEZONE=auto`; unknown locales return `None` so the real
|
||||
/// timezone is left untouched rather than guessing a wrong one.
|
||||
#[cfg(test)]
|
||||
mod timezone_tests {
|
||||
use super::{locale_default_timezone, resolve_timezone};
|
||||
|
||||
#[test]
|
||||
fn maps_common_locales_case_insensitively() {
|
||||
assert_eq!(locale_default_timezone("en-US"), Some("America/New_York"));
|
||||
assert_eq!(locale_default_timezone("ja-JP"), Some("Asia/Tokyo"));
|
||||
assert_eq!(locale_default_timezone("zh-CN"), Some("Asia/Shanghai"));
|
||||
assert_eq!(locale_default_timezone("ZH-TW"), Some("Asia/Taipei"));
|
||||
assert_eq!(locale_default_timezone("ja"), Some("Asia/Tokyo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_locale_returns_none() {
|
||||
assert_eq!(locale_default_timezone("xx-YY"), None);
|
||||
assert_eq!(locale_default_timezone(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_timezone_honors_env() {
|
||||
// Serialized via a single test to avoid cross-test env races on this key.
|
||||
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
|
||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "Europe/Berlin");
|
||||
assert_eq!(resolve_timezone(None), Some("Europe/Berlin".to_string()));
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", " ");
|
||||
assert_eq!(resolve_timezone(Some("en-US")), None);
|
||||
|
||||
std::env::set_var("AGENT_BROWSER_TIMEZONE", "auto");
|
||||
assert_eq!(resolve_timezone(Some("ja-JP")), Some("Asia/Tokyo".to_string()));
|
||||
assert_eq!(resolve_timezone(Some("xx-YY")), None);
|
||||
assert_eq!(resolve_timezone(None), None);
|
||||
|
||||
std::env::remove_var("AGENT_BROWSER_TIMEZONE");
|
||||
}
|
||||
}
|
||||
|
||||
fn locale_default_timezone(locale: &str) -> Option<&'static str> {
|
||||
let tz = match locale.to_ascii_lowercase().as_str() {
|
||||
"en-us" => "America/New_York",
|
||||
"en-ca" => "America/Toronto",
|
||||
"en-gb" => "Europe/London",
|
||||
"en-au" => "Australia/Sydney",
|
||||
"ja" | "ja-jp" => "Asia/Tokyo",
|
||||
"ko" | "ko-kr" => "Asia/Seoul",
|
||||
"zh-cn" | "zh-hans" | "zh-hans-cn" => "Asia/Shanghai",
|
||||
"zh-tw" | "zh-hant" | "zh-hant-tw" => "Asia/Taipei",
|
||||
"zh-hk" => "Asia/Hong_Kong",
|
||||
"de" | "de-de" => "Europe/Berlin",
|
||||
"fr" | "fr-fr" => "Europe/Paris",
|
||||
"es" | "es-es" => "Europe/Madrid",
|
||||
"it" | "it-it" => "Europe/Rome",
|
||||
"nl" | "nl-nl" => "Europe/Amsterdam",
|
||||
"pt-br" => "America/Sao_Paulo",
|
||||
"pt" | "pt-pt" => "Europe/Lisbon",
|
||||
"ru" | "ru-ru" => "Europe/Moscow",
|
||||
_ => return None,
|
||||
};
|
||||
Some(tz)
|
||||
}
|
||||
|
||||
/// Get the browser's User-Agent string via CDP.
|
||||
async fn get_browser_user_agent(client: &CdpClient, session_id: &str) -> Option<String> {
|
||||
let result = client
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false };
|
||||
const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLContextFallback: false, hideCanvas: false, canvasSeed: 0 };
|
||||
(function(){
|
||||
// Prefer the CDP-level automation override (Emulation.setAutomationOverride),
|
||||
// which makes navigator.webdriver report `false` NATIVELY — undetectable by
|
||||
@@ -1275,3 +1275,126 @@ const __abStealth = { locale: "en-US", languages: ["en-US", "en"], allowWebGLCon
|
||||
}
|
||||
}
|
||||
})();
|
||||
// Canvas + audio fingerprint noise (OPT-IN, full-launch only).
|
||||
// Headless Chrome produces a stable canvas/audio hash that trackers use as a
|
||||
// device id. When __abStealth.hideCanvas is on we perturb readback APIs with a
|
||||
// SESSION-STABLE, sub-perceptual amount of noise: repeated reads on this page
|
||||
// return the same noised result (a real device is consistent too), but the
|
||||
// hash differs from the headless default. Off by default — noise is itself a
|
||||
// "lie", so it's reserved for users who explicitly enable it.
|
||||
(function(){
|
||||
if (!__abStealth || __abStealth.hideCanvas !== true) return;
|
||||
|
||||
// Deterministic PRNG keyed by the per-session seed plus a position, so the
|
||||
// same pixel/sample is perturbed identically every read within the session.
|
||||
const baseSeed = (__abStealth.canvasSeed >>> 0) || 0x9e3779b9;
|
||||
const noiseAt = (n) => {
|
||||
let t = (baseSeed ^ Math.imul(n | 0, 0x6d2b79f5)) >>> 0;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
// Make a wrapped function masquerade as the native one (toString + name).
|
||||
const mask = (wrapped, native) => {
|
||||
try {
|
||||
Object.defineProperty(wrapped, 'name', {
|
||||
value: native.name,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(wrapped, 'toString', {
|
||||
value: () => native.toString(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {}
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
// ---- Canvas 2D readback ---------------------------------------------------
|
||||
const perturbImageData = (imageData) => {
|
||||
const data = imageData && imageData.data;
|
||||
if (!data || !data.length) return imageData;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Touch ~5% of pixels by +/-1 on each RGB channel; leave alpha alone.
|
||||
if (noiseAt(i) < 0.05) {
|
||||
const delta = noiseAt(i + 1) < 0.5 ? -1 : 1;
|
||||
data[i] = Math.max(0, Math.min(255, data[i] + delta));
|
||||
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + delta));
|
||||
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + delta));
|
||||
}
|
||||
}
|
||||
return imageData;
|
||||
};
|
||||
|
||||
try {
|
||||
const ctxProto = (typeof CanvasRenderingContext2D !== 'undefined')
|
||||
? CanvasRenderingContext2D.prototype : null;
|
||||
if (ctxProto && typeof ctxProto.getImageData === 'function') {
|
||||
const nativeGetImageData = ctxProto.getImageData;
|
||||
ctxProto.getImageData = mask(function(...args) {
|
||||
return perturbImageData(nativeGetImageData.apply(this, args));
|
||||
}, nativeGetImageData);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// For toDataURL/toBlob, draw the (already-rendered) canvas onto a scratch
|
||||
// canvas, perturb its pixels, then encode that — so the export hash shifts
|
||||
// without disturbing what the page sees on screen.
|
||||
const exportNoised = (canvas) => {
|
||||
try {
|
||||
const w = canvas.width, h = canvas.height;
|
||||
if (!w || !h) return null;
|
||||
const scratch = document.createElement('canvas');
|
||||
scratch.width = w; scratch.height = h;
|
||||
const sctx = scratch.getContext('2d');
|
||||
if (!sctx) return null;
|
||||
sctx.drawImage(canvas, 0, 0);
|
||||
const img = sctx.getImageData(0, 0, w, h);
|
||||
perturbImageData(img);
|
||||
sctx.putImageData(img, 0, 0);
|
||||
return scratch;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
try {
|
||||
const canvasProto = (typeof HTMLCanvasElement !== 'undefined')
|
||||
? HTMLCanvasElement.prototype : null;
|
||||
if (canvasProto && typeof canvasProto.toDataURL === 'function') {
|
||||
const nativeToDataURL = canvasProto.toDataURL;
|
||||
canvasProto.toDataURL = mask(function(...args) {
|
||||
const scratch = exportNoised(this);
|
||||
return nativeToDataURL.apply(scratch || this, args);
|
||||
}, nativeToDataURL);
|
||||
}
|
||||
if (canvasProto && typeof canvasProto.toBlob === 'function') {
|
||||
const nativeToBlob = canvasProto.toBlob;
|
||||
canvasProto.toBlob = mask(function(cb, ...rest) {
|
||||
const scratch = exportNoised(this);
|
||||
return nativeToBlob.call(scratch || this, cb, ...rest);
|
||||
}, nativeToBlob);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ---- AudioBuffer readback -------------------------------------------------
|
||||
// Perturb time-domain samples by a tiny, seed-stable amount so the audio
|
||||
// fingerprint (sum/hash of channel data) shifts without audible effect.
|
||||
try {
|
||||
const audioProto = (typeof AudioBuffer !== 'undefined') ? AudioBuffer.prototype : null;
|
||||
if (audioProto && typeof audioProto.getChannelData === 'function') {
|
||||
const nativeGetChannelData = audioProto.getChannelData;
|
||||
const seen = new WeakSet();
|
||||
audioProto.getChannelData = mask(function(...args) {
|
||||
const channel = nativeGetChannelData.apply(this, args);
|
||||
// Only perturb once per buffer to keep reads consistent.
|
||||
if (channel && !seen.has(channel)) {
|
||||
seen.add(channel);
|
||||
for (let i = 0; i < channel.length; i += 100) {
|
||||
channel[i] = channel[i] + (noiseAt(i) - 0.5) * 1e-7;
|
||||
}
|
||||
}
|
||||
return channel;
|
||||
}, nativeGetChannelData);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user