1use crate::network::fingerprint::{DeviceProfile, Fingerprint};
2use anyhow::Result;
3use boa_engine::{Context, Source};
4
5pub struct JsRuntime {
6 context: Context<'static>,
7}
8
9impl JsRuntime {
10 pub fn new() -> Result<Self> {
11 let default_fp = Fingerprint::for_profile(DeviceProfile::ChromeWindows);
12 Self::with_fingerprint(&default_fp)
13 }
14
15 pub fn with_fingerprint(fp: &Fingerprint) -> Result<Self> {
16 let mut context = Context::default();
17
18 let max_touch_points = if fp.is_mobile { 5 } else { 0 };
19 let dpr = if fp.is_mobile { "3.0" } else { "1.0" };
20 let mobile_bool = if fp.is_mobile { "true" } else { "false" };
21
22 let (gpu_vendor, gpu_renderer) = match fp.profile {
23 DeviceProfile::ChromeWindows => (
24 "Google Inc. (NVIDIA)",
25 "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)",
26 ),
27 DeviceProfile::ChromeLinux => (
28 "Google Inc. (Intel)",
29 "ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)",
30 ),
31 DeviceProfile::SafariMac => ("Apple Inc.", "Apple M2 Pro"),
32 DeviceProfile::SafariIos => ("Apple Inc.", "Apple A17 Pro GPU"),
33 DeviceProfile::ChromeAndroid => ("ARM", "Mali-G715-Immortalis MC11"),
34 };
35
36 let init_script = format!(
37 r###"
38 // 1. Core Window & Global References
39 globalThis.window = globalThis;
40 globalThis.self = globalThis;
41 globalThis.top = globalThis;
42 globalThis.parent = globalThis;
43 globalThis.devicePixelRatio = {dpr};
44
45 // 2. Storage APIs (localStorage & sessionStorage)
46 function createStorageMock() {{
47 const store = new Map();
48 return {{
49 getItem: function(k) {{ return store.has(String(k)) ? store.get(String(k)) : null; }},
50 setItem: function(k, v) {{ store.set(String(k), String(v)); }},
51 removeItem: function(k) {{ store.delete(String(k)); }},
52 clear: function() {{ store.clear(); }},
53 key: function(i) {{ const keys = Array.from(store.keys()); return keys[i] || null; }},
54 get length() {{ return store.size; }}
55 }};
56 }}
57 globalThis.localStorage = createStorageMock();
58 globalThis.sessionStorage = createStorageMock();
59 globalThis.window.localStorage = globalThis.localStorage;
60 globalThis.window.sessionStorage = globalThis.sessionStorage;
61
62 // 3. IndexedDB API
63 globalThis.indexedDB = {{
64 open: function(name, ver) {{
65 return {{
66 result: {{ name: name, version: ver || 1, objectStoreNames: [] }},
67 error: null,
68 readyState: "done",
69 onsuccess: null,
70 onerror: null,
71 onupgradeneeded: null
72 }};
73 }},
74 databases: function() {{ return Promise.resolve([]); }},
75 deleteDatabase: function() {{ return {{ onsuccess: null, onerror: null }}; }},
76 cmp: function(a, b) {{ return a < b ? -1 : (a > b ? 1 : 0); }}
77 }};
78 globalThis.window.indexedDB = globalThis.indexedDB;
79
80 // 4. Deep Navigator Stealth Emulation
81 const pluginArray = [
82 {{
83 name: "Chrome PDF Plugin",
84 filename: "internal-pdf-viewer",
85 description: "Portable Document Format",
86 length: 1,
87 0: {{
88 type: "application/x-google-chrome-pdf",
89 suffixes: "pdf",
90 description: "Portable Document Format",
91 enabledPlugin: null
92 }}
93 }},
94 {{
95 name: "Chrome PDF Viewer",
96 filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai",
97 description: "",
98 length: 1,
99 0: {{
100 type: "application/pdf",
101 suffixes: "pdf",
102 description: "",
103 enabledPlugin: null
104 }}
105 }},
106 {{
107 name: "Native Client",
108 filename: "internal-nacl-plugin",
109 description: "",
110 length: 2,
111 0: {{
112 type: "application/x-nacl",
113 suffixes: "",
114 description: "Native Client Executable",
115 enabledPlugin: null
116 }},
117 1: {{
118 type: "application/x-pnacl",
119 suffixes: "",
120 description: "Portable Native Client Executable",
121 enabledPlugin: null
122 }}
123 }}
124 ];
125 pluginArray.item = function(index) {{ return this[index] || null; }};
126 pluginArray.namedItem = function(name) {{
127 for (let i = 0; i < this.length; i++) {{
128 if (this[i].name === name) return this[i];
129 }}
130 return null;
131 }};
132 pluginArray.refresh = function() {{}};
133
134 const mimeTypeArray = [
135 {{
136 type: "application/pdf",
137 suffixes: "pdf",
138 description: "",
139 enabledPlugin: pluginArray[1]
140 }},
141 {{
142 type: "application/x-google-chrome-pdf",
143 suffixes: "pdf",
144 description: "Portable Document Format",
145 enabledPlugin: pluginArray[0]
146 }},
147 {{
148 type: "application/x-nacl",
149 suffixes: "",
150 description: "Native Client Executable",
151 enabledPlugin: pluginArray[2]
152 }},
153 {{
154 type: "application/x-pnacl",
155 suffixes: "",
156 description: "Portable Native Client Executable",
157 enabledPlugin: pluginArray[2]
158 }}
159 ];
160 mimeTypeArray.item = function(index) {{ return this[index] || null; }};
161 mimeTypeArray.namedItem = function(type) {{
162 for (let i = 0; i < this.length; i++) {{
163 if (this[i].type === type) return this[i];
164 }}
165 return null;
166 }};
167
168 globalThis.navigator = {{
169 userAgent: "{ua}",
170 appVersion: "{ua}",
171 platform: "{platform}",
172 appName: "Netscape",
173 appCodeName: "Mozilla",
174 language: "en-US",
175 languages: ["en-US", "en"],
176 webdriver: false,
177 cookieEnabled: true,
178 hardwareConcurrency: 8,
179 deviceMemory: 8,
180 maxTouchPoints: {touch},
181 vendor: "Google Inc.",
182 vendorSub: "",
183 product: "Gecko",
184 productSub: "20030107",
185 plugins: pluginArray,
186 mimeTypes: mimeTypeArray,
187 doNotTrack: null,
188 connection: {{
189 downlink: 10,
190 effectiveType: "4g",
191 rtt: 50,
192 saveData: false,
193 onchange: null
194 }},
195 mediaDevices: {{
196 enumerateDevices: function() {{
197 return Promise.resolve([
198 {{ deviceId: "default", kind: "audioinput", label: "Default - Microphone (Realtek Audio)", groupId: "audio-group-1" }},
199 {{ deviceId: "default", kind: "audiooutput", label: "Default - Speakers (Realtek Audio)", groupId: "audio-group-1" }},
200 {{ deviceId: "cam-01", kind: "videoinput", label: "Integrated Camera (HD Webcam)", groupId: "video-group-1" }}
201 ]);
202 }},
203 getUserMedia: function() {{ return Promise.reject(new Error("Permission denied")); }},
204 getDisplayMedia: function() {{ return Promise.reject(new Error("Permission denied")); }}
205 }},
206 getBattery: function() {{
207 return Promise.resolve({{
208 charging: true,
209 chargingTime: 0,
210 dischargingTime: Infinity,
211 level: 1.0,
212 onchargingchange: null,
213 onlevelchange: null
214 }});
215 }},
216 serviceWorker: {{
217 controller: null,
218 ready: Promise.resolve({{ active: null, scope: "/" }}),
219 register: function() {{ return Promise.resolve(); }},
220 getRegistration: function() {{ return Promise.resolve(null); }},
221 getRegistrations: function() {{ return Promise.resolve([]); }}
222 }},
223 credentials: {{
224 get: function() {{ return Promise.resolve(null); }},
225 create: function() {{ return Promise.resolve(null); }},
226 store: function() {{ return Promise.resolve(); }},
227 preventSilentAccess: function() {{ return Promise.resolve(); }}
228 }},
229 userAgentData: {{
230 brands: [
231 {{ brand: "Not(A:Brand", version: "99" }},
232 {{ brand: "Google Chrome", version: "133" }},
233 {{ brand: "Chromium", version: "133" }}
234 ],
235 mobile: {is_mobile},
236 platform: "{platform}",
237 getHighEntropyValues: function(hints) {{
238 return Promise.resolve({{
239 architecture: "x86",
240 bitness: "64",
241 brands: [
242 {{ brand: "Not(A:Brand", version: "99" }},
243 {{ brand: "Google Chrome", version: "133" }},
244 {{ brand: "Chromium", version: "133" }}
245 ],
246 mobile: {is_mobile},
247 model: "",
248 platform: "{platform}",
249 platformVersion: "15.0.0",
250 uaFullVersion: "133.0.6943.127"
251 }});
252 }}
253 }},
254 permissions: {{
255 query: function(param) {{
256 return Promise.resolve({{
257 state: "default",
258 onchange: null
259 }});
260 }}
261 }}
262 }};
263
264 // 5. Complete window.chrome Emulation
265 globalThis.window.chrome = {{
266 app: {{
267 isInstalled: false,
268 InstallState: {{
269 DISABLED: "disabled",
270 INSTALLED: "installed",
271 NOT_INSTALLED: "not_installed"
272 }},
273 RunningState: {{
274 CANNOT_RUN: "cannot_run",
275 READY_TO_RUN: "ready_to_run",
276 RUNNING: "running"
277 }},
278 getDetails: function() {{ return null; }},
279 getIsInstalled: function() {{ return false; }},
280 runningState: function() {{ return "cannot_run"; }}
281 }},
282 runtime: {{
283 OnInstalledReason: {{
284 CHROME_UPDATE: "chrome_update",
285 INSTALL: "install",
286 SHARED_MODULE_UPDATE: "shared_module_update",
287 UPDATE: "update"
288 }},
289 OnRestartRequiredReason: {{
290 APP_UPDATE: "app_update",
291 OS_UPDATE: "os_update",
292 PERIODIC: "periodic"
293 }},
294 PlatformArch: {{
295 ARM: "arm",
296 ARM64: "arm64",
297 MIPS: "mips",
298 MIPS64: "mips64",
299 X86_32: "x86-32",
300 X86_64: "x86-64"
301 }},
302 PlatformNaclArch: {{
303 ARM: "arm",
304 MIPS: "mips",
305 MIPS64: "mips64",
306 X86_32: "x86-32",
307 X86_64: "x86-64"
308 }},
309 PlatformOs: {{
310 ANDROID: "android",
311 CROS: "cros",
312 LINUX: "linux",
313 MAC: "mac",
314 OPENBSD: "openbsd",
315 WIN: "win"
316 }},
317 RequestUpdateCheckStatus: {{
318 NO_UPDATE: "no_update",
319 THROTTLED: "throttled",
320 UPDATE_AVAILABLE: "update_available"
321 }},
322 connect: function() {{}},
323 sendMessage: function() {{}}
324 }},
325 csi: function() {{
326 return {{
327 onloadT: Date.now(),
328 pageT: 142.5,
329 startE: Date.now() - 150,
330 tran: 15
331 }};
332 }},
333 loadTimes: function() {{
334 return {{
335 commitLoadTime: Date.now() / 1000 - 0.1,
336 connectionInfo: "h2",
337 finishDocumentLoadTime: Date.now() / 1000,
338 finishLoadTime: Date.now() / 1000,
339 firstPaintAfterLoadTime: 0,
340 firstPaintTime: Date.now() / 1000 - 0.05,
341 navigationType: "Other",
342 npnNegotiatedProtocol: "h2",
343 requestTime: Date.now() / 1000 - 0.15,
344 startLoadTime: Date.now() / 1000 - 0.15,
345 wasAlternateProtocolAvailable: false,
346 wasFetchedViaSpdy: true,
347 wasNpnNegotiated: true
348 }};
349 }}
350 }};
351
352 // 6. Screen and Display Metrics
353 globalThis.screen = {{
354 width: {sw},
355 height: {sh},
356 availWidth: {sw},
357 availHeight: {sh},
358 colorDepth: 24,
359 pixelDepth: 24,
360 availLeft: 0,
361 availTop: 0,
362 orientation: {{
363 angle: 0,
364 type: "landscape-primary",
365 onchange: null
366 }}
367 }};
368 globalThis.outerWidth = {sw};
369 globalThis.outerHeight = {sh};
370 globalThis.innerWidth = {sw};
371 globalThis.innerHeight = {sh};
372
373 // 7. Notification API
374 globalThis.Notification = {{
375 permission: "default",
376 maxActions: 2,
377 requestPermission: function() {{
378 return Promise.resolve("default");
379 }}
380 }};
381
382 // 8. WebGL Vendor & Renderer Spoofing
383 globalThis.WebGLRenderingContext = function() {{}};
384 globalThis.WebGLRenderingContext.prototype = {{
385 getParameter: function(param) {{
386 if (param === 37445) return "{gpu_vendor}";
387 if (param === 37446) return "{gpu_renderer}";
388 if (param === 7936) return "WebKit";
389 if (param === 7937) return "WebKit WebGL";
390 return null;
391 }},
392 getSupportedExtensions: function() {{
393 return [
394 "ANGLE_instanced_arrays",
395 "EXT_blend_minmax",
396 "EXT_color_buffer_half_float",
397 "EXT_float_blend",
398 "EXT_frag_depth",
399 "EXT_shader_texture_lod",
400 "EXT_sRGB",
401 "EXT_texture_compression_bptc",
402 "EXT_texture_compression_rgtc",
403 "EXT_texture_filter_anisotropic",
404 "OES_element_index_uint",
405 "OES_fbo_render_mipmap",
406 "OES_standard_derivatives",
407 "OES_texture_float",
408 "OES_texture_float_linear",
409 "OES_texture_half_float",
410 "OES_texture_half_float_linear",
411 "OES_vertex_array_object",
412 "WEBGL_color_buffer_float",
413 "WEBGL_compressed_texture_s3tc",
414 "WEBGL_compressed_texture_s3tc_srgb",
415 "WEBGL_debug_renderer_info",
416 "WEBGL_debug_shaders",
417 "WEBGL_depth_texture",
418 "WEBGL_draw_buffers",
419 "WEBGL_lose_context",
420 "WEBGL_multi_draw"
421 ];
422 }},
423 getExtension: function(name) {{
424 if (name === "WEBGL_debug_renderer_info") {{
425 return {{
426 UNMASKED_VENDOR_WEBGL: 37445,
427 UNMASKED_RENDERER_WEBGL: 37446
428 }};
429 }}
430 return {{}};
431 }}
432 }};
433 globalThis.WebGL2RenderingContext = globalThis.WebGLRenderingContext;
434
435 // 9. Web Audio API
436 function AudioContextMock() {{
437 return {{
438 state: "running",
439 sampleRate: 44100,
440 currentTime: 0.1,
441 destination: {{ maxChannelCount: 2, channelCount: 2, channelCountMode: "explicit" }},
442 createOscillator: function() {{
443 return {{
444 type: "sine",
445 frequency: {{ value: 440, setValueAtTime: function() {{}} }},
446 connect: function() {{}},
447 start: function() {{}},
448 stop: function() {{}}
449 }};
450 }},
451 createGain: function() {{
452 return {{ gain: {{ value: 1.0, setValueAtTime: function() {{}} }}, connect: function() {{}} }};
453 }},
454 createDynamicsCompressor: function() {{
455 return {{
456 threshold: {{ value: -24 }},
457 knee: {{ value: 30 }},
458 ratio: {{ value: 12 }},
459 reduction: -10,
460 attack: {{ value: 0.003 }},
461 release: {{ value: 0.25 }},
462 connect: function() {{}}
463 }};
464 }},
465 createBufferSource: function() {{ return {{ connect: function() {{}}, start: function() {{}}, stop: function() {{}} }}; }},
466 createAnalyser: function() {{
467 return {{
468 fftSize: 2048,
469 frequencyBinCount: 1024,
470 minDecibels: -100,
471 maxDecibels: -30,
472 smoothingTimeConstant: 0.8,
473 getByteFrequencyData: function(arr) {{ if (arr && arr.fill) arr.fill(128); }},
474 getFloatFrequencyData: function(arr) {{ if (arr && arr.fill) arr.fill(-50.0); }}
475 }};
476 }}
477 }};
478 }}
479 globalThis.AudioContext = AudioContextMock;
480 globalThis.webkitAudioContext = AudioContextMock;
481 globalThis.OfflineAudioContext = function(ch, len, sr) {{
482 const ctx = AudioContextMock();
483 ctx.startRendering = function() {{
484 return Promise.resolve({{
485 length: len,
486 duration: len / (sr || 44100),
487 sampleRate: sr || 44100,
488 numberOfChannels: ch || 2,
489 getChannelData: function(c) {{
490 const d = new Float32Array(len || 100);
491 for (let i = 0; i < d.length; i++) {{
492 d[i] = Math.sin(i * 0.05) * 0.5 + 0.0001 * Math.sin(i * 1.5);
493 }}
494 return d;
495 }}
496 }});
497 }};
498 return ctx;
499 }};
500
501 // 10. Performance API
502 const _startTime = Date.now() - 320;
503 globalThis.performance = {{
504 now: function() {{ return Date.now() - _startTime; }},
505 timeOrigin: _startTime,
506 timing: {{
507 navigationStart: _startTime,
508 unloadEventStart: 0,
509 unloadEventEnd: 0,
510 redirectStart: 0,
511 redirectEnd: 0,
512 fetchStart: _startTime + 5,
513 domainLookupStart: _startTime + 12,
514 domainLookupEnd: _startTime + 25,
515 connectStart: _startTime + 25,
516 connectEnd: _startTime + 75,
517 secureConnectionStart: _startTime + 40,
518 requestStart: _startTime + 76,
519 responseStart: _startTime + 140,
520 responseEnd: _startTime + 185,
521 domLoading: _startTime + 190,
522 domInteractive: _startTime + 280,
523 domContentLoadedEventStart: _startTime + 290,
524 domContentLoadedEventEnd: _startTime + 295,
525 domComplete: _startTime + 310,
526 loadEventStart: _startTime + 315,
527 loadEventEnd: _startTime + 320
528 }},
529 navigation: {{
530 type: 0,
531 redirectCount: 0
532 }},
533 memory: {{
534 jsHeapSizeLimit: 4294705152,
535 totalJSHeapSize: 58410652,
536 usedJSHeapSize: 42892110
537 }},
538 getEntriesByType: function(type) {{
539 if (type === "navigation") {{
540 return [{{
541 name: globalThis.location ? globalThis.location.href : "https://example.com",
542 entryType: "navigation",
543 startTime: 0,
544 duration: 320,
545 initiatorType: "navigation",
546 nextHopProtocol: "h2",
547 renderBlockingStatus: "non-blocking",
548 responseStatus: 200
549 }}];
550 }}
551 return [];
552 }},
553 getEntriesByName: function() {{ return []; }},
554 getEntries: function() {{ return []; }}
555 }};
556 globalThis.window.performance = globalThis.performance;
557
558 // 11. CSS Media & Animation APIs
559 globalThis.matchMedia = function(query) {{
560 return {{
561 matches: query.includes("prefers-color-scheme") || query.includes("screen"),
562 media: query,
563 onchange: null,
564 addListener: function() {{}},
565 removeListener: function() {{}},
566 addEventListener: function() {{}},
567 removeEventListener: function() {{}},
568 dispatchEvent: function() {{ return false; }}
569 }};
570 }};
571 globalThis.window.matchMedia = globalThis.matchMedia;
572 globalThis.requestAnimationFrame = function(cb) {{ return setTimeout(cb, 16); }};
573 globalThis.cancelAnimationFrame = function(id) {{ clearTimeout(id); }};
574
575 // 12. Document & Location Defaults
576 globalThis.document = {{
577 title: "",
578 cookie: "",
579 referrer: "",
580 readyState: "complete",
581 characterSet: "UTF-8",
582 compatMode: "CSS1Compat",
583 location: {{
584 href: ""
585 }},
586 getElementById: function(id) {{ return null; }},
587 getElementsByTagName: function(tag) {{ return []; }},
588 querySelector: function(sel) {{ return null; }},
589 querySelectorAll: function(sel) {{ return []; }},
590 createElement: function(tag) {{
591 const tagUpper = String(tag).toUpperCase();
592 if (tagUpper === "CANVAS") {{
593 return {{
594 tagName: "CANVAS",
595 width: 300,
596 height: 150,
597 getContext: function(type) {{
598 if (type === "2d") {{
599 return {{
600 fillStyle: "#000000",
601 strokeStyle: "#000000",
602 font: "10px sans_serif",
603 fillRect: function() {{}},
604 strokeRect: function() {{}},
605 clearRect: function() {{}},
606 beginPath: function() {{}},
607 closePath: function() {{}},
608 moveTo: function() {{}},
609 lineTo: function() {{}},
610 arc: function() {{}},
611 fill: function() {{}},
612 stroke: function() {{}},
613 fillText: function() {{}},
614 strokeText: function() {{}},
615 measureText: function(text) {{
616 return {{
617 width: String(text).length * 7.5 + 0.02,
618 actualBoundingBoxAscent: 8,
619 actualBoundingBoxDescent: 2,
620 fontBoundingBoxAscent: 10,
621 fontBoundingBoxDescent: 3
622 }};
623 }},
624 getImageData: function(sx, sy, sw, sh) {{
625 const data = new Uint8ClampedArray((sw || 16) * (sh || 16) * 4);
626 for (let i = 0; i < data.length; i += 4) {{
627 data[i] = (i * 3 + 120) % 256;
628 data[i + 1] = (i * 7 + 80) % 256;
629 data[i + 2] = (i * 11 + 200) % 256;
630 data[i + 3] = 255;
631 }}
632 return {{ data: data, width: sw || 16, height: sh || 16 }};
633 }}
634 }};
635 }}
636 return new globalThis.WebGLRenderingContext();
637 }},
638 toDataURL: function() {{
639 return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAACWCAYAAAB5DiTlAAA=";
640 }},
641 style: {{}}
642 }};
643 }}
644 return {{
645 tagName: tagUpper,
646 setAttribute: function() {{}},
647 getAttribute: function() {{ return null; }},
648 appendChild: function() {{}},
649 style: {{}}
650 }};
651 }}
652 }};
653
654 globalThis.location = {{
655 href: "",
656 origin: "",
657 protocol: "https:",
658 host: "",
659 hostname: "",
660 pathname: "/",
661 search: ""
662 }};
663 "###,
664 ua = fp.user_agent,
665 platform = fp.platform,
666 touch = max_touch_points,
667 dpr = dpr,
668 is_mobile = mobile_bool,
669 sw = fp.screen_width,
670 sh = fp.screen_height,
671 gpu_vendor = gpu_vendor,
672 gpu_renderer = gpu_renderer
673 );
674
675 context
676 .eval(Source::from_bytes(&init_script))
677 .map_err(|e| anyhow::anyhow!("Failed to initialize JS runtime: {}", e))?;
678
679 Ok(Self { context })
680 }
681
682 pub fn update_page_state(&mut self, url: &str, title: &str) -> Result<()> {
683 let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\"");
684 let escaped_title = title.replace('\\', "\\\\").replace('"', "\\\"");
685
686 let update_script = format!(
687 r#"
688 document.title = "{}";
689 document.location.href = "{}";
690 location.href = "{}";
691 "#,
692 escaped_title, escaped_url, escaped_url
693 );
694
695 self.context
696 .eval(Source::from_bytes(&update_script))
697 .map_err(|e| anyhow::anyhow!("Failed to update JS page state: {}", e))?;
698
699 Ok(())
700 }
701
702 pub fn evaluate(&mut self, code: &str) -> Result<String> {
703 match self.context.eval(Source::from_bytes(code)) {
704 Ok(res) => Ok(res.display().to_string()),
705 Err(err) => Err(anyhow::anyhow!("JS Eval error: {}", err)),
706 }
707 }
708}