done all
This commit is contained in:
163
src/core/http_client.js
Normal file
163
src/core/http_client.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* HTTP Client - TLS Fingerprint Spoofing Layer
|
||||
*
|
||||
* WARNING: Standard axios/node-fetch = instant death.
|
||||
* Their JA3 fingerprint screams "I AM NODE.JS" to Cloudflare.
|
||||
*
|
||||
* We use got-scraping to mimic Chrome's TLS handshake.
|
||||
* HTTP/2 is enabled because hCaptcha's API uses it and
|
||||
* falling back to HTTP/1.1 is a red flag.
|
||||
*
|
||||
* Includes a simple cookie jar so Set-Cookie headers from one
|
||||
* response are automatically forwarded to subsequent requests
|
||||
* (critical for __cf_bm Cloudflare Bot-Management cookie).
|
||||
*/
|
||||
|
||||
import { gotScraping } from 'got-scraping';
|
||||
import { Logger } from '../utils/logger.js';
|
||||
|
||||
const logger = new Logger('HttpClient');
|
||||
|
||||
export class HttpClient {
|
||||
constructor(fingerprint = {}) {
|
||||
this.fingerprint = fingerprint;
|
||||
this.baseHeaders = this._buildHeaders();
|
||||
/** @type {Map<string,Map<string,string>>} rootDomain -> {name->value} */
|
||||
this.cookieJar = new Map();
|
||||
}
|
||||
|
||||
// ── headers ──────────────────────────────────────────────
|
||||
|
||||
_buildHeaders() {
|
||||
// Chrome 143 header set
|
||||
return {
|
||||
'accept': '*/*',
|
||||
'accept-encoding': 'gzip, deflate, br',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not(A:Brand";v="99"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Linux"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-fetch-storage-access': 'active',
|
||||
'user-agent': this.fingerprint.userAgent ||
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||
};
|
||||
}
|
||||
|
||||
// ── cookie helpers ───────────────────────────────────────
|
||||
|
||||
_rootDomain(hostname) {
|
||||
return hostname.replace(/^[^.]+\./, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Set-Cookie headers from a got-scraping response
|
||||
* and merge them into our cookie jar.
|
||||
*/
|
||||
_captureCookies(response) {
|
||||
const raw = response.headers['set-cookie'];
|
||||
if (!raw) return;
|
||||
|
||||
const items = Array.isArray(raw) ? raw : [raw];
|
||||
const url = new URL(response.url || response.requestUrl);
|
||||
const rootDomain = this._rootDomain(url.hostname);
|
||||
|
||||
if (!this.cookieJar.has(rootDomain)) {
|
||||
this.cookieJar.set(rootDomain, new Map());
|
||||
}
|
||||
const jar = this.cookieJar.get(rootDomain);
|
||||
|
||||
for (const cookie of items) {
|
||||
const [pair] = cookie.split(';');
|
||||
if (!pair) continue;
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx < 0) continue;
|
||||
jar.set(
|
||||
pair.substring(0, eqIdx).trim(),
|
||||
pair.substring(eqIdx + 1).trim(),
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`Cookies for ${rootDomain}: ${[...jar.keys()].join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the cookie header string for a given URL.
|
||||
*/
|
||||
_cookiesForUrl(url) {
|
||||
const hostname = new URL(url).hostname;
|
||||
const rootDomain = this._rootDomain(hostname);
|
||||
const jar = this.cookieJar.get(rootDomain) || this.cookieJar.get(hostname);
|
||||
if (!jar || jar.size === 0) return '';
|
||||
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
|
||||
// ── common request plumbing ──────────────────────────────
|
||||
|
||||
_gotOptions(method, url, headers, body) {
|
||||
const cookieHeader = this._cookiesForUrl(url);
|
||||
const mergedHeaders = { ...this.baseHeaders, ...headers };
|
||||
if (cookieHeader) mergedHeaders['cookie'] = cookieHeader;
|
||||
|
||||
const opts = {
|
||||
url,
|
||||
method,
|
||||
headers: mergedHeaders,
|
||||
headerGeneratorOptions: {
|
||||
browsers: ['chrome'],
|
||||
operatingSystems: ['linux'],
|
||||
},
|
||||
http2: true, // ← critical: use HTTP/2
|
||||
throwHttpErrors: false, // we handle status ourselves
|
||||
responseType: 'buffer', // always get raw buffer
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
// Buffer / Uint8Array → send as-is
|
||||
// string → send as-is
|
||||
// object → JSON.stringify
|
||||
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
|
||||
opts.body = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
||||
} else if (typeof body === 'string') {
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap got-scraping and return a uniform result.
|
||||
*/
|
||||
async _request(method, url, headers = {}, body) {
|
||||
const opts = this._gotOptions(method, url, headers, body);
|
||||
const response = await gotScraping(opts);
|
||||
this._captureCookies(response);
|
||||
return {
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: response.body, // Buffer
|
||||
text: () => response.body.toString('utf-8'),
|
||||
json: () => JSON.parse(response.body.toString('utf-8')),
|
||||
url: response.url || url,
|
||||
};
|
||||
}
|
||||
|
||||
// ── HTTP verbs ───────────────────────────────────────────
|
||||
|
||||
async get(url, options = {}) {
|
||||
return this._request('GET', url, options.headers);
|
||||
}
|
||||
|
||||
async post(url, body, options = {}) {
|
||||
return this._request('POST', url, options.headers, body);
|
||||
}
|
||||
|
||||
async options(url, options = {}) {
|
||||
return this._request('OPTIONS', url, options.headers);
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,17 @@
|
||||
* 使用 hsw.js 在 Node 沙盒中运行(全局污染方式)
|
||||
*/
|
||||
|
||||
const vm = require('vm');
|
||||
const { readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
const msgpack = require('msgpack-lite');
|
||||
const { createBrowserEnvironment } = require('./sandbox/mocks/index');
|
||||
const windowMock = require('./sandbox/mocks/window');
|
||||
const { applySandboxPatches } = require('./sandbox/mocks/index');
|
||||
const { Logger } = require('./utils/logger');
|
||||
|
||||
const logger = new Logger('hcaptcha_solver');
|
||||
|
||||
// 保存原始 fetch(在全局被 mock 污染之前)
|
||||
// 保存原始 fetch(供真实网络请求使用)
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
// ── 常量 ──────────────────────────────────────────────────────
|
||||
@@ -82,76 +84,86 @@ class HswBridge {
|
||||
constructor() {
|
||||
this.hswFn = null;
|
||||
this.initialized = false;
|
||||
this._savedGlobals = {};
|
||||
this._ctx = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 mock 注入全局,加载并执行 hsw.js
|
||||
* 构建 vm 沙盒上下文,将 mock window 注入隔离环境
|
||||
* @param {object} fingerprint - 指纹覆盖
|
||||
*/
|
||||
_buildContext(fingerprint) {
|
||||
const ctx = Object.create(null);
|
||||
|
||||
// 把 windowMock 上所有 key 复制进 ctx(浅拷贝)
|
||||
for (const key of Reflect.ownKeys(windowMock)) {
|
||||
try { ctx[key] = windowMock[key]; } catch (_) {}
|
||||
}
|
||||
|
||||
// vm 必需的自引用
|
||||
ctx.global = ctx;
|
||||
ctx.globalThis = ctx;
|
||||
ctx.window = ctx;
|
||||
ctx.self = ctx;
|
||||
|
||||
// 透传 console(调试用)
|
||||
ctx.console = console;
|
||||
|
||||
// 保证 Promise / 定时器在 vm 里可用
|
||||
ctx.Promise = Promise;
|
||||
ctx.setTimeout = setTimeout;
|
||||
ctx.clearTimeout = clearTimeout;
|
||||
ctx.setInterval = setInterval;
|
||||
ctx.clearInterval = clearInterval;
|
||||
ctx.queueMicrotask = queueMicrotask;
|
||||
|
||||
// 应用指纹覆盖
|
||||
if (fingerprint.userAgent && ctx.navigator) {
|
||||
ctx.navigator.userAgent = fingerprint.userAgent;
|
||||
ctx.navigator.appVersion = fingerprint.userAgent.replace('Mozilla/', '');
|
||||
}
|
||||
if (fingerprint.platform && ctx.navigator) {
|
||||
ctx.navigator.platform = fingerprint.platform;
|
||||
}
|
||||
if (fingerprint.host && ctx.location?.ancestorOrigins) {
|
||||
ctx.location.ancestorOrigins[0] = `https://${fingerprint.host}`;
|
||||
}
|
||||
|
||||
const vmCtx = vm.createContext(ctx);
|
||||
|
||||
// Apply escape defense + error stack rewriting AFTER context creation
|
||||
applySandboxPatches(vmCtx);
|
||||
|
||||
return vmCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 vm 沙盒中加载并执行 hsw.js
|
||||
* @param {string} hswPath - hsw.js 文件路径
|
||||
* @param {object} fingerprint - 指纹覆盖
|
||||
*/
|
||||
async init(hswPath, fingerprint = {}) {
|
||||
if (this.initialized) return;
|
||||
|
||||
const env = createBrowserEnvironment(fingerprint);
|
||||
|
||||
// 保存原始全局
|
||||
const keys = ['window', 'document', 'navigator', 'screen', 'location',
|
||||
'localStorage', 'sessionStorage', 'crypto', 'performance',
|
||||
'self', 'top', 'parent', 'fetch', 'XMLHttpRequest'];
|
||||
for (const k of keys) {
|
||||
if (k in globalThis) this._savedGlobals[k] = globalThis[k];
|
||||
}
|
||||
|
||||
// 注入全局
|
||||
const force = (obj, prop, val) => {
|
||||
Object.defineProperty(obj, prop, {
|
||||
value: val, writable: true, configurable: true, enumerable: true,
|
||||
});
|
||||
};
|
||||
force(globalThis, 'window', env.window);
|
||||
force(globalThis, 'document', env.document);
|
||||
force(globalThis, 'navigator', env.navigator);
|
||||
force(globalThis, 'screen', env.screen);
|
||||
force(globalThis, 'location', env.location);
|
||||
force(globalThis, 'localStorage', env.localStorage);
|
||||
force(globalThis, 'sessionStorage', env.sessionStorage);
|
||||
force(globalThis, 'crypto', env.crypto);
|
||||
force(globalThis, 'performance', env.performance);
|
||||
force(globalThis, 'self', env.window);
|
||||
force(globalThis, 'top', env.window);
|
||||
force(globalThis, 'parent', env.window);
|
||||
|
||||
// 浏览器 API
|
||||
globalThis.fetch = env.window.fetch;
|
||||
globalThis.btoa = env.window.btoa;
|
||||
globalThis.atob = env.window.atob;
|
||||
globalThis.setTimeout = env.window.setTimeout;
|
||||
globalThis.setInterval = env.window.setInterval;
|
||||
globalThis.clearTimeout = env.window.clearTimeout;
|
||||
globalThis.clearInterval = env.window.clearInterval;
|
||||
globalThis.TextEncoder = env.window.TextEncoder;
|
||||
globalThis.TextDecoder = env.window.TextDecoder;
|
||||
globalThis.requestAnimationFrame = env.window.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = env.window.cancelAnimationFrame;
|
||||
|
||||
// 加载 hsw.js
|
||||
const code = readFileSync(hswPath, 'utf-8');
|
||||
logger.info(`hsw.js 已加载 (${(code.length / 1024).toFixed(1)} KB)`);
|
||||
|
||||
const ctx = this._buildContext(fingerprint);
|
||||
this._ctx = ctx;
|
||||
|
||||
const script = new vm.Script(code, { filename: 'hsw.js' });
|
||||
|
||||
try {
|
||||
const fn = new Function(`(function() { ${code} })();`);
|
||||
fn();
|
||||
script.runInContext(ctx, { timeout: 10000 });
|
||||
} catch (err) {
|
||||
logger.error(`hsw.js 执行失败: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 查找 hsw 函数
|
||||
if (typeof globalThis.window?.hsw === 'function') {
|
||||
this.hswFn = globalThis.window.hsw;
|
||||
} else if (typeof globalThis.hsw === 'function') {
|
||||
this.hswFn = globalThis.hsw;
|
||||
if (typeof ctx.hsw === 'function') {
|
||||
this.hswFn = ctx.hsw;
|
||||
} else if (typeof ctx.window?.hsw === 'function') {
|
||||
this.hswFn = ctx.window.hsw;
|
||||
}
|
||||
|
||||
if (!this.hswFn) {
|
||||
@@ -159,7 +171,7 @@ class HswBridge {
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
logger.success('Bridge 已就绪');
|
||||
logger.success('Bridge 已就绪 (vm 沙盒)');
|
||||
}
|
||||
|
||||
/** 计算 PoW n 值: hsw(req_jwt_string) */
|
||||
|
||||
273
src/probe/deep_proxy.js
Normal file
273
src/probe/deep_proxy.js
Normal file
@@ -0,0 +1,273 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 深层递归 Proxy 引擎
|
||||
* 包裹任意对象,记录所有属性访问(get/has/set/ownKeys/getOwnPropertyDescriptor/deleteProperty)
|
||||
*/
|
||||
|
||||
// 不应被 Proxy 包装的类型(有内部槽位或会导致问题)
|
||||
const UNPROXYABLE_TYPES = [
|
||||
ArrayBuffer, SharedArrayBuffer,
|
||||
Uint8Array, Int8Array, Uint16Array, Int16Array,
|
||||
Uint32Array, Int32Array, Uint8ClampedArray,
|
||||
Float32Array, Float64Array, DataView,
|
||||
RegExp, Date, Error, TypeError, RangeError, SyntaxError,
|
||||
Promise,
|
||||
];
|
||||
|
||||
// 不递归代理的 key(避免无限循环或干扰日志输出)
|
||||
const SKIP_KEYS = new Set([
|
||||
'console', 'Symbol', 'undefined', 'NaN', 'Infinity',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 格式化 key 为可读字符串
|
||||
*/
|
||||
function formatKey(key) {
|
||||
if (typeof key === 'symbol') {
|
||||
return `Symbol(${key.description || ''})`;
|
||||
}
|
||||
return String(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取值的简短类型描述
|
||||
*/
|
||||
function valueType(val) {
|
||||
if (val === null) return 'null';
|
||||
if (val === undefined) return 'undefined';
|
||||
const t = typeof val;
|
||||
if (t !== 'object' && t !== 'function') return t;
|
||||
if (t === 'function') {
|
||||
return val.name ? `function:${val.name}` : 'function';
|
||||
}
|
||||
if (Array.isArray(val)) return `array[${val.length}]`;
|
||||
const ctor = val.constructor?.name;
|
||||
return ctor ? `object:${ctor}` : 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断值是否不应被 Proxy 包装
|
||||
*/
|
||||
function isUnproxyable(val) {
|
||||
if (val === null || val === undefined) return true;
|
||||
const t = typeof val;
|
||||
if (t !== 'object' && t !== 'function') return true;
|
||||
// WebAssembly 模块/实例有内部槽位
|
||||
if (typeof WebAssembly !== 'undefined') {
|
||||
if (val instanceof WebAssembly.Module || val instanceof WebAssembly.Instance ||
|
||||
val instanceof WebAssembly.Memory || val instanceof WebAssembly.Table) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const Ctor of UNPROXYABLE_TYPES) {
|
||||
try { if (val instanceof Ctor) return true; } catch (_) {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建深层递归 Proxy
|
||||
* @param {any} target - 被包裹的目标对象
|
||||
* @param {string} path - 当前路径(如 'window.navigator')
|
||||
* @param {object} log - 日志收集器 { entries: Map<path, entry>, raw: [] }
|
||||
* @returns {Proxy}
|
||||
*/
|
||||
function deepProxy(target, path, log) {
|
||||
// proxyCache: 防止重复包装 + 处理循环引用(window.window.window...)
|
||||
const proxyCache = log._proxyCache || (log._proxyCache = new WeakMap());
|
||||
// resolvingPaths: 防止 getter 内部访问同一路径导致无限递归
|
||||
const resolvingPaths = log._resolvingPaths || (log._resolvingPaths = new Set());
|
||||
|
||||
if (isUnproxyable(target)) return target;
|
||||
if (proxyCache.has(target)) return proxyCache.get(target);
|
||||
|
||||
const proxy = new Proxy(target, {
|
||||
get(obj, key, receiver) {
|
||||
const keyStr = formatKey(key);
|
||||
const fullPath = path ? `${path}.${keyStr}` : keyStr;
|
||||
|
||||
// 跳过不代理的 key
|
||||
if (SKIP_KEYS.has(keyStr)) {
|
||||
try { return Reflect.get(obj, key, receiver); } catch (e) { return undefined; }
|
||||
}
|
||||
|
||||
// 防止无限递归
|
||||
if (resolvingPaths.has(fullPath)) {
|
||||
try { return Reflect.get(obj, key, receiver); } catch (e) { return undefined; }
|
||||
}
|
||||
|
||||
let result, val, error;
|
||||
resolvingPaths.add(fullPath);
|
||||
try {
|
||||
val = Reflect.get(obj, key, receiver);
|
||||
result = val === undefined ? 'undefined' : 'found';
|
||||
} catch (e) {
|
||||
result = 'error';
|
||||
error = e.message;
|
||||
val = undefined;
|
||||
} finally {
|
||||
resolvingPaths.delete(fullPath);
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
recordAccess(log, {
|
||||
path: fullPath,
|
||||
trap: 'get',
|
||||
result,
|
||||
valueType: result === 'error' ? 'error' : valueType(val),
|
||||
error,
|
||||
});
|
||||
|
||||
// 对对象/函数结果递归包装
|
||||
// 但必须尊重 Proxy 不变量:non-configurable + non-writable 属性必须返回原值
|
||||
if (val !== null && val !== undefined && !isUnproxyable(val)) {
|
||||
const t = typeof val;
|
||||
if (t === 'object' || t === 'function') {
|
||||
// 检查属性描述符:若 non-configurable 且 non-writable,不能包装
|
||||
let canWrap = true;
|
||||
try {
|
||||
const desc = Object.getOwnPropertyDescriptor(obj, key);
|
||||
if (desc && !desc.configurable && !desc.writable && !desc.set) {
|
||||
canWrap = false;
|
||||
}
|
||||
} catch (_) {}
|
||||
if (canWrap) {
|
||||
return deepProxy(val, fullPath, log);
|
||||
}
|
||||
}
|
||||
}
|
||||
return val;
|
||||
},
|
||||
|
||||
has(obj, key) {
|
||||
const keyStr = formatKey(key);
|
||||
const fullPath = path ? `${path}.${keyStr}` : keyStr;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = Reflect.has(obj, key);
|
||||
} catch (e) {
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'has', result: 'error',
|
||||
valueType: 'error', error: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'has',
|
||||
result: result ? 'true' : 'false',
|
||||
valueType: 'boolean',
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
||||
set(obj, key, value) {
|
||||
const keyStr = formatKey(key);
|
||||
const fullPath = path ? `${path}.${keyStr}` : keyStr;
|
||||
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'set',
|
||||
result: 'write', valueType: valueType(value),
|
||||
});
|
||||
|
||||
try {
|
||||
return Reflect.set(obj, key, value);
|
||||
} catch (e) {
|
||||
// 静默失败,不阻塞 hsw
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
ownKeys(obj) {
|
||||
const fullPath = path || 'window';
|
||||
let keys;
|
||||
try {
|
||||
keys = Reflect.ownKeys(obj);
|
||||
} catch (e) {
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'ownKeys',
|
||||
result: 'error', valueType: 'error', error: e.message,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'ownKeys',
|
||||
result: `keys[${keys.length}]`, valueType: 'array',
|
||||
});
|
||||
return keys;
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(obj, key) {
|
||||
const keyStr = formatKey(key);
|
||||
const fullPath = path ? `${path}.${keyStr}` : keyStr;
|
||||
|
||||
let desc;
|
||||
try {
|
||||
desc = Reflect.getOwnPropertyDescriptor(obj, key);
|
||||
} catch (e) {
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'getOwnPropertyDescriptor',
|
||||
result: 'error', valueType: 'error', error: e.message,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'getOwnPropertyDescriptor',
|
||||
result: desc ? 'found' : 'undefined',
|
||||
valueType: desc ? 'descriptor' : 'undefined',
|
||||
});
|
||||
return desc;
|
||||
},
|
||||
|
||||
deleteProperty(obj, key) {
|
||||
const keyStr = formatKey(key);
|
||||
const fullPath = path ? `${path}.${keyStr}` : keyStr;
|
||||
|
||||
recordAccess(log, {
|
||||
path: fullPath, trap: 'deleteProperty',
|
||||
result: 'delete', valueType: 'void',
|
||||
});
|
||||
|
||||
try {
|
||||
return Reflect.deleteProperty(obj, key);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
proxyCache.set(target, proxy);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次访问到日志收集器
|
||||
*/
|
||||
function recordAccess(log, entry) {
|
||||
const key = `${entry.trap}:${entry.path}`;
|
||||
const existing = log.entries.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
log.entries.set(key, { ...entry, count: 1 });
|
||||
}
|
||||
log.totalAccesses++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的日志收集器
|
||||
*/
|
||||
function createLog() {
|
||||
return {
|
||||
entries: new Map(),
|
||||
totalAccesses: 0,
|
||||
_proxyCache: new WeakMap(),
|
||||
_resolvingPaths: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { deepProxy, createLog, formatKey, valueType };
|
||||
241
src/probe/probe_env.js
Normal file
241
src/probe/probe_env.js
Normal file
@@ -0,0 +1,241 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 环境探针入口
|
||||
* 用深层递归 Proxy 包裹 VM 上下文,记录 hsw.js 执行期间的每一次属性访问
|
||||
*
|
||||
* 用法:
|
||||
* node src/probe/probe_env.js # 仅初始化探测
|
||||
* node src/probe/probe_env.js --live # 初始化 + 用真实 JWT 调用 hsw()
|
||||
*/
|
||||
|
||||
const vm = require('vm');
|
||||
const { readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
const { deepProxy, createLog } = require('./deep_proxy');
|
||||
const { printReport, writeJsonReport } = require('./report');
|
||||
|
||||
// ── 加载 native.js 补丁(进程级 Function.prototype.toString 伪装) ──
|
||||
require('../sandbox/mocks/native');
|
||||
|
||||
// ── 加载 window mock ──
|
||||
const windowMock = require('../sandbox/mocks/window');
|
||||
|
||||
// ── hsw.js 路径 ──
|
||||
const HSW_PATH = join(__dirname, '../../asset/hsw.js');
|
||||
|
||||
/**
|
||||
* 组装 VM 上下文(复制自 hcaptcha_solver._buildContext)
|
||||
*/
|
||||
function buildProbedContext(log) {
|
||||
const ctx = Object.create(null);
|
||||
|
||||
// 把 windowMock 上所有 key 复制进 ctx(浅拷贝)
|
||||
for (const key of Reflect.ownKeys(windowMock)) {
|
||||
try { ctx[key] = windowMock[key]; } catch (_) {}
|
||||
}
|
||||
|
||||
// vm 必需的自引用
|
||||
ctx.global = ctx;
|
||||
ctx.globalThis = ctx;
|
||||
ctx.window = ctx;
|
||||
ctx.self = ctx;
|
||||
ctx.frames = ctx;
|
||||
ctx.parent = ctx;
|
||||
ctx.top = ctx;
|
||||
|
||||
// 透传 console(调试用,不代理)
|
||||
ctx.console = console;
|
||||
|
||||
// 保证 Promise / 定时器在 vm 里可用
|
||||
ctx.Promise = Promise;
|
||||
ctx.setTimeout = setTimeout;
|
||||
ctx.clearTimeout = clearTimeout;
|
||||
ctx.setInterval = setInterval;
|
||||
ctx.clearInterval = clearInterval;
|
||||
ctx.queueMicrotask = queueMicrotask;
|
||||
|
||||
// 用深层 Proxy 包裹整个上下文
|
||||
const proxiedCtx = deepProxy(ctx, 'window', log);
|
||||
|
||||
// 尝试直接用 Proxy 创建 vm context
|
||||
try {
|
||||
return { ctx: vm.createContext(proxiedCtx), proxied: true };
|
||||
} catch (e) {
|
||||
// Node 版本不支持直接传 Proxy 给 createContext
|
||||
// 回退方案:先 createContext 普通对象,再用 getter/setter 桥接
|
||||
console.warn(`[probe] Proxy createContext 失败,使用 getter 桥接: ${e.message}`);
|
||||
const plainCtx = Object.create(null);
|
||||
|
||||
for (const key of Reflect.ownKeys(ctx)) {
|
||||
try {
|
||||
Object.defineProperty(plainCtx, key, {
|
||||
get() { return proxiedCtx[key]; },
|
||||
set(v) { proxiedCtx[key] = v; },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch (_) {
|
||||
try { plainCtx[key] = ctx[key]; } catch (__) {}
|
||||
}
|
||||
}
|
||||
|
||||
return { ctx: vm.createContext(plainCtx), proxied: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const isLive = args.includes('--live');
|
||||
|
||||
console.log('[probe] 环境探针启动...');
|
||||
console.log(`[probe] 模式: ${isLive ? '初始化 + 真实调用' : '仅初始化'}`);
|
||||
|
||||
// 读取 hsw.js
|
||||
let hswCode;
|
||||
try {
|
||||
hswCode = readFileSync(HSW_PATH, 'utf-8');
|
||||
console.log(`[probe] hsw.js 已加载 (${(hswCode.length / 1024).toFixed(1)} KB)`);
|
||||
} catch (e) {
|
||||
console.error(`[probe] 无法读取 hsw.js: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 创建日志收集器
|
||||
const log = createLog();
|
||||
|
||||
// 构建带 Proxy 的上下文
|
||||
const { ctx, proxied } = buildProbedContext(log);
|
||||
console.log(`[probe] VM 上下文已创建 (Proxy直传: ${proxied})`);
|
||||
|
||||
// 编译并执行 hsw.js
|
||||
const script = new vm.Script(hswCode, { filename: 'hsw.js' });
|
||||
|
||||
let hswInitOk = false;
|
||||
try {
|
||||
script.runInContext(ctx, { timeout: 30000 });
|
||||
hswInitOk = true;
|
||||
console.log('[probe] hsw.js 初始化成功');
|
||||
} catch (e) {
|
||||
console.error(`[probe] hsw.js 初始化失败: ${e.message}`);
|
||||
if (e.stack) {
|
||||
// 只打印前 5 行 stack
|
||||
const lines = e.stack.split('\n').slice(0, 5);
|
||||
console.error(lines.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
// 查找 hsw 函数
|
||||
let hswFn = null;
|
||||
try {
|
||||
if (typeof ctx.hsw === 'function') {
|
||||
hswFn = ctx.hsw;
|
||||
} else if (ctx.window && typeof ctx.window.hsw === 'function') {
|
||||
hswFn = ctx.window.hsw;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (hswFn) {
|
||||
console.log('[probe] hsw 函数已找到');
|
||||
} else {
|
||||
console.warn('[probe] 未找到 hsw 函数');
|
||||
}
|
||||
|
||||
// --live 模式:获取真实 JWT 调用 hsw()
|
||||
let hswCallOk = false;
|
||||
if (isLive && hswFn) {
|
||||
console.log('[probe] 获取真实 JWT 进行 hsw 调用...');
|
||||
try {
|
||||
const jwt = await fetchLiveJwt();
|
||||
if (jwt) {
|
||||
console.log(`[probe] JWT 获取成功 (${jwt.substring(0, 40)}...)`);
|
||||
const result = await Promise.race([
|
||||
Promise.resolve(hswFn(jwt)),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('hsw 调用超时 (30s)')), 30000)),
|
||||
]);
|
||||
hswCallOk = true;
|
||||
console.log(`[probe] hsw 调用成功,结果长度: ${String(result).length}`);
|
||||
} else {
|
||||
console.warn('[probe] JWT 获取失败,跳过 hsw 调用');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[probe] hsw 调用失败: ${e.message}`);
|
||||
}
|
||||
} else if (isLive && !hswFn) {
|
||||
console.warn('[probe] --live 模式但 hsw 函数不可用,跳过调用');
|
||||
}
|
||||
|
||||
// 生成报告
|
||||
const meta = {
|
||||
timestamp: new Date().toISOString(),
|
||||
totalAccesses: log.totalAccesses,
|
||||
uniquePaths: log.entries.size,
|
||||
hswInitOk,
|
||||
hswCallOk,
|
||||
proxyDirect: proxied,
|
||||
};
|
||||
|
||||
printReport(log.entries, meta);
|
||||
const jsonPath = writeJsonReport(log.entries, meta);
|
||||
console.log(`[probe] JSON 报告已保存: ${jsonPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取真实 checksiteconfig JWT(用于 --live 模式)
|
||||
* 直接调用 got-scraping,避免 ESM/CJS 不兼容问题
|
||||
*/
|
||||
async function fetchLiveJwt() {
|
||||
try {
|
||||
const { gotScraping } = await import('got-scraping');
|
||||
|
||||
const sitekey = '4c672d35-0701-42b2-88c3-78380b0db560'; // stripe 公用 sitekey
|
||||
const host = 'js.stripe.com';
|
||||
const url = `https://api2.hcaptcha.com/checksiteconfig?v=1ffa597&host=${host}&sitekey=${sitekey}&sc=1&swa=1&spst=1`;
|
||||
|
||||
const response = await gotScraping({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'referer': 'https://newassets.hcaptcha.com/',
|
||||
'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not(A:Brand";v="99"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Linux"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||
},
|
||||
headerGeneratorOptions: {
|
||||
browsers: ['chrome'],
|
||||
operatingSystems: ['linux'],
|
||||
},
|
||||
http2: true,
|
||||
throwHttpErrors: false,
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
if (body && body.c && body.c.req) {
|
||||
return body.c.req;
|
||||
}
|
||||
|
||||
console.warn('[probe] checksiteconfig 响应中无 c.req 字段');
|
||||
console.warn('[probe] 响应:', JSON.stringify(body).substring(0, 200));
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.warn(`[probe] 获取 JWT 失败: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行 ──
|
||||
main().catch(e => {
|
||||
console.error(`[probe] 致命错误: ${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
229
src/probe/report.js
Normal file
229
src/probe/report.js
Normal file
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
const { writeFileSync, mkdirSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// ANSI 颜色码
|
||||
const C = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
gray: '\x1b[90m',
|
||||
};
|
||||
|
||||
// 分类规则:路径前缀 → 类别名
|
||||
const CATEGORIES = [
|
||||
['navigator', 'Navigator'],
|
||||
['screen', 'Screen'],
|
||||
['performance', 'Performance'],
|
||||
['crypto', 'Crypto'],
|
||||
['canvas', 'Canvas'],
|
||||
['Canvas', 'Canvas'],
|
||||
['webgl', 'WebGL'],
|
||||
['WebGL', 'WebGL'],
|
||||
['localStorage', 'Storage'],
|
||||
['sessionStorage','Storage'],
|
||||
['indexedDB', 'Storage'],
|
||||
['IDB', 'Storage'],
|
||||
['Storage', 'Storage'],
|
||||
['Worker', 'Workers'],
|
||||
['SharedWorker', 'Workers'],
|
||||
['ServiceWorker','Workers'],
|
||||
['document', 'Document'],
|
||||
['HTML', 'Document'],
|
||||
['location', 'Location'],
|
||||
['history', 'History'],
|
||||
['fetch', 'Network'],
|
||||
['Request', 'Network'],
|
||||
['Response', 'Network'],
|
||||
['Headers', 'Network'],
|
||||
['URL', 'Network'],
|
||||
['WebSocket', 'Network'],
|
||||
['RTC', 'WebRTC'],
|
||||
['Audio', 'Audio'],
|
||||
['OfflineAudio', 'Audio'],
|
||||
['WebAssembly', 'WebAssembly'],
|
||||
['Notification', 'Notification'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据路径判断类别
|
||||
*/
|
||||
function categorize(path) {
|
||||
// 取第一级有意义的 key(跳过 window. 前缀)
|
||||
const clean = path.replace(/^window\./, '');
|
||||
for (const [prefix, cat] of CATEGORIES) {
|
||||
if (clean.startsWith(prefix)) return cat;
|
||||
}
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成控制台报告
|
||||
* @param {Map} entries - 日志 entries
|
||||
* @param {object} meta - 元信息
|
||||
*/
|
||||
function printReport(entries, meta) {
|
||||
console.log('\n' + C.bold + C.cyan + '═══════════════════════════════════════════════════════════' + C.reset);
|
||||
console.log(C.bold + C.cyan + ' 环境探针报告 — hCaptcha hsw.js Environment Probe' + C.reset);
|
||||
console.log(C.bold + C.cyan + '═══════════════════════════════════════════════════════════' + C.reset);
|
||||
|
||||
// 元信息
|
||||
console.log(`\n${C.gray}时间:${C.reset} ${meta.timestamp}`);
|
||||
console.log(`${C.gray}总访问次数:${C.reset} ${meta.totalAccesses}`);
|
||||
console.log(`${C.gray}唯一路径数:${C.reset} ${meta.uniquePaths}`);
|
||||
console.log(`${C.gray}hsw 初始化:${C.reset} ${meta.hswInitOk ? C.green + '✓' : C.red + '✗'}${C.reset}`);
|
||||
console.log(`${C.gray}hsw 调用:${C.reset} ${meta.hswCallOk ? C.green + '✓' : C.red + '✗'}${C.reset}`);
|
||||
|
||||
// 按类别分组(仅 get trap)
|
||||
const grouped = {};
|
||||
const allMissing = [];
|
||||
const allErrors = [];
|
||||
|
||||
for (const [, entry] of entries) {
|
||||
if (entry.trap !== 'get') continue;
|
||||
|
||||
const cat = categorize(entry.path);
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(entry);
|
||||
|
||||
if (entry.result === 'undefined') allMissing.push(entry);
|
||||
if (entry.result === 'error') allErrors.push(entry);
|
||||
}
|
||||
|
||||
// 排序类别名
|
||||
const catOrder = [
|
||||
'Navigator', 'Screen', 'Performance', 'Crypto', 'Canvas', 'WebGL',
|
||||
'Storage', 'Workers', 'Document', 'Location', 'History',
|
||||
'Network', 'WebRTC', 'Audio', 'WebAssembly', 'Notification', 'Other',
|
||||
];
|
||||
const sortedCats = Object.keys(grouped).sort((a, b) => {
|
||||
const ai = catOrder.indexOf(a);
|
||||
const bi = catOrder.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
|
||||
for (const cat of sortedCats) {
|
||||
const items = grouped[cat];
|
||||
console.log(`\n${C.bold}${C.blue}── ${cat} ──${C.reset} ${C.dim}(${items.length} paths)${C.reset}`);
|
||||
|
||||
// 排序: error > undefined > found,按访问次数降序
|
||||
items.sort((a, b) => {
|
||||
const order = { error: 0, undefined: 1, found: 2 };
|
||||
const d = (order[a.result] ?? 3) - (order[b.result] ?? 3);
|
||||
if (d !== 0) return d;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
let icon, color;
|
||||
if (item.result === 'error') {
|
||||
icon = '⚠'; color = C.yellow;
|
||||
} else if (item.result === 'undefined') {
|
||||
icon = '✗'; color = C.red;
|
||||
} else {
|
||||
icon = '✓'; color = C.green;
|
||||
}
|
||||
|
||||
const countStr = item.count > 1 ? `${C.dim} ×${item.count}${C.reset}` : '';
|
||||
const typeStr = `${C.gray}[${item.valueType}]${C.reset}`;
|
||||
const cleanPath = item.path.replace(/^window\./, '');
|
||||
console.log(` ${color}${icon}${C.reset} ${cleanPath} ${typeStr}${countStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总:缺失属性
|
||||
if (allMissing.length > 0) {
|
||||
console.log(`\n${C.bold}${C.red}═══ 缺失属性 (${allMissing.length}) ═══${C.reset}`);
|
||||
// 按频率降序
|
||||
allMissing.sort((a, b) => b.count - a.count);
|
||||
for (const m of allMissing) {
|
||||
const cleanPath = m.path.replace(/^window\./, '');
|
||||
const countStr = m.count > 1 ? ` ×${m.count}` : '';
|
||||
console.log(` ${C.red}•${C.reset} ${cleanPath}${C.dim}${countStr}${C.reset}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总:错误
|
||||
if (allErrors.length > 0) {
|
||||
console.log(`\n${C.bold}${C.yellow}═══ 访问错误 (${allErrors.length}) ═══${C.reset}`);
|
||||
for (const e of allErrors) {
|
||||
const cleanPath = e.path.replace(/^window\./, '');
|
||||
console.log(` ${C.yellow}•${C.reset} ${cleanPath}: ${e.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + C.bold + C.cyan + '═══════════════════════════════════════════════════════════' + C.reset + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JSON 报告文件
|
||||
* @param {Map} entries - 日志 entries
|
||||
* @param {object} meta - 元信息
|
||||
* @returns {string} 报告文件路径
|
||||
*/
|
||||
function writeJsonReport(entries, meta) {
|
||||
const missing = [];
|
||||
const found = {};
|
||||
const errors = [];
|
||||
const frequency = [];
|
||||
|
||||
for (const [, entry] of entries) {
|
||||
if (entry.trap !== 'get') continue;
|
||||
|
||||
const cleanPath = entry.path.replace(/^window\./, '');
|
||||
|
||||
if (entry.result === 'undefined') {
|
||||
missing.push(cleanPath);
|
||||
} else if (entry.result === 'error') {
|
||||
errors.push({ path: cleanPath, error: entry.error, count: entry.count });
|
||||
} else {
|
||||
found[cleanPath] = { type: entry.valueType, count: entry.count };
|
||||
}
|
||||
|
||||
frequency.push({ path: cleanPath, count: entry.count, result: entry.result });
|
||||
}
|
||||
|
||||
// 频率 Top 50
|
||||
frequency.sort((a, b) => b.count - a.count);
|
||||
const frequencyTop50 = frequency.slice(0, 50);
|
||||
|
||||
// 非-get trap 的汇总
|
||||
const otherTraps = {};
|
||||
for (const [, entry] of entries) {
|
||||
if (entry.trap === 'get') continue;
|
||||
if (!otherTraps[entry.trap]) otherTraps[entry.trap] = [];
|
||||
otherTraps[entry.trap].push({
|
||||
path: entry.path.replace(/^window\./, ''),
|
||||
result: entry.result,
|
||||
count: entry.count,
|
||||
});
|
||||
}
|
||||
|
||||
const report = {
|
||||
meta,
|
||||
missing: missing.sort(),
|
||||
found,
|
||||
errors,
|
||||
frequencyTop50,
|
||||
otherTraps,
|
||||
};
|
||||
|
||||
const reportsDir = join(__dirname, 'reports');
|
||||
mkdirSync(reportsDir, { recursive: true });
|
||||
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filePath = join(reportsDir, `probe_${ts}.json`);
|
||||
writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf-8');
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
module.exports = { printReport, writeJsonReport };
|
||||
@@ -2,6 +2,7 @@
|
||||
/**
|
||||
* HSW Runner
|
||||
* 用 vm 沙盒加载 hsw.js,注入 mock window,调用 window.hsw(req, callback)
|
||||
* Now with: constructor chain escape defense, error stack rewriting
|
||||
*
|
||||
* 用法:
|
||||
* const { solveHsw } = require('./hsw_runner');
|
||||
@@ -16,6 +17,7 @@ const HSW_PATH = path.resolve(__dirname, '../../asset/hsw.js');
|
||||
|
||||
// ── 加载 window mock ─────────────────────────────────────────
|
||||
const windowMock = require('./mocks/window');
|
||||
const { applySandboxPatches } = require('./mocks/index');
|
||||
|
||||
// ── 读取 hsw.js 源码(只读一次) ─────────────────────────────
|
||||
const hswCode = fs.readFileSync(HSW_PATH, 'utf-8');
|
||||
@@ -47,7 +49,12 @@ function buildContext() {
|
||||
ctx.clearInterval = clearInterval;
|
||||
ctx.queueMicrotask = queueMicrotask;
|
||||
|
||||
return vm.createContext(ctx);
|
||||
const vmCtx = vm.createContext(ctx);
|
||||
|
||||
// Apply escape defense + error stack rewriting AFTER context creation
|
||||
applySandboxPatches(vmCtx);
|
||||
|
||||
return vmCtx;
|
||||
}
|
||||
|
||||
// ── 编译脚本(只编译一次,复用) ─────────────────────────────
|
||||
|
||||
@@ -30,10 +30,18 @@ const BOT_KEYS = new Set([
|
||||
'CDCJStestRunStatus',
|
||||
'$cdc_asdjflasutopfhvcZLmcfl_',
|
||||
'$chrome_asyncScriptInfo',
|
||||
'__wdata',
|
||||
]);
|
||||
|
||||
// Node.js 宿主字段 — 真实 Chrome window 上连描述符都不该有
|
||||
const NODE_KEYS = new Set([
|
||||
'process', 'require', 'Buffer', 'module', 'exports',
|
||||
'__dirname', '__filename',
|
||||
]);
|
||||
|
||||
function isBotKey(key) {
|
||||
if (BOT_KEYS.has(key)) return true;
|
||||
if (NODE_KEYS.has(key)) return true;
|
||||
if (typeof key === 'string' && (
|
||||
key.startsWith('cdc_') ||
|
||||
key.startsWith('$cdc_') ||
|
||||
|
||||
@@ -2,116 +2,299 @@
|
||||
/**
|
||||
* P1: Canvas mock
|
||||
* hsw 检测:HTMLCanvasElement / CanvasRenderingContext2D / fillStyle 默认值 / measureText
|
||||
* Now with: full 7-property measureText bounding box, getImageData with colorSpace,
|
||||
* deterministic pixel buffer, PRNG-seeded drawing
|
||||
*/
|
||||
|
||||
const { createNative, nativeClass } = require('./native');
|
||||
const { createNative, nativeMethod: M, nativeClass } = require('./native');
|
||||
|
||||
// 2D Context
|
||||
// ── Seeded PRNG for deterministic canvas fingerprint ────────────
|
||||
let _seed = 0x9E3779B9;
|
||||
const prng = () => {
|
||||
_seed ^= _seed << 13;
|
||||
_seed ^= _seed >> 17;
|
||||
_seed ^= _seed << 5;
|
||||
return (_seed >>> 0) / 0xFFFFFFFF;
|
||||
};
|
||||
|
||||
// ── Internal pixel buffer for deterministic rendering ───────────
|
||||
class PixelBuffer {
|
||||
constructor(w, h) {
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.data = new Uint8ClampedArray(w * h * 4);
|
||||
}
|
||||
setPixel(x, y, r, g, b, a) {
|
||||
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
|
||||
const i = (y * this.width + x) * 4;
|
||||
this.data[i] = r;
|
||||
this.data[i + 1] = g;
|
||||
this.data[i + 2] = b;
|
||||
this.data[i + 3] = a;
|
||||
}
|
||||
getRegion(sx, sy, sw, sh) {
|
||||
const out = new Uint8ClampedArray(sw * sh * 4);
|
||||
for (let y = 0; y < sh; y++) {
|
||||
for (let x = 0; x < sw; x++) {
|
||||
const srcX = sx + x;
|
||||
const srcY = sy + y;
|
||||
const di = (y * sw + x) * 4;
|
||||
if (srcX >= 0 && srcX < this.width && srcY >= 0 && srcY < this.height) {
|
||||
const si = (srcY * this.width + srcX) * 4;
|
||||
out[di] = this.data[si];
|
||||
out[di + 1] = this.data[si + 1];
|
||||
out[di + 2] = this.data[si + 2];
|
||||
out[di + 3] = this.data[si + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Emoji/character width lookup (approximation of Chrome metrics) ─
|
||||
const CHAR_WIDTHS = {
|
||||
// Basic ASCII at 10px sans-serif
|
||||
default: 5.5,
|
||||
space: 2.5,
|
||||
emoji: 10.0, // Most emoji are double-width
|
||||
};
|
||||
|
||||
const isEmoji = (ch) => {
|
||||
const cp = ch.codePointAt(0);
|
||||
return cp > 0x1F000 || (cp >= 0x2600 && cp <= 0x27BF) ||
|
||||
(cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0x200D && cp <= 0x200D);
|
||||
};
|
||||
|
||||
const measureChar = (ch) => {
|
||||
if (ch === ' ') return CHAR_WIDTHS.space;
|
||||
if (isEmoji(ch)) return CHAR_WIDTHS.emoji;
|
||||
return CHAR_WIDTHS.default;
|
||||
};
|
||||
|
||||
// ── 2D Context ──────────────────────────────────────────────────
|
||||
const CanvasRenderingContext2D = createNative('CanvasRenderingContext2D', function () {});
|
||||
CanvasRenderingContext2D.prototype = {
|
||||
constructor: CanvasRenderingContext2D,
|
||||
fillStyle: '#000000', // P1: 默认值必须是黑色
|
||||
strokeStyle: '#000000',
|
||||
font: '10px sans-serif',
|
||||
textAlign: 'start',
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
font: '10px sans-serif',
|
||||
textAlign: 'start',
|
||||
textBaseline: 'alphabetic',
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
lineWidth: 1,
|
||||
fillRect: createNative('fillRect', function () {}),
|
||||
strokeRect: createNative('strokeRect', function () {}),
|
||||
clearRect: createNative('clearRect', function () {}),
|
||||
fillText: createNative('fillText', function () {}),
|
||||
strokeText: createNative('strokeText', function () {}),
|
||||
beginPath: createNative('beginPath', function () {}),
|
||||
closePath: createNative('closePath', function () {}),
|
||||
moveTo: createNative('moveTo', function () {}),
|
||||
lineTo: createNative('lineTo', function () {}),
|
||||
arc: createNative('arc', function () {}),
|
||||
fill: createNative('fill', function () {}),
|
||||
stroke: createNative('stroke', function () {}),
|
||||
save: createNative('save', function () {}),
|
||||
restore: createNative('restore', function () {}),
|
||||
scale: createNative('scale', function () {}),
|
||||
rotate: createNative('rotate', function () {}),
|
||||
translate: createNative('translate', function () {}),
|
||||
drawImage: createNative('drawImage', function () {}),
|
||||
getImageData: createNative('getImageData', function (x, y, w, h) {
|
||||
return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h };
|
||||
lineCap: 'butt',
|
||||
lineJoin: 'miter',
|
||||
miterLimit: 10,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0)',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'low',
|
||||
filter: 'none',
|
||||
direction: 'ltr',
|
||||
fontKerning: 'auto',
|
||||
letterSpacing: '0px',
|
||||
wordSpacing: '0px',
|
||||
textRendering: 'auto',
|
||||
|
||||
// ── Drawing methods (seed deterministic pixels) ─────────────
|
||||
fillRect: M('fillRect', 4, function (x, y, w, h) {
|
||||
if (!this._pbuf) return;
|
||||
// Fill with deterministic color based on current fillStyle seed
|
||||
const hash = _seed ^ (x * 31 + y * 37 + w * 41 + h * 43);
|
||||
const r = (hash & 0xFF);
|
||||
const g = ((hash >> 8) & 0xFF);
|
||||
const b = ((hash >> 16) & 0xFF);
|
||||
for (let py = Math.max(0, y | 0); py < Math.min(this._pbuf.height, (y + h) | 0); py++) {
|
||||
for (let px = Math.max(0, x | 0); px < Math.min(this._pbuf.width, (x + w) | 0); px++) {
|
||||
this._pbuf.setPixel(px, py, r, g, b, 255);
|
||||
}
|
||||
}
|
||||
}),
|
||||
putImageData: createNative('putImageData', function () {}),
|
||||
createImageData: createNative('createImageData', function (w, h) {
|
||||
return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h };
|
||||
strokeRect: M('strokeRect', 4, function () {}),
|
||||
clearRect: M('clearRect', 4, function (x, y, w, h) {
|
||||
if (!this._pbuf) return;
|
||||
for (let py = Math.max(0, y | 0); py < Math.min(this._pbuf.height, (y + h) | 0); py++) {
|
||||
for (let px = Math.max(0, x | 0); px < Math.min(this._pbuf.width, (x + w) | 0); px++) {
|
||||
this._pbuf.setPixel(px, py, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}),
|
||||
measureText: createNative('measureText', function (text) {
|
||||
// 近似真实 Chrome 的字体测量(Helvetica 10px)
|
||||
|
||||
fillText: M('fillText', 3, function (text, x, y) {
|
||||
if (!this._pbuf) return;
|
||||
// Seed pixels at text position for fingerprint consistency
|
||||
const str = String(text);
|
||||
let cx = x | 0;
|
||||
for (let i = 0; i < str.length && i < 100; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
const r = (code * 7 + 31) & 0xFF;
|
||||
const g = (code * 13 + 97) & 0xFF;
|
||||
const b = (code * 23 + 151) & 0xFF;
|
||||
if (cx >= 0 && cx < this._pbuf.width && (y | 0) >= 0 && (y | 0) < this._pbuf.height) {
|
||||
this._pbuf.setPixel(cx, y | 0, r, g, b, 255);
|
||||
}
|
||||
cx += measureChar(str[i]) | 0;
|
||||
}
|
||||
}),
|
||||
strokeText: M('strokeText', 3, function () {}),
|
||||
|
||||
beginPath: M('beginPath', 0, function () {}),
|
||||
closePath: M('closePath', 0, function () {}),
|
||||
moveTo: M('moveTo', 2, function () {}),
|
||||
lineTo: M('lineTo', 2, function () {}),
|
||||
bezierCurveTo: M('bezierCurveTo', 6, function () {}),
|
||||
quadraticCurveTo: M('quadraticCurveTo', 4, function () {}),
|
||||
arc: M('arc', 5, function (x, y, r, sa, ea) {
|
||||
// Seed pixel at arc center for PRNG fingerprint
|
||||
if (this._pbuf && x >= 0 && x < this._pbuf.width && y >= 0 && y < this._pbuf.height) {
|
||||
const hash = (x * 71 + y * 113 + (r * 1000 | 0)) & 0xFFFFFF;
|
||||
this._pbuf.setPixel(x | 0, y | 0, hash & 0xFF, (hash >> 8) & 0xFF, (hash >> 16) & 0xFF, 255);
|
||||
}
|
||||
}),
|
||||
arcTo: M('arcTo', 5, function () {}),
|
||||
ellipse: M('ellipse', 7, function () {}),
|
||||
rect: M('rect', 4, function () {}),
|
||||
roundRect: M('roundRect', 5, function () {}),
|
||||
fill: M('fill', 0, function () {}),
|
||||
stroke: M('stroke', 0, function () {}),
|
||||
save: M('save', 0, function () {}),
|
||||
restore: M('restore', 0, function () {}),
|
||||
scale: M('scale', 2, function () {}),
|
||||
rotate: M('rotate', 1, function () {}),
|
||||
translate: M('translate', 2, function () {}),
|
||||
transform: M('transform', 6, function () {}),
|
||||
drawImage: M('drawImage', 3, function () {}),
|
||||
|
||||
getImageData: M('getImageData', 4, function (x, y, w, h) {
|
||||
let data;
|
||||
if (this._pbuf) {
|
||||
data = this._pbuf.getRegion(x | 0, y | 0, w | 0, h | 0);
|
||||
} else {
|
||||
data = new Uint8ClampedArray(w * h * 4);
|
||||
}
|
||||
return { data, width: w, height: h, colorSpace: 'srgb' };
|
||||
}),
|
||||
putImageData: M('putImageData', 3, function () {}),
|
||||
createImageData: M('createImageData', 1, function (w, h) {
|
||||
if (typeof w === 'object') { h = w.height; w = w.width; }
|
||||
return { data: new Uint8ClampedArray(w * h * 4), width: w, height: h, colorSpace: 'srgb' };
|
||||
}),
|
||||
|
||||
measureText: M('measureText', 1, function (text) {
|
||||
const str = String(text);
|
||||
let totalWidth = 0;
|
||||
for (const ch of str) {
|
||||
totalWidth += measureChar(ch);
|
||||
}
|
||||
|
||||
// Parse font size from this.font (e.g., "10px sans-serif")
|
||||
const fontMatch = (this.font || '10px sans-serif').match(/(\d+(?:\.\d+)?)\s*px/);
|
||||
const fontSize = fontMatch ? parseFloat(fontMatch[1]) : 10;
|
||||
const scale = fontSize / 10;
|
||||
|
||||
return {
|
||||
width: text.length * 5.5,
|
||||
actualBoundingBoxAscent: 7,
|
||||
actualBoundingBoxDescent: 2,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
width: totalWidth * scale,
|
||||
actualBoundingBoxLeft: 0,
|
||||
actualBoundingBoxRight: totalWidth * scale,
|
||||
actualBoundingBoxAscent: 7 * scale,
|
||||
actualBoundingBoxDescent: 2 * scale,
|
||||
fontBoundingBoxAscent: 8 * scale,
|
||||
fontBoundingBoxDescent: 2 * scale,
|
||||
emHeightAscent: 8 * scale,
|
||||
emHeightDescent: 2 * scale,
|
||||
hangingBaseline: 6.4 * scale,
|
||||
alphabeticBaseline: 0,
|
||||
ideographicBaseline: -2 * scale,
|
||||
};
|
||||
}),
|
||||
setTransform: createNative('setTransform', function () {}),
|
||||
resetTransform: createNative('resetTransform', function () {}),
|
||||
clip: createNative('clip', function () {}),
|
||||
isPointInPath: createNative('isPointInPath', function () { return false; }),
|
||||
createLinearGradient: createNative('createLinearGradient', function () {
|
||||
return { addColorStop: createNative('addColorStop', function () {}) };
|
||||
|
||||
setTransform: M('setTransform', 0, function () {}),
|
||||
resetTransform: M('resetTransform', 0, function () {}),
|
||||
getTransform: M('getTransform', 0, function () {
|
||||
return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||
}),
|
||||
createRadialGradient: createNative('createRadialGradient', function () {
|
||||
return { addColorStop: createNative('addColorStop', function () {}) };
|
||||
clip: M('clip', 0, function () {}),
|
||||
isPointInPath: M('isPointInPath', 2, function () { return false; }),
|
||||
isPointInStroke: M('isPointInStroke', 2, function () { return false; }),
|
||||
createLinearGradient: M('createLinearGradient', 4, function () {
|
||||
return { addColorStop: M('addColorStop', 2, function () {}) };
|
||||
}),
|
||||
createPattern: createNative('createPattern', function () { return null; }),
|
||||
canvas: null, // 会在 createElement 里回填
|
||||
createRadialGradient: M('createRadialGradient', 6, function () {
|
||||
return { addColorStop: M('addColorStop', 2, function () {}) };
|
||||
}),
|
||||
createConicGradient: M('createConicGradient', 3, function () {
|
||||
return { addColorStop: M('addColorStop', 2, function () {}) };
|
||||
}),
|
||||
createPattern: M('createPattern', 2, function () { return null; }),
|
||||
setLineDash: M('setLineDash', 1, function () {}),
|
||||
getLineDash: M('getLineDash', 0, function () { return []; }),
|
||||
canvas: null, // 会在 getContext 里回填
|
||||
};
|
||||
|
||||
// WebGL context (浅实现,过类型检测)
|
||||
// ── WebGL context ───────────────────────────────────────────────
|
||||
function makeWebGLContext() {
|
||||
return {
|
||||
getParameter: createNative('getParameter', function (param) {
|
||||
// RENDERER / VENDOR 参数
|
||||
getParameter: M('getParameter', 1, function (param) {
|
||||
if (param === 0x1F01) return 'Google Inc. (Intel)'; // RENDERER
|
||||
if (param === 0x1F00) return 'WebKit WebGL'; // VENDOR
|
||||
if (param === 0x8B8C) return 'WebGL GLSL ES 3.00'; // SHADING_LANGUAGE_VERSION
|
||||
if (param === 0x1F02) return 'WebGL 2.0 (OpenGL ES 3.0)';// VERSION
|
||||
if (param === 0x0D33) return 16384; // MAX_TEXTURE_SIZE
|
||||
if (param === 0x0D3A) return 16; // MAX_VIEWPORT_DIMS
|
||||
return null;
|
||||
}),
|
||||
getExtension: createNative('getExtension', function () { return null; }),
|
||||
getSupportedExtensions: createNative('getSupportedExtensions', function () { return []; }),
|
||||
createBuffer: createNative('createBuffer', function () { return {}; }),
|
||||
bindBuffer: createNative('bindBuffer', function () {}),
|
||||
bufferData: createNative('bufferData', function () {}),
|
||||
createShader: createNative('createShader', function () { return {}; }),
|
||||
shaderSource: createNative('shaderSource', function () {}),
|
||||
compileShader: createNative('compileShader', function () {}),
|
||||
createProgram: createNative('createProgram', function () { return {}; }),
|
||||
attachShader: createNative('attachShader', function () {}),
|
||||
linkProgram: createNative('linkProgram', function () {}),
|
||||
useProgram: createNative('useProgram', function () {}),
|
||||
getUniformLocation: createNative('getUniformLocation', function () { return {}; }),
|
||||
uniform1f: createNative('uniform1f', function () {}),
|
||||
drawArrays: createNative('drawArrays', function () {}),
|
||||
readPixels: createNative('readPixels', function () {}),
|
||||
enable: createNative('enable', function () {}),
|
||||
clear: createNative('clear', function () {}),
|
||||
clearColor: createNative('clearColor', function () {}),
|
||||
viewport: createNative('viewport', function () {}),
|
||||
getExtension: M('getExtension', 1, function (name) {
|
||||
if (name === 'WEBGL_debug_renderer_info') {
|
||||
return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
getSupportedExtensions: M('getSupportedExtensions', 0, function () {
|
||||
return ['WEBGL_debug_renderer_info', 'OES_texture_float', 'OES_element_index_uint'];
|
||||
}),
|
||||
createBuffer: M('createBuffer', 0, () => ({})),
|
||||
bindBuffer: M('bindBuffer', 2, () => {}),
|
||||
bufferData: M('bufferData', 3, () => {}),
|
||||
createShader: M('createShader', 1, () => ({})),
|
||||
shaderSource: M('shaderSource', 2, () => {}),
|
||||
compileShader: M('compileShader', 1, () => {}),
|
||||
createProgram: M('createProgram', 0, () => ({})),
|
||||
attachShader: M('attachShader', 2, () => {}),
|
||||
linkProgram: M('linkProgram', 1, () => {}),
|
||||
useProgram: M('useProgram', 1, () => {}),
|
||||
getUniformLocation: M('getUniformLocation', 2, () => ({})),
|
||||
uniform1f: M('uniform1f', 2, () => {}),
|
||||
drawArrays: M('drawArrays', 3, () => {}),
|
||||
readPixels: M('readPixels', 7, () => {}),
|
||||
enable: M('enable', 1, () => {}),
|
||||
clear: M('clear', 1, () => {}),
|
||||
clearColor: M('clearColor', 4, () => {}),
|
||||
viewport: M('viewport', 4, () => {}),
|
||||
getShaderPrecisionFormat: M('getShaderPrecisionFormat', 2, () => ({
|
||||
rangeMin: 127, rangeMax: 127, precision: 23,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// HTMLCanvasElement
|
||||
// ── HTMLCanvasElement ────────────────────────────────────────────
|
||||
class HTMLCanvasElement {
|
||||
constructor() {
|
||||
this.width = 300;
|
||||
this.height = 150;
|
||||
this._ctx2d = null;
|
||||
this._pbuf = null;
|
||||
}
|
||||
getContext(type) {
|
||||
if (type === '2d') {
|
||||
if (!this._ctx2d) {
|
||||
this._pbuf = new PixelBuffer(this.width, this.height);
|
||||
this._ctx2d = Object.create(CanvasRenderingContext2D.prototype);
|
||||
this._ctx2d.canvas = this;
|
||||
this._ctx2d._pbuf = this._pbuf;
|
||||
}
|
||||
return this._ctx2d;
|
||||
}
|
||||
@@ -121,11 +304,30 @@ class HTMLCanvasElement {
|
||||
return null;
|
||||
}
|
||||
toDataURL(type) {
|
||||
// 返回一个最小的合法 1x1 透明 PNG base64
|
||||
// Deterministic fingerprint based on pixel buffer content
|
||||
if (this._pbuf) {
|
||||
// Hash pixel buffer for a stable but content-dependent result
|
||||
let hash = 0x811C9DC5;
|
||||
const d = this._pbuf.data;
|
||||
// Sample every 64th byte for speed
|
||||
for (let i = 0; i < d.length; i += 64) {
|
||||
hash ^= d[i];
|
||||
hash = (hash * 0x01000193) >>> 0;
|
||||
}
|
||||
// Return a stable fake PNG that varies with content
|
||||
const hex = hash.toString(16).padStart(8, '0');
|
||||
return `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8${hex}DwAD/wH+${hex}AAAABJRU5ErkJggg==`;
|
||||
}
|
||||
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
}
|
||||
toBlob(cb) { cb(null); }
|
||||
captureStream() { return {}; }
|
||||
toBlob(cb, type, quality) {
|
||||
// Provide a minimal Blob rather than null
|
||||
const dataUrl = this.toDataURL(type);
|
||||
const data = dataUrl.split(',')[1] || '';
|
||||
cb({ size: data.length, type: type || 'image/png' });
|
||||
}
|
||||
captureStream(fps) { return { getTracks: M('getTracks', 0, () => []) }; }
|
||||
transferControlToOffscreen() { return {}; }
|
||||
}
|
||||
nativeClass(HTMLCanvasElement);
|
||||
|
||||
|
||||
310
src/sandbox/mocks/class_registry.js
Normal file
310
src/sandbox/mocks/class_registry.js
Normal file
@@ -0,0 +1,310 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Browser class prototype chain registry
|
||||
* Provides proper constructors + Symbol.toStringTag + prototype chains
|
||||
* so that Object.prototype.toString.call(obj) returns correct [object Xxx]
|
||||
*/
|
||||
|
||||
const { nativeClass, nativeMethod: M } = require('./native');
|
||||
|
||||
// ── Helper: define toStringTag on prototype ─────────────────────
|
||||
const tag = (cls, label) => {
|
||||
Object.defineProperty(cls.prototype, Symbol.toStringTag, {
|
||||
value: label, configurable: true, writable: false, enumerable: false,
|
||||
});
|
||||
};
|
||||
|
||||
// ── EventTarget (base for Window, Performance, Document etc) ────
|
||||
class EventTarget {
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
dispatchEvent() { return true; }
|
||||
}
|
||||
tag(EventTarget, 'EventTarget');
|
||||
nativeClass(EventTarget);
|
||||
|
||||
// ── Window ──────────────────────────────────────────────────────
|
||||
class Window extends EventTarget {}
|
||||
tag(Window, 'Window');
|
||||
nativeClass(Window);
|
||||
|
||||
// ── Navigator ───────────────────────────────────────────────────
|
||||
class Navigator {}
|
||||
tag(Navigator, 'Navigator');
|
||||
nativeClass(Navigator);
|
||||
|
||||
// ── NavigatorUAData ─────────────────────────────────────────────
|
||||
class NavigatorUAData {}
|
||||
tag(NavigatorUAData, 'NavigatorUAData');
|
||||
nativeClass(NavigatorUAData);
|
||||
|
||||
// ── Performance ─────────────────────────────────────────────────
|
||||
class Performance extends EventTarget {}
|
||||
tag(Performance, 'Performance');
|
||||
nativeClass(Performance);
|
||||
|
||||
// ── PerformanceEntry ────────────────────────────────────────────
|
||||
class PerformanceEntry {}
|
||||
PerformanceEntry.prototype.toJSON = M('toJSON', 0, function () {
|
||||
const o = {};
|
||||
for (const k of Object.keys(this)) o[k] = this[k];
|
||||
return o;
|
||||
});
|
||||
tag(PerformanceEntry, 'PerformanceEntry');
|
||||
nativeClass(PerformanceEntry);
|
||||
|
||||
// ── PerformanceResourceTiming ───────────────────────────────────
|
||||
class PerformanceResourceTiming extends PerformanceEntry {}
|
||||
tag(PerformanceResourceTiming, 'PerformanceResourceTiming');
|
||||
nativeClass(PerformanceResourceTiming);
|
||||
|
||||
// ── PerformanceNavigationTiming ─────────────────────────────────
|
||||
class PerformanceNavigationTiming extends PerformanceResourceTiming {}
|
||||
tag(PerformanceNavigationTiming, 'PerformanceNavigationTiming');
|
||||
nativeClass(PerformanceNavigationTiming);
|
||||
|
||||
// ── Crypto ──────────────────────────────────────────────────────
|
||||
class Crypto {}
|
||||
tag(Crypto, 'Crypto');
|
||||
nativeClass(Crypto);
|
||||
|
||||
// ── SubtleCrypto ────────────────────────────────────────────────
|
||||
class SubtleCrypto {}
|
||||
tag(SubtleCrypto, 'SubtleCrypto');
|
||||
nativeClass(SubtleCrypto);
|
||||
|
||||
// ── Screen ──────────────────────────────────────────────────────
|
||||
class Screen {}
|
||||
tag(Screen, 'Screen');
|
||||
nativeClass(Screen);
|
||||
|
||||
// ── ScreenOrientation ───────────────────────────────────────────
|
||||
class ScreenOrientation extends EventTarget {}
|
||||
tag(ScreenOrientation, 'ScreenOrientation');
|
||||
nativeClass(ScreenOrientation);
|
||||
|
||||
// ── Node ────────────────────────────────────────────────────────
|
||||
class Node extends EventTarget {}
|
||||
tag(Node, 'Node');
|
||||
nativeClass(Node);
|
||||
|
||||
// ── Element ─────────────────────────────────────────────────────
|
||||
class Element extends Node {}
|
||||
tag(Element, 'Element');
|
||||
nativeClass(Element);
|
||||
|
||||
// ── HTMLElement ──────────────────────────────────────────────────
|
||||
class HTMLElement extends Element {}
|
||||
tag(HTMLElement, 'HTMLElement');
|
||||
nativeClass(HTMLElement);
|
||||
|
||||
// ── Document ────────────────────────────────────────────────────
|
||||
class Document extends Node {}
|
||||
tag(Document, 'Document');
|
||||
nativeClass(Document);
|
||||
|
||||
// ── HTMLDocument ────────────────────────────────────────────────
|
||||
class HTMLDocument extends Document {}
|
||||
tag(HTMLDocument, 'HTMLDocument');
|
||||
nativeClass(HTMLDocument);
|
||||
|
||||
// ── HTMLIFrameElement ───────────────────────────────────────────
|
||||
class HTMLIFrameElement extends HTMLElement {}
|
||||
tag(HTMLIFrameElement, 'HTMLIFrameElement');
|
||||
nativeClass(HTMLIFrameElement);
|
||||
|
||||
// ── SVGTextContentElement ───────────────────────────────────────
|
||||
class SVGElement extends Element {}
|
||||
tag(SVGElement, 'SVGElement');
|
||||
nativeClass(SVGElement);
|
||||
|
||||
class SVGTextContentElement extends SVGElement {}
|
||||
tag(SVGTextContentElement, 'SVGTextContentElement');
|
||||
nativeClass(SVGTextContentElement);
|
||||
|
||||
// ── Storage ─────────────────────────────────────────────────────
|
||||
class StorageProto {}
|
||||
tag(StorageProto, 'Storage');
|
||||
nativeClass(StorageProto);
|
||||
|
||||
// ── Permissions ─────────────────────────────────────────────────
|
||||
class Permissions {}
|
||||
tag(Permissions, 'Permissions');
|
||||
nativeClass(Permissions);
|
||||
|
||||
// ── PermissionStatus ────────────────────────────────────────────
|
||||
class PermissionStatus extends EventTarget {}
|
||||
tag(PermissionStatus, 'PermissionStatus');
|
||||
nativeClass(PermissionStatus);
|
||||
|
||||
// ── PluginArray ─────────────────────────────────────────────────
|
||||
class PluginArray {}
|
||||
tag(PluginArray, 'PluginArray');
|
||||
nativeClass(PluginArray);
|
||||
|
||||
// ── MimeTypeArray ───────────────────────────────────────────────
|
||||
class MimeTypeArray {}
|
||||
tag(MimeTypeArray, 'MimeTypeArray');
|
||||
nativeClass(MimeTypeArray);
|
||||
|
||||
// ── NetworkInformation ──────────────────────────────────────────
|
||||
class NetworkInformation extends EventTarget {}
|
||||
tag(NetworkInformation, 'NetworkInformation');
|
||||
nativeClass(NetworkInformation);
|
||||
|
||||
// ── FontFace ────────────────────────────────────────────────────
|
||||
class FontFace {
|
||||
constructor(family, source) {
|
||||
this.family = family || '';
|
||||
this.status = 'unloaded';
|
||||
}
|
||||
}
|
||||
FontFace.prototype.load = M('load', 0, function () { this.status = 'loaded'; return Promise.resolve(this); });
|
||||
tag(FontFace, 'FontFace');
|
||||
nativeClass(FontFace);
|
||||
|
||||
// ── CSS ─────────────────────────────────────────────────────────
|
||||
const CSS = {
|
||||
supports: M('supports', 1, (prop, val) => {
|
||||
if (val === undefined) return false;
|
||||
return true;
|
||||
}),
|
||||
escape: M('escape', 1, (str) => str.replace(/([^\w-])/g, '\\$1')),
|
||||
};
|
||||
|
||||
// ── WebGLRenderingContext ───────────────────────────────────────
|
||||
class WebGLRenderingContext {}
|
||||
tag(WebGLRenderingContext, 'WebGLRenderingContext');
|
||||
nativeClass(WebGLRenderingContext);
|
||||
|
||||
class WebGL2RenderingContext {}
|
||||
tag(WebGL2RenderingContext, 'WebGL2RenderingContext');
|
||||
nativeClass(WebGL2RenderingContext);
|
||||
|
||||
// ── Audio API ───────────────────────────────────────────────────
|
||||
class AudioContext extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.sampleRate = 44100;
|
||||
this.state = 'suspended';
|
||||
this.currentTime = 0;
|
||||
this.destination = { channelCount: 2 };
|
||||
}
|
||||
}
|
||||
AudioContext.prototype.createAnalyser = M('createAnalyser', 0, function () { return {}; });
|
||||
AudioContext.prototype.createOscillator = M('createOscillator', 0, function () { return {}; });
|
||||
AudioContext.prototype.close = M('close', 0, function () { return Promise.resolve(); });
|
||||
tag(AudioContext, 'AudioContext');
|
||||
nativeClass(AudioContext);
|
||||
|
||||
class AnalyserNode {}
|
||||
tag(AnalyserNode, 'AnalyserNode');
|
||||
nativeClass(AnalyserNode);
|
||||
|
||||
class AudioBuffer {
|
||||
constructor(opts) {
|
||||
this.length = opts?.length || 0;
|
||||
this.sampleRate = opts?.sampleRate || 44100;
|
||||
this.numberOfChannels = opts?.numberOfChannels || 1;
|
||||
}
|
||||
}
|
||||
AudioBuffer.prototype.getChannelData = M('getChannelData', 1, function () { return new Float32Array(this.length); });
|
||||
tag(AudioBuffer, 'AudioBuffer');
|
||||
nativeClass(AudioBuffer);
|
||||
|
||||
// standalone Audio constructor (HTMLAudioElement)
|
||||
class Audio extends HTMLElement {
|
||||
constructor(src) {
|
||||
super();
|
||||
this.src = src || '';
|
||||
this.currentTime = 0;
|
||||
this.duration = 0;
|
||||
this.paused = true;
|
||||
}
|
||||
}
|
||||
Audio.prototype.play = M('play', 0, function () { return Promise.resolve(); });
|
||||
Audio.prototype.pause = M('pause', 0, function () {});
|
||||
Audio.prototype.load = M('load', 0, function () {});
|
||||
tag(Audio, 'HTMLAudioElement');
|
||||
nativeClass(Audio);
|
||||
|
||||
// ── RTCRtpSender / RTCRtpReceiver ───────────────────────────────
|
||||
class RTCRtpSender {
|
||||
constructor() { this.track = null; }
|
||||
}
|
||||
RTCRtpSender.getCapabilities = M('getCapabilities', 1, () => ({ codecs: [], headerExtensions: [] }));
|
||||
tag(RTCRtpSender, 'RTCRtpSender');
|
||||
nativeClass(RTCRtpSender);
|
||||
|
||||
class RTCRtpReceiver {
|
||||
constructor() { this.track = null; }
|
||||
}
|
||||
RTCRtpReceiver.getCapabilities = M('getCapabilities', 1, () => ({ codecs: [], headerExtensions: [] }));
|
||||
tag(RTCRtpReceiver, 'RTCRtpReceiver');
|
||||
nativeClass(RTCRtpReceiver);
|
||||
|
||||
class RTCSessionDescription {
|
||||
constructor(init) {
|
||||
this.type = init?.type || '';
|
||||
this.sdp = init?.sdp || '';
|
||||
}
|
||||
}
|
||||
tag(RTCSessionDescription, 'RTCSessionDescription');
|
||||
nativeClass(RTCSessionDescription);
|
||||
|
||||
// ── VisualViewport ──────────────────────────────────────────────
|
||||
class VisualViewport extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.width = 1920;
|
||||
this.height = 1080;
|
||||
this.offsetLeft = 0;
|
||||
this.offsetTop = 0;
|
||||
this.pageLeft = 0;
|
||||
this.pageTop = 0;
|
||||
this.scale = 1;
|
||||
}
|
||||
}
|
||||
tag(VisualViewport, 'VisualViewport');
|
||||
nativeClass(VisualViewport);
|
||||
|
||||
module.exports = {
|
||||
EventTarget,
|
||||
Window,
|
||||
Navigator,
|
||||
NavigatorUAData,
|
||||
Performance,
|
||||
PerformanceEntry,
|
||||
PerformanceResourceTiming,
|
||||
PerformanceNavigationTiming,
|
||||
Crypto,
|
||||
SubtleCrypto,
|
||||
Screen,
|
||||
ScreenOrientation,
|
||||
Node,
|
||||
Element,
|
||||
HTMLElement,
|
||||
Document,
|
||||
HTMLDocument,
|
||||
HTMLIFrameElement,
|
||||
SVGElement,
|
||||
SVGTextContentElement,
|
||||
StorageProto,
|
||||
Permissions,
|
||||
PermissionStatus,
|
||||
PluginArray,
|
||||
MimeTypeArray,
|
||||
NetworkInformation,
|
||||
FontFace,
|
||||
CSS,
|
||||
WebGLRenderingContext,
|
||||
WebGL2RenderingContext,
|
||||
AudioContext,
|
||||
AnalyserNode,
|
||||
AudioBuffer,
|
||||
Audio,
|
||||
RTCRtpSender,
|
||||
RTCRtpReceiver,
|
||||
RTCSessionDescription,
|
||||
VisualViewport,
|
||||
};
|
||||
@@ -1,32 +1,39 @@
|
||||
'use strict';
|
||||
/**
|
||||
* P1: Crypto / Storage / IDBFactory / atob / btoa mock
|
||||
* Now with proper Crypto + SubtleCrypto prototype chains
|
||||
*/
|
||||
|
||||
const { createNative, nativeClass } = require('./native');
|
||||
const { nativeMethod: M, nativeClass } = require('./native');
|
||||
const { Crypto, SubtleCrypto, StorageProto } = require('./class_registry');
|
||||
const nodeCrypto = require('crypto');
|
||||
|
||||
// ── Crypto ───────────────────────────────────────────────────
|
||||
const cryptoMock = {
|
||||
getRandomValues: createNative('getRandomValues', function (array) {
|
||||
return nodeCrypto.randomFillSync(array);
|
||||
}),
|
||||
randomUUID: createNative('randomUUID', function () {
|
||||
return nodeCrypto.randomUUID();
|
||||
}),
|
||||
subtle: {
|
||||
digest: createNative('digest', function () { return Promise.resolve(new ArrayBuffer(32)); }),
|
||||
encrypt: createNative('encrypt', function () { return Promise.resolve(new ArrayBuffer(0)); }),
|
||||
decrypt: createNative('decrypt', function () { return Promise.resolve(new ArrayBuffer(0)); }),
|
||||
sign: createNative('sign', function () { return Promise.resolve(new ArrayBuffer(32)); }),
|
||||
verify: createNative('verify', function () { return Promise.resolve(true); }),
|
||||
generateKey: createNative('generateKey', function () { return Promise.resolve({}); }),
|
||||
importKey: createNative('importKey', function () { return Promise.resolve({}); }),
|
||||
exportKey: createNative('exportKey', function () { return Promise.resolve({}); }),
|
||||
},
|
||||
};
|
||||
// ── SubtleCrypto instance with proper prototype ─────────────────
|
||||
const subtle = Object.create(SubtleCrypto.prototype);
|
||||
Object.assign(subtle, {
|
||||
digest: M('digest', 2, () => Promise.resolve(new ArrayBuffer(32))),
|
||||
encrypt: M('encrypt', 3, () => Promise.resolve(new ArrayBuffer(0))),
|
||||
decrypt: M('decrypt', 3, () => Promise.resolve(new ArrayBuffer(0))),
|
||||
sign: M('sign', 3, () => Promise.resolve(new ArrayBuffer(32))),
|
||||
verify: M('verify', 4, () => Promise.resolve(true)),
|
||||
generateKey: M('generateKey', 3, () => Promise.resolve({})),
|
||||
importKey: M('importKey', 5, () => Promise.resolve({})),
|
||||
exportKey: M('exportKey', 2, () => Promise.resolve({})),
|
||||
deriveBits: M('deriveBits', 3, () => Promise.resolve(new ArrayBuffer(32))),
|
||||
deriveKey: M('deriveKey', 5, () => Promise.resolve({})),
|
||||
wrapKey: M('wrapKey', 4, () => Promise.resolve(new ArrayBuffer(0))),
|
||||
unwrapKey: M('unwrapKey', 7, () => Promise.resolve({})),
|
||||
});
|
||||
|
||||
// ── Storage (localStorage / sessionStorage) ──────────────────
|
||||
// ── Crypto instance with proper prototype ───────────────────────
|
||||
const cryptoMock = Object.create(Crypto.prototype);
|
||||
Object.assign(cryptoMock, {
|
||||
getRandomValues: M('getRandomValues', 1, (array) => nodeCrypto.randomFillSync(array)),
|
||||
randomUUID: M('randomUUID', 0, () => nodeCrypto.randomUUID()),
|
||||
subtle,
|
||||
});
|
||||
|
||||
// ── Storage (localStorage / sessionStorage) ─────────────────────
|
||||
class Storage {
|
||||
constructor() { this._store = {}; }
|
||||
get length() { return Object.keys(this._store).length; }
|
||||
@@ -36,31 +43,33 @@ class Storage {
|
||||
removeItem(k) { delete this._store[k]; }
|
||||
clear() { this._store = {}; }
|
||||
}
|
||||
// Set proper prototype chain: Storage instance -> StorageProto.prototype -> Object
|
||||
Object.setPrototypeOf(Storage.prototype, StorageProto.prototype);
|
||||
nativeClass(Storage);
|
||||
|
||||
// ── IDBFactory (indexedDB) ────────────────────────────────────
|
||||
// ── IDBFactory (indexedDB) ──────────────────────────────────────
|
||||
class IDBFactory {
|
||||
open() { return { result: null, onerror: null, onsuccess: null }; }
|
||||
deleteDatabase() { return {}; }
|
||||
open(name) { return { result: null, onerror: null, onsuccess: null }; }
|
||||
deleteDatabase(name) { return {}; }
|
||||
databases() { return Promise.resolve([]); }
|
||||
cmp() { return 0; }
|
||||
cmp(first, second) { return 0; }
|
||||
}
|
||||
nativeClass(IDBFactory);
|
||||
|
||||
// ── Notification ──────────────────────────────────────────────
|
||||
// ── Notification ────────────────────────────────────────────────
|
||||
class Notification {
|
||||
constructor(title, opts) {
|
||||
this.title = title;
|
||||
this.options = opts || {};
|
||||
}
|
||||
close() {}
|
||||
static get permission() { return 'denied'; } // P2: denied 或 default
|
||||
static get permission() { return 'denied'; }
|
||||
static requestPermission() { return Promise.resolve('denied'); }
|
||||
}
|
||||
nativeClass(Notification);
|
||||
|
||||
// ── atob / btoa ───────────────────────────────────────────────
|
||||
const atob = createNative('atob', (str) => Buffer.from(str, 'base64').toString('binary'));
|
||||
const btoa = createNative('btoa', (str) => Buffer.from(str, 'binary').toString('base64'));
|
||||
// ── atob / btoa ─────────────────────────────────────────────────
|
||||
const atob = M('atob', 1, (str) => Buffer.from(str, 'base64').toString('binary'));
|
||||
const btoa = M('btoa', 1, (str) => Buffer.from(str, 'binary').toString('base64'));
|
||||
|
||||
module.exports = { cryptoMock, Storage, IDBFactory, Notification, atob, btoa };
|
||||
module.exports = { cryptoMock, Storage, IDBFactory, Notification, atob, btoa, Crypto, SubtleCrypto };
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
/**
|
||||
* P1: Document / HTMLDocument mock
|
||||
* hsw 检测:document 类型、createElement、cookie 等
|
||||
* Now with proper prototype chain: HTMLDocument -> Document -> Node -> EventTarget -> Object
|
||||
*/
|
||||
|
||||
const { createNative, nativeClass } = require('./native');
|
||||
const { nativeMethod: M, nativeClass } = require('./native');
|
||||
const { HTMLCanvasElement } = require('./canvas');
|
||||
const CR = require('./class_registry');
|
||||
|
||||
class HTMLDocument {
|
||||
constructor() {
|
||||
@@ -20,34 +22,80 @@ class HTMLDocument {
|
||||
this.contentType = 'text/html';
|
||||
this.URL = '';
|
||||
this.domain = '';
|
||||
this.body = { childNodes: [], appendChild: createNative('appendChild', function() {}) };
|
||||
this.head = { childNodes: [], appendChild: createNative('appendChild', function() {}) };
|
||||
this.documentElement = { clientWidth: 1920, clientHeight: 1080 };
|
||||
this.body = { childNodes: [], appendChild: M('appendChild', 1, () => {}), removeChild: M('removeChild', 1, () => {}) };
|
||||
this.head = { childNodes: [], appendChild: M('appendChild', 1, () => {}), removeChild: M('removeChild', 1, () => {}) };
|
||||
this.documentElement = { clientWidth: 1920, clientHeight: 1080, style: {} };
|
||||
this.activeElement = null;
|
||||
this.fonts = { ready: Promise.resolve(), check: M('check', 1, () => true), forEach: M('forEach', 1, () => {}) };
|
||||
}
|
||||
}
|
||||
|
||||
HTMLDocument.prototype.createElement = createNative('createElement', function (tag) {
|
||||
// Set prototype chain: HTMLDocument -> CR.HTMLDocument.prototype -> CR.Document.prototype -> CR.Node.prototype -> ...
|
||||
Object.setPrototypeOf(HTMLDocument.prototype, CR.HTMLDocument.prototype);
|
||||
|
||||
HTMLDocument.prototype.createElement = M('createElement', 1, function (tag) {
|
||||
const t = tag.toLowerCase();
|
||||
if (t === 'canvas') return new HTMLCanvasElement();
|
||||
if (t === 'div' || t === 'span' || t === 'p') {
|
||||
return {
|
||||
style: {},
|
||||
appendChild: createNative('appendChild', function() {}),
|
||||
getAttribute: createNative('getAttribute', function() { return null; }),
|
||||
setAttribute: createNative('setAttribute', function() {}),
|
||||
};
|
||||
if (t === 'iframe') {
|
||||
const el = Object.create(CR.HTMLIFrameElement.prototype);
|
||||
Object.assign(el, { style: {}, contentWindow: null, contentDocument: null, src: '', sandbox: '' });
|
||||
el.appendChild = M('appendChild', 1, () => {});
|
||||
el.getAttribute = M('getAttribute', 1, () => null);
|
||||
el.setAttribute = M('setAttribute', 2, () => {});
|
||||
return el;
|
||||
}
|
||||
return { style: {} };
|
||||
// Generic element
|
||||
const el = {
|
||||
style: {},
|
||||
tagName: tag.toUpperCase(),
|
||||
appendChild: M('appendChild', 1, () => {}),
|
||||
removeChild: M('removeChild', 1, () => {}),
|
||||
getAttribute: M('getAttribute', 1, () => null),
|
||||
setAttribute: M('setAttribute', 2, () => {}),
|
||||
addEventListener: M('addEventListener', 2, () => {}),
|
||||
removeEventListener: M('removeEventListener', 2, () => {}),
|
||||
getBoundingClientRect: M('getBoundingClientRect', 0, () => ({
|
||||
top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0,
|
||||
})),
|
||||
childNodes: [],
|
||||
children: [],
|
||||
parentNode: null,
|
||||
parentElement: null,
|
||||
innerHTML: '',
|
||||
outerHTML: '',
|
||||
textContent: '',
|
||||
};
|
||||
return el;
|
||||
});
|
||||
|
||||
HTMLDocument.prototype.getElementById = createNative('getElementById', function () { return null; });
|
||||
HTMLDocument.prototype.querySelector = createNative('querySelector', function () { return null; });
|
||||
HTMLDocument.prototype.querySelectorAll = createNative('querySelectorAll', function () { return []; });
|
||||
HTMLDocument.prototype.getElementsByTagName = createNative('getElementsByTagName', function () { return []; });
|
||||
HTMLDocument.prototype.createTextNode = createNative('createTextNode', function (t) { return { data: t }; });
|
||||
HTMLDocument.prototype.addEventListener = createNative('addEventListener', function () {});
|
||||
HTMLDocument.prototype.removeEventListener = createNative('removeEventListener', function () {});
|
||||
HTMLDocument.prototype.dispatchEvent = createNative('dispatchEvent', function () { return true; });
|
||||
HTMLDocument.prototype.createElementNS = M('createElementNS', 2, function (ns, tag) {
|
||||
return this.createElement(tag);
|
||||
});
|
||||
|
||||
HTMLDocument.prototype.createEvent = M('createEvent', 1, function (type) {
|
||||
return {
|
||||
type: '',
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
initEvent: M('initEvent', 3, function (t, b, c) { this.type = t; this.bubbles = b; this.cancelable = c; }),
|
||||
preventDefault: M('preventDefault', 0, () => {}),
|
||||
stopPropagation: M('stopPropagation', 0, () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
HTMLDocument.prototype.getElementById = M('getElementById', 1, () => null);
|
||||
HTMLDocument.prototype.querySelector = M('querySelector', 1, () => null);
|
||||
HTMLDocument.prototype.querySelectorAll = M('querySelectorAll', 1, () => []);
|
||||
HTMLDocument.prototype.getElementsByTagName = M('getElementsByTagName', 1, () => []);
|
||||
HTMLDocument.prototype.getElementsByClassName = M('getElementsByClassName', 1, () => []);
|
||||
HTMLDocument.prototype.createTextNode = M('createTextNode', 1, (t) => ({ data: t, nodeType: 3 }));
|
||||
HTMLDocument.prototype.createDocumentFragment = M('createDocumentFragment', 0, () => ({ childNodes: [], appendChild: M('appendChild', 1, () => {}) }));
|
||||
HTMLDocument.prototype.hasFocus = M('hasFocus', 0, () => true);
|
||||
HTMLDocument.prototype.addEventListener = M('addEventListener', 2, () => {});
|
||||
HTMLDocument.prototype.removeEventListener = M('removeEventListener', 2, () => {});
|
||||
HTMLDocument.prototype.dispatchEvent = M('dispatchEvent', 1, () => true);
|
||||
HTMLDocument.prototype.write = M('write', 1, () => {});
|
||||
HTMLDocument.prototype.writeln = M('writeln', 1, () => {});
|
||||
nativeClass(HTMLDocument);
|
||||
|
||||
module.exports = HTMLDocument;
|
||||
|
||||
83
src/sandbox/mocks/error.js
Normal file
83
src/sandbox/mocks/error.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Phase 2: Error stack format rewriting
|
||||
* Node.js stack frames contain file:// paths and node: prefixes.
|
||||
* Chrome stack frames use <anonymous>:line:col format.
|
||||
* hsw.js Dt() collector triggers try { null.x } to inspect stack format.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Install Chrome-style stack trace formatting into a vm context.
|
||||
* Must be called AFTER vm.createContext but BEFORE running hsw.js.
|
||||
* @param {object} ctx - The vm context sandbox object
|
||||
*/
|
||||
function installErrorStackRewrite(ctx) {
|
||||
// V8 has Error.prepareStackTrace — works in both Node and Chrome V8
|
||||
// We override it to produce Chrome-formatted output
|
||||
|
||||
const rewrite = (err, callSites) => {
|
||||
const lines = [];
|
||||
for (const site of callSites) {
|
||||
const fn = site.getFunctionName();
|
||||
const file = site.getFileName() || '<anonymous>';
|
||||
const line = site.getLineNumber();
|
||||
const col = site.getColumnNumber();
|
||||
|
||||
// Filter out Node.js internals
|
||||
if (file.startsWith('node:')) continue;
|
||||
if (file.startsWith('internal/')) continue;
|
||||
if (file.includes('/node_modules/')) continue;
|
||||
|
||||
// Convert file system paths to anonymous
|
||||
let location;
|
||||
if (file.startsWith('/') || file.startsWith('file://') || file.includes(':\\')) {
|
||||
// Absolute path → anonymous (Chrome wouldn't show FS paths)
|
||||
location = `<anonymous>:${line}:${col}`;
|
||||
} else if (file === 'evalmachine.<anonymous>' || file === '<anonymous>') {
|
||||
location = `<anonymous>:${line}:${col}`;
|
||||
} else {
|
||||
location = `${file}:${line}:${col}`;
|
||||
}
|
||||
|
||||
if (fn) {
|
||||
lines.push(` at ${fn} (${location})`);
|
||||
} else {
|
||||
lines.push(` at ${location}`);
|
||||
}
|
||||
}
|
||||
return `${err.name}: ${err.message}\n${lines.join('\n')}`;
|
||||
};
|
||||
|
||||
// Apply to all error types in the context
|
||||
const errorTypes = ['Error', 'TypeError', 'RangeError', 'SyntaxError',
|
||||
'ReferenceError', 'URIError', 'EvalError'];
|
||||
|
||||
for (const name of errorTypes) {
|
||||
const ErrorCtor = ctx[name];
|
||||
if (ErrorCtor) {
|
||||
ErrorCtor.prepareStackTrace = rewrite;
|
||||
}
|
||||
}
|
||||
|
||||
// Also set on the base Error
|
||||
if (ctx.Error) {
|
||||
ctx.Error.prepareStackTrace = rewrite;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix error message format differences between Node and Chrome.
|
||||
* Call this on the sandbox's Error constructors.
|
||||
* @param {object} ctx - The vm context sandbox object
|
||||
*/
|
||||
function patchErrorMessages(ctx) {
|
||||
// Node: "Cannot read properties of null (reading 'x')"
|
||||
// Chrome: "Cannot read properties of null (reading 'x')"
|
||||
// These are actually the same in modern V8, but we ensure consistency
|
||||
|
||||
// Node: "Unexpected token u in JSON at position 0" (older)
|
||||
// Chrome: "Unexpected token 'u', ... is not valid JSON" (newer V8)
|
||||
// Modern Node 18+ matches Chrome format, so this is mostly a safeguard
|
||||
}
|
||||
|
||||
module.exports = { installErrorStackRewrite, patchErrorMessages };
|
||||
@@ -3,9 +3,12 @@
|
||||
* Mock 总装工厂
|
||||
* 导出 createBrowserEnvironment(),返回 { window, document, navigator, ... }
|
||||
* 供 HswRunner 注入全局作用域
|
||||
* Now integrates: class_registry, math, error stack rewriting, constructor chain patching
|
||||
*/
|
||||
|
||||
const windowProxy = require('./window');
|
||||
const { patchConstructorChain } = require('./native');
|
||||
const { installErrorStackRewrite } = require('./error');
|
||||
|
||||
function createBrowserEnvironment(fingerprint = {}) {
|
||||
const win = windowProxy;
|
||||
@@ -29,7 +32,6 @@ function createBrowserEnvironment(fingerprint = {}) {
|
||||
win.screen.availHeight = fingerprint.screenHeight - 40;
|
||||
}
|
||||
if (fingerprint.host) {
|
||||
// 更新 location 中与 host 相关的字段
|
||||
const loc = win.location;
|
||||
if (loc.ancestorOrigins) {
|
||||
loc.ancestorOrigins[0] = `https://${fingerprint.host}`;
|
||||
@@ -49,4 +51,17 @@ function createBrowserEnvironment(fingerprint = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createBrowserEnvironment };
|
||||
/**
|
||||
* Apply all safety patches to a vm context after createContext().
|
||||
* Call this BEFORE running hsw.js in the sandbox.
|
||||
* @param {object} ctx - The vm context sandbox object
|
||||
*/
|
||||
function applySandboxPatches(ctx) {
|
||||
// 1. Patch constructor chain to block host escape
|
||||
patchConstructorChain(ctx);
|
||||
|
||||
// 2. Install Chrome-style error stack formatting
|
||||
installErrorStackRewrite(ctx);
|
||||
}
|
||||
|
||||
module.exports = { createBrowserEnvironment, applySandboxPatches };
|
||||
|
||||
64
src/sandbox/mocks/math.js
Normal file
64
src/sandbox/mocks/math.js
Normal file
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Phase 2: Math precision fix
|
||||
* Node.js V8 may use different libm for trig functions.
|
||||
* Replace with Chrome-matching implementations using arrow functions
|
||||
* to avoid prototype/this leakage.
|
||||
*/
|
||||
|
||||
const { nativeMethod: M } = require('./native');
|
||||
|
||||
// Cache original Math for pass-through of non-overridden methods
|
||||
const _Math = Math;
|
||||
|
||||
// Chrome V8 trig results are IEEE 754 compliant via V8's own codegen.
|
||||
// In most cases Node.js V8 matches, but edge cases with large inputs
|
||||
// can diverge. We wrap with arrow functions per plan requirement.
|
||||
const chromeMath = Object.create(null);
|
||||
|
||||
// Copy all standard Math properties
|
||||
for (const key of Object.getOwnPropertyNames(_Math)) {
|
||||
const desc = Object.getOwnPropertyDescriptor(_Math, key);
|
||||
if (desc) Object.defineProperty(chromeMath, key, desc);
|
||||
}
|
||||
|
||||
// Override trig functions with arrow-function wrappers (no this/prototype leak)
|
||||
chromeMath.cos = M('cos', 1, (x) => _Math.cos(x));
|
||||
chromeMath.sin = M('sin', 1, (x) => _Math.sin(x));
|
||||
chromeMath.tan = M('tan', 1, (x) => _Math.tan(x));
|
||||
chromeMath.pow = M('pow', 2, (base, exp) => _Math.pow(base, exp));
|
||||
chromeMath.acos = M('acos', 1, (x) => _Math.acos(x));
|
||||
chromeMath.asin = M('asin', 1, (x) => _Math.asin(x));
|
||||
chromeMath.atan = M('atan', 1, (x) => _Math.atan(x));
|
||||
chromeMath.atan2 = M('atan2', 2, (y, x) => _Math.atan2(y, x));
|
||||
chromeMath.log = M('log', 1, (x) => _Math.log(x));
|
||||
chromeMath.exp = M('exp', 1, (x) => _Math.exp(x));
|
||||
chromeMath.sqrt = M('sqrt', 1, (x) => _Math.sqrt(x));
|
||||
|
||||
// Arrow-wrap remaining methods that probes call
|
||||
chromeMath.floor = M('floor', 1, (x) => _Math.floor(x));
|
||||
chromeMath.ceil = M('ceil', 1, (x) => _Math.ceil(x));
|
||||
chromeMath.round = M('round', 1, (x) => _Math.round(x));
|
||||
chromeMath.trunc = M('trunc', 1, (x) => _Math.trunc(x));
|
||||
chromeMath.abs = M('abs', 1, (x) => _Math.abs(x));
|
||||
chromeMath.max = M('max', 2, (...args) => _Math.max(...args));
|
||||
chromeMath.min = M('min', 2, (...args) => _Math.min(...args));
|
||||
chromeMath.random = M('random', 0, () => _Math.random());
|
||||
chromeMath.sign = M('sign', 1, (x) => _Math.sign(x));
|
||||
chromeMath.cbrt = M('cbrt', 1, (x) => _Math.cbrt(x));
|
||||
chromeMath.hypot = M('hypot', 2, (...args) => _Math.hypot(...args));
|
||||
chromeMath.log2 = M('log2', 1, (x) => _Math.log2(x));
|
||||
chromeMath.log10 = M('log10', 1, (x) => _Math.log10(x));
|
||||
chromeMath.fround = M('fround', 1, (x) => _Math.fround(x));
|
||||
chromeMath.clz32 = M('clz32', 1, (x) => _Math.clz32(x));
|
||||
chromeMath.imul = M('imul', 2, (a, b) => _Math.imul(a, b));
|
||||
|
||||
// Set Symbol.toStringTag
|
||||
Object.defineProperty(chromeMath, Symbol.toStringTag, {
|
||||
value: 'Math', configurable: true, writable: false, enumerable: false,
|
||||
});
|
||||
|
||||
// Freeze prototype chain — Math is not a constructor
|
||||
Object.setPrototypeOf(chromeMath, Object.prototype);
|
||||
|
||||
module.exports = chromeMath;
|
||||
@@ -17,9 +17,11 @@ Function.prototype.toString = function () {
|
||||
}
|
||||
return _origToString.call(this);
|
||||
};
|
||||
// toString 自身也要过检测
|
||||
nativeSet.add(Function.prototype.toString);
|
||||
|
||||
/**
|
||||
* 将一个 JS 函数包装成"看起来像原生"的函数
|
||||
* 将一个 JS 函数包装成"看起来像原生"的函数(保留 .prototype,用于构造函数)
|
||||
* @param {string} name - 函数名(影响 toString 输出)
|
||||
* @param {Function} fn - 实际实现
|
||||
* @returns {Function}
|
||||
@@ -30,6 +32,21 @@ function createNative(name, fn) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 ES2015 method shorthand 创建无 .prototype 的"原生方法"伪装
|
||||
* 真实浏览器的原型方法 / standalone 函数都没有 .prototype
|
||||
* @param {string} name - 方法名
|
||||
* @param {number} arity - Function.length(形参个数)
|
||||
* @param {Function} impl - 实际实现
|
||||
* @returns {Function}
|
||||
*/
|
||||
function createNativeMethod(name, arity, impl) {
|
||||
const fn = { [name](...args) { return impl.apply(this, args); } }[name];
|
||||
Object.defineProperty(fn, 'length', { value: arity, configurable: true });
|
||||
nativeSet.add(fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一个 class 的构造函数 + 所有原型方法 全部标记为 native
|
||||
* @param {Function} cls
|
||||
@@ -47,4 +64,64 @@ function nativeClass(cls) {
|
||||
return cls;
|
||||
}
|
||||
|
||||
module.exports = { createNative, nativeClass, nativeSet };
|
||||
// ── SafeFunction: blocks constructor chain escape ───────────────
|
||||
// Prevents obj.constructor.constructor("return process")() from
|
||||
// reaching the host Node.js realm
|
||||
const SafeFunction = createNative('Function', function (...args) {
|
||||
const body = args.length > 0 ? String(args[args.length - 1]) : '';
|
||||
// Block any attempt to access Node.js globals
|
||||
const blocked = /\b(process|require|module|exports|Buffer|global|__dirname|__filename|child_process)\b/;
|
||||
if (blocked.test(body)) {
|
||||
throw new TypeError('Function constructor is not allowed in this context');
|
||||
}
|
||||
// For benign cases, delegate to real Function but in restricted form
|
||||
try {
|
||||
return Function(...args);
|
||||
} catch (e) {
|
||||
throw new TypeError('Function constructor is not allowed in this context');
|
||||
}
|
||||
});
|
||||
Object.defineProperty(SafeFunction, 'prototype', {
|
||||
value: Function.prototype, writable: false, configurable: false,
|
||||
});
|
||||
nativeSet.add(SafeFunction);
|
||||
|
||||
// ── Safe eval wrapper ───────────────────────────────────────────
|
||||
const safeEval = createNativeMethod('eval', 1, (code) => {
|
||||
const str = String(code);
|
||||
const blocked = /\b(process|require|module|exports|Buffer|child_process)\b/;
|
||||
if (blocked.test(str)) return undefined;
|
||||
// Don't actually eval — hsw only probes for its existence
|
||||
return undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch constructor chain on all objects in a sandbox context.
|
||||
* Rewrites .constructor.constructor to SafeFunction to prevent
|
||||
* host realm escape via Function("return process")().
|
||||
* @param {object} ctx - The vm context sandbox object
|
||||
*/
|
||||
function patchConstructorChain(ctx) {
|
||||
ctx.Function = SafeFunction;
|
||||
ctx.eval = safeEval;
|
||||
|
||||
// Kill host references
|
||||
ctx.process = undefined;
|
||||
ctx.require = undefined;
|
||||
ctx.Buffer = undefined;
|
||||
ctx.module = undefined;
|
||||
ctx.exports = undefined;
|
||||
ctx.global = undefined;
|
||||
ctx.__dirname = undefined;
|
||||
ctx.__filename = undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createNative,
|
||||
nativeMethod: createNativeMethod,
|
||||
nativeClass,
|
||||
nativeSet,
|
||||
SafeFunction,
|
||||
safeEval,
|
||||
patchConstructorChain,
|
||||
};
|
||||
|
||||
@@ -2,23 +2,87 @@
|
||||
/**
|
||||
* P0/P1: Navigator mock
|
||||
* hsw 检测:webdriver / languages / maxTouchPoints / plugins / userAgentData
|
||||
* Now with proper Navigator prototype chain + missing sub-properties
|
||||
*/
|
||||
|
||||
const { createNative } = require('./native');
|
||||
const { nativeMethod: M } = require('./native');
|
||||
const {
|
||||
Navigator, NavigatorUAData, Permissions, PermissionStatus,
|
||||
PluginArray, MimeTypeArray, NetworkInformation,
|
||||
} = require('./class_registry');
|
||||
|
||||
// PluginArray 结构
|
||||
const plugins = Object.assign(Object.create({
|
||||
item: createNative('item', function (i) { return this[i] || null; }),
|
||||
namedItem: createNative('namedItem', function () { return null; }),
|
||||
refresh: createNative('refresh', function () {}),
|
||||
}), {
|
||||
// ── PluginArray with proper prototype ───────────────────────────
|
||||
const plugins = Object.create(PluginArray.prototype);
|
||||
Object.assign(plugins, {
|
||||
0: { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 2 },
|
||||
1: { name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 2 },
|
||||
2: { name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 2 },
|
||||
length: 3,
|
||||
item: M('item', 1, function (i) { return this[i] || null; }),
|
||||
namedItem: M('namedItem', 1, function () { return null; }),
|
||||
refresh: M('refresh', 0, function () {}),
|
||||
});
|
||||
|
||||
const navigatorMock = {
|
||||
// ── MimeTypeArray with proper prototype ─────────────────────────
|
||||
const mimeTypes = Object.create(MimeTypeArray.prototype);
|
||||
Object.assign(mimeTypes, {
|
||||
length: 2,
|
||||
0: { type: 'application/pdf', description: 'Portable Document Format', suffixes: 'pdf' },
|
||||
1: { type: 'text/pdf', description: 'Portable Document Format', suffixes: 'pdf' },
|
||||
item: M('item', 1, function (i) { return this[i] || null; }),
|
||||
namedItem: M('namedItem', 1, function () { return null; }),
|
||||
});
|
||||
|
||||
// ── Permissions with proper prototype ───────────────────────────
|
||||
const permissions = Object.create(Permissions.prototype);
|
||||
permissions.query = M('query', 1, (desc) => {
|
||||
const status = Object.create(PermissionStatus.prototype);
|
||||
status.state = desc.name === 'notifications' ? 'denied' : 'prompt';
|
||||
status.onchange = null;
|
||||
return Promise.resolve(status);
|
||||
});
|
||||
|
||||
// ── NetworkInformation (connection) with proper prototype ───────
|
||||
const connection = Object.create(NetworkInformation.prototype);
|
||||
Object.assign(connection, {
|
||||
effectiveType: '4g',
|
||||
type: '4g',
|
||||
downlink: 10,
|
||||
rtt: 50,
|
||||
saveData: false,
|
||||
onchange: null,
|
||||
});
|
||||
|
||||
// ── NavigatorUAData with proper prototype ────────────────────────
|
||||
const userAgentData = Object.create(NavigatorUAData.prototype);
|
||||
Object.assign(userAgentData, {
|
||||
brands: [
|
||||
{ brand: 'Not:A-Brand', version: '99' },
|
||||
{ brand: 'Google Chrome', version: '145' },
|
||||
{ brand: 'Chromium', version: '145' },
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Linux',
|
||||
getHighEntropyValues: M('getHighEntropyValues', 1, (hints) => {
|
||||
return Promise.resolve({
|
||||
architecture: 'x86',
|
||||
bitness: '64',
|
||||
model: '',
|
||||
platform: 'Linux',
|
||||
platformVersion: '6.1.0',
|
||||
uaFullVersion: '145.0.0.0',
|
||||
fullVersionList: [
|
||||
{ brand: 'Not:A-Brand', version: '99.0.0.0' },
|
||||
{ brand: 'Google Chrome', version: '145.0.0.0' },
|
||||
{ brand: 'Chromium', version: '145.0.0.0' },
|
||||
],
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
// ── Main navigator object with Navigator prototype ──────────────
|
||||
const navigatorMock = Object.create(Navigator.prototype);
|
||||
Object.assign(navigatorMock, {
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
|
||||
appVersion: '5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
|
||||
appName: 'Netscape',
|
||||
@@ -27,65 +91,76 @@ const navigatorMock = {
|
||||
product: 'Gecko',
|
||||
vendor: 'Google Inc.',
|
||||
language: 'en-US',
|
||||
languages: ['en-US', 'en'], // P1: 必须是非空数组
|
||||
webdriver: false, // navigator.webdriver = false(window.webdriver = undefined)
|
||||
maxTouchPoints: 0, // P1: 桌面为 0
|
||||
languages: Object.freeze(['en-US', 'en']),
|
||||
webdriver: false,
|
||||
maxTouchPoints: 0,
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: 8,
|
||||
cookieEnabled: true,
|
||||
onLine: true,
|
||||
doNotTrack: null,
|
||||
pdfViewerEnabled: true,
|
||||
plugins,
|
||||
mimeTypes: { length: 0 },
|
||||
mimeTypes,
|
||||
userAgentData,
|
||||
connection,
|
||||
permissions,
|
||||
|
||||
// P2: userAgentData (NavigatorUAData)
|
||||
userAgentData: {
|
||||
brands: [
|
||||
{ brand: 'Not:A-Brand', version: '99' },
|
||||
{ brand: 'Google Chrome', version: '145' },
|
||||
{ brand: 'Chromium', version: '145' },
|
||||
],
|
||||
mobile: false,
|
||||
platform: 'Linux',
|
||||
getHighEntropyValues: createNative('getHighEntropyValues', function (hints) {
|
||||
return Promise.resolve({
|
||||
architecture: 'x86',
|
||||
bitness: '64',
|
||||
model: '',
|
||||
platform: 'Linux',
|
||||
platformVersion: '6.1.0',
|
||||
uaFullVersion: '145.0.0.0',
|
||||
fullVersionList: [
|
||||
{ brand: 'Not:A-Brand', version: '99.0.0.0' },
|
||||
{ brand: 'Google Chrome', version: '145.0.0.0' },
|
||||
{ brand: 'Chromium', version: '145.0.0.0' },
|
||||
],
|
||||
});
|
||||
}),
|
||||
// P2: missing sub-properties from probe report
|
||||
mediaDevices: {
|
||||
enumerateDevices: M('enumerateDevices', 0, () => Promise.resolve([])),
|
||||
getUserMedia: M('getUserMedia', 1, () => Promise.reject(new DOMException('NotAllowedError'))),
|
||||
getDisplayMedia: M('getDisplayMedia', 0, () => Promise.reject(new DOMException('NotAllowedError'))),
|
||||
},
|
||||
|
||||
// P2: connection (NetworkInformation)
|
||||
connection: {
|
||||
effectiveType: '4g',
|
||||
downlink: 10,
|
||||
rtt: 50,
|
||||
saveData: false,
|
||||
storage: {
|
||||
estimate: M('estimate', 0, () => Promise.resolve({ quota: 2147483648, usage: 0 })),
|
||||
persist: M('persist', 0, () => Promise.resolve(false)),
|
||||
persisted: M('persisted', 0, () => Promise.resolve(false)),
|
||||
getDirectory: M('getDirectory', 0, () => Promise.resolve({})),
|
||||
},
|
||||
|
||||
webkitTemporaryStorage: {
|
||||
queryUsageAndQuota: M('queryUsageAndQuota', 1, (cb) => { if (cb) cb(0, 2147483648); }),
|
||||
},
|
||||
|
||||
keyboard: (() => {
|
||||
const kb = {
|
||||
getLayoutMap: M('getLayoutMap', 0, () => Promise.resolve(new Map())),
|
||||
lock: M('lock', 0, () => Promise.resolve()),
|
||||
unlock: M('unlock', 0, () => {}),
|
||||
};
|
||||
Object.defineProperty(kb, Symbol.toStringTag, {
|
||||
value: 'Keyboard', configurable: true, writable: false, enumerable: false,
|
||||
});
|
||||
Object.defineProperty(kb, Symbol.toPrimitive, {
|
||||
value: () => '[object Keyboard]', configurable: true, writable: false, enumerable: false,
|
||||
});
|
||||
return kb;
|
||||
})(),
|
||||
|
||||
credentials: {
|
||||
create: M('create', 1, () => Promise.resolve(null)),
|
||||
get: M('get', 1, () => Promise.resolve(null)),
|
||||
store: M('store', 1, () => Promise.resolve()),
|
||||
preventSilentAccess: M('preventSilentAccess', 0, () => Promise.resolve()),
|
||||
},
|
||||
|
||||
geolocation: {
|
||||
getCurrentPosition: createNative('getCurrentPosition', function (s, e) { e && e({ code: 1, message: 'denied' }); }),
|
||||
watchPosition: createNative('watchPosition', function () { return 0; }),
|
||||
clearWatch: createNative('clearWatch', function () {}),
|
||||
getCurrentPosition: M('getCurrentPosition', 1, (s, e) => { if (e) e({ code: 1, message: 'denied' }); }),
|
||||
watchPosition: M('watchPosition', 1, () => 0),
|
||||
clearWatch: M('clearWatch', 1, () => {}),
|
||||
},
|
||||
|
||||
permissions: {
|
||||
query: createNative('query', function (desc) {
|
||||
return Promise.resolve({ state: desc.name === 'notifications' ? 'denied' : 'prompt' });
|
||||
}),
|
||||
},
|
||||
sendBeacon: M('sendBeacon', 1, () => true),
|
||||
vibrate: M('vibrate', 1, () => false),
|
||||
});
|
||||
|
||||
sendBeacon: createNative('sendBeacon', function () { return true; }),
|
||||
vibrate: createNative('vibrate', function () { return false; }),
|
||||
};
|
||||
// oscpu: do NOT define as own property — Chrome doesn't have it at all.
|
||||
// Defining it as undefined still creates a descriptor that hsw can detect.
|
||||
|
||||
// languages Symbol.toStringTag — real Chrome: Object.prototype.toString.call(navigator.languages) = '[object Array]'
|
||||
// but the probe checks navigator.languages.Symbol(Symbol.toStringTag) — which shouldn't exist on Array
|
||||
// This is fine as-is since we froze the array.
|
||||
|
||||
module.exports = navigatorMock;
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
/**
|
||||
* P0: Performance mock
|
||||
* hsw 检测:timing / timeOrigin / getEntriesByType('resource') / getEntriesByType('navigation')
|
||||
* Now with: 5µs quantization, proper prototype chain (Performance -> EventTarget -> Object),
|
||||
* PerformanceEntry / PerformanceResourceTiming / PerformanceNavigationTiming classes,
|
||||
* getEntries() method
|
||||
*/
|
||||
|
||||
const { createNative } = require('./native');
|
||||
const { nativeMethod: M } = require('./native');
|
||||
const {
|
||||
Performance,
|
||||
PerformanceEntry,
|
||||
PerformanceResourceTiming,
|
||||
PerformanceNavigationTiming,
|
||||
} = require('./class_registry');
|
||||
|
||||
const NAV_START = Date.now() - 1200;
|
||||
|
||||
@@ -32,9 +41,23 @@ const timingData = {
|
||||
unloadEventEnd: 0,
|
||||
};
|
||||
|
||||
// ── Helper: create PerformanceResourceTiming instance ────────────
|
||||
const makeResourceEntry = (data) => {
|
||||
const entry = Object.create(PerformanceResourceTiming.prototype);
|
||||
Object.assign(entry, data);
|
||||
return entry;
|
||||
};
|
||||
|
||||
// ── Helper: create PerformanceNavigationTiming instance ──────────
|
||||
const makeNavEntry = (data) => {
|
||||
const entry = Object.create(PerformanceNavigationTiming.prototype);
|
||||
Object.assign(entry, data);
|
||||
return entry;
|
||||
};
|
||||
|
||||
// 模拟 resource 条目(hsw 会查 checksiteconfig 请求痕迹)
|
||||
const resourceEntries = [
|
||||
{
|
||||
makeResourceEntry({
|
||||
name: 'https://api.hcaptcha.com/checksiteconfig?v=xxx&host=b.stripecdn.com&sitekey=xxx&sc=1&swa=1&spst=1',
|
||||
entryType: 'resource',
|
||||
initiatorType: 'xmlhttprequest',
|
||||
@@ -60,11 +83,11 @@ const resourceEntries = [
|
||||
requestStart: 0,
|
||||
responseStart: 0,
|
||||
firstInterimResponseStart: 0,
|
||||
finalResponseHeadersStart: 0, // P2 要求的字段
|
||||
finalResponseHeadersStart: 0,
|
||||
serverTiming: [],
|
||||
renderBlockingStatus: 'non-blocking',
|
||||
},
|
||||
{
|
||||
}),
|
||||
makeResourceEntry({
|
||||
name: 'https://newassets.hcaptcha.com/c/xxx/hsw.js',
|
||||
entryType: 'resource',
|
||||
initiatorType: 'script',
|
||||
@@ -93,11 +116,11 @@ const resourceEntries = [
|
||||
redirectEnd: 0,
|
||||
serverTiming: [],
|
||||
renderBlockingStatus: 'non-blocking',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// 模拟 navigation 条目
|
||||
const navigationEntry = {
|
||||
const navigationEntry = makeNavEntry({
|
||||
name: 'https://newassets.hcaptcha.com/captcha/v1/xxx/static/hcaptcha.html',
|
||||
entryType: 'navigation',
|
||||
initiatorType: 'navigation',
|
||||
@@ -140,31 +163,47 @@ const navigationEntry = {
|
||||
workerStart: 0,
|
||||
contentEncoding: 'br',
|
||||
renderBlockingStatus: 'non-blocking',
|
||||
};
|
||||
});
|
||||
|
||||
const performanceMock = {
|
||||
// ── Build performance object with proper prototype ──────────────
|
||||
const performanceMock = Object.create(Performance.prototype);
|
||||
|
||||
Object.assign(performanceMock, {
|
||||
timeOrigin: NAV_START,
|
||||
timing: timingData,
|
||||
navigation: { type: 0, redirectCount: 0 },
|
||||
|
||||
getEntriesByType: createNative('getEntriesByType', function (type) {
|
||||
// 5µs quantization (Chromium feature)
|
||||
now: M('now', 0, () => {
|
||||
const raw = Date.now() - NAV_START;
|
||||
return Math.round(raw * 200) / 200; // 0.005ms = 5µs steps
|
||||
}),
|
||||
|
||||
getEntries: M('getEntries', 0, () => {
|
||||
return [navigationEntry, ...resourceEntries];
|
||||
}),
|
||||
|
||||
getEntriesByType: M('getEntriesByType', 1, (type) => {
|
||||
if (type === 'resource') return resourceEntries;
|
||||
if (type === 'navigation') return [navigationEntry];
|
||||
if (type === 'paint') return [];
|
||||
if (type === 'mark') return [];
|
||||
if (type === 'measure') return [];
|
||||
return [];
|
||||
}),
|
||||
|
||||
getEntriesByName: createNative('getEntriesByName', function (name) {
|
||||
return resourceEntries.filter(e => e.name === name);
|
||||
getEntriesByName: M('getEntriesByName', 1, (name) => {
|
||||
return [...resourceEntries, navigationEntry].filter(e => e.name === name);
|
||||
}),
|
||||
|
||||
now: createNative('now', function () {
|
||||
return Date.now() - NAV_START;
|
||||
}),
|
||||
|
||||
mark: createNative('mark', function () {}),
|
||||
measure: createNative('measure', function () {}),
|
||||
clearMarks: createNative('clearMarks', function () {}),
|
||||
clearMeasures: createNative('clearMeasures', function () {}),
|
||||
};
|
||||
mark: M('mark', 1, () => {}),
|
||||
measure: M('measure', 1, () => {}),
|
||||
clearMarks: M('clearMarks', 0, () => {}),
|
||||
clearMeasures: M('clearMeasures', 0, () => {}),
|
||||
clearResourceTimings: M('clearResourceTimings', 0, () => {}),
|
||||
setResourceTimingBufferSize: M('setResourceTimingBufferSize', 1, () => {}),
|
||||
addEventListener: M('addEventListener', 2, () => {}),
|
||||
removeEventListener: M('removeEventListener', 2, () => {}),
|
||||
});
|
||||
|
||||
module.exports = performanceMock;
|
||||
|
||||
@@ -2,9 +2,19 @@
|
||||
/**
|
||||
* P1: Screen mock
|
||||
* hsw 检测:screen.width / height / colorDepth / pixelDepth / availWidth / availHeight
|
||||
* Now with proper Screen prototype chain + ScreenOrientation
|
||||
*/
|
||||
|
||||
const screenMock = {
|
||||
const { Screen, ScreenOrientation } = require('./class_registry');
|
||||
|
||||
const orientation = Object.create(ScreenOrientation.prototype);
|
||||
Object.assign(orientation, {
|
||||
type: 'landscape-primary',
|
||||
angle: 0,
|
||||
});
|
||||
|
||||
const screenMock = Object.create(Screen.prototype);
|
||||
Object.assign(screenMock, {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
availWidth: 1920,
|
||||
@@ -13,10 +23,8 @@ const screenMock = {
|
||||
availTop: 0,
|
||||
colorDepth: 24,
|
||||
pixelDepth: 24,
|
||||
orientation: {
|
||||
type: 'landscape-primary',
|
||||
angle: 0,
|
||||
},
|
||||
};
|
||||
isExtended: false,
|
||||
orientation,
|
||||
});
|
||||
|
||||
module.exports = screenMock;
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
/**
|
||||
* P0: RTCPeerConnection mock
|
||||
* P0: OfflineAudioContext mock
|
||||
* P2: Blob / Worker mock (stack depth detection)
|
||||
* hsw 检测:构造函数存在性 + 原型链 + toString() 不暴露源码
|
||||
*/
|
||||
|
||||
const { createNative, nativeClass } = require('./native');
|
||||
const { nativeMethod: M, nativeClass } = require('./native');
|
||||
const {
|
||||
RTCRtpSender, RTCRtpReceiver, RTCSessionDescription,
|
||||
} = require('./class_registry');
|
||||
|
||||
// ── RTCPeerConnection ────────────────────────────────────────
|
||||
// ── RTCPeerConnection ──────────────────────────────────────────
|
||||
class RTCPeerConnection {
|
||||
constructor(config) {
|
||||
this.localDescription = null;
|
||||
@@ -17,26 +21,40 @@ class RTCPeerConnection {
|
||||
this.iceGatheringState = 'new';
|
||||
this.connectionState = 'new';
|
||||
this._config = config || {};
|
||||
this.onicecandidate = null;
|
||||
this.onicegatheringstatechange = null;
|
||||
this.onconnectionstatechange = null;
|
||||
this.oniceconnectionstatechange = null;
|
||||
this.onsignalingstatechange = null;
|
||||
this.ondatachannel = null;
|
||||
this.ontrack = null;
|
||||
}
|
||||
}
|
||||
RTCPeerConnection.prototype.createOffer = createNative('createOffer', function (options) {
|
||||
return Promise.resolve({ type: 'offer', sdp: 'v=0\r\n' });
|
||||
});
|
||||
RTCPeerConnection.prototype.createAnswer = createNative('createAnswer', function () {
|
||||
return Promise.resolve({ type: 'answer', sdp: 'v=0\r\n' });
|
||||
});
|
||||
RTCPeerConnection.prototype.setLocalDescription = createNative('setLocalDescription', function () { return Promise.resolve(); });
|
||||
RTCPeerConnection.prototype.setRemoteDescription = createNative('setRemoteDescription', function () { return Promise.resolve(); });
|
||||
RTCPeerConnection.prototype.addIceCandidate = createNative('addIceCandidate', function () { return Promise.resolve(); });
|
||||
RTCPeerConnection.prototype.createDataChannel = createNative('createDataChannel', function (label) {
|
||||
return { label, readyState: 'open', close: createNative('close', function(){}) };
|
||||
});
|
||||
RTCPeerConnection.prototype.close = createNative('close', function () {});
|
||||
RTCPeerConnection.prototype.addEventListener = createNative('addEventListener', function () {});
|
||||
RTCPeerConnection.prototype.removeEventListener = createNative('removeEventListener', function () {});
|
||||
RTCPeerConnection.prototype.createOffer = M('createOffer', 0, (options) =>
|
||||
Promise.resolve({ type: 'offer', sdp: 'v=0\r\n' })
|
||||
);
|
||||
RTCPeerConnection.prototype.createAnswer = M('createAnswer', 0, () =>
|
||||
Promise.resolve({ type: 'answer', sdp: 'v=0\r\n' })
|
||||
);
|
||||
RTCPeerConnection.prototype.setLocalDescription = M('setLocalDescription', 0, () => Promise.resolve());
|
||||
RTCPeerConnection.prototype.setRemoteDescription = M('setRemoteDescription', 1, () => Promise.resolve());
|
||||
RTCPeerConnection.prototype.addIceCandidate = M('addIceCandidate', 0, () => Promise.resolve());
|
||||
RTCPeerConnection.prototype.createDataChannel = M('createDataChannel', 1, (label) => ({
|
||||
label, readyState: 'open', close: M('close', 0, () => {}),
|
||||
send: M('send', 1, () => {}), onmessage: null, onopen: null, onclose: null,
|
||||
}));
|
||||
RTCPeerConnection.prototype.close = M('close', 0, () => {});
|
||||
RTCPeerConnection.prototype.getSenders = M('getSenders', 0, () => []);
|
||||
RTCPeerConnection.prototype.getReceivers = M('getReceivers', 0, () => []);
|
||||
RTCPeerConnection.prototype.getStats = M('getStats', 0, () => Promise.resolve(new Map()));
|
||||
RTCPeerConnection.prototype.addTrack = M('addTrack', 1, () => Object.create(RTCRtpSender.prototype));
|
||||
RTCPeerConnection.prototype.removeTrack = M('removeTrack', 1, () => {});
|
||||
RTCPeerConnection.prototype.getConfiguration = M('getConfiguration', 0, function () { return this._config; });
|
||||
RTCPeerConnection.prototype.addEventListener = M('addEventListener', 2, () => {});
|
||||
RTCPeerConnection.prototype.removeEventListener = M('removeEventListener', 2, () => {});
|
||||
nativeClass(RTCPeerConnection);
|
||||
|
||||
// ── OfflineAudioContext ──────────────────────────────────────
|
||||
// ── OfflineAudioContext ────────────────────────────────────────
|
||||
class OfflineAudioContext {
|
||||
constructor(channels, length, sampleRate) {
|
||||
this.length = length || 4096;
|
||||
@@ -46,40 +64,141 @@ class OfflineAudioContext {
|
||||
this.destination = { channelCount: channels || 1 };
|
||||
}
|
||||
}
|
||||
OfflineAudioContext.prototype.createAnalyser = createNative('createAnalyser', function () {
|
||||
OfflineAudioContext.prototype.createAnalyser = M('createAnalyser', 0, function () {
|
||||
return {
|
||||
fftSize: 2048,
|
||||
frequencyBinCount: 1024,
|
||||
connect: createNative('connect', function () {}),
|
||||
getFloatFrequencyData: createNative('getFloatFrequencyData', function (arr) {
|
||||
connect: M('connect', 1, () => {}),
|
||||
disconnect: M('disconnect', 0, () => {}),
|
||||
getFloatFrequencyData: M('getFloatFrequencyData', 1, (arr) => {
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = -100 + Math.random() * 5;
|
||||
}),
|
||||
getByteFrequencyData: M('getByteFrequencyData', 1, (arr) => {
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = 0;
|
||||
}),
|
||||
};
|
||||
});
|
||||
OfflineAudioContext.prototype.createOscillator = createNative('createOscillator', function () {
|
||||
OfflineAudioContext.prototype.createOscillator = M('createOscillator', 0, function () {
|
||||
return {
|
||||
type: 'triangle',
|
||||
frequency: { value: 10000 },
|
||||
connect: createNative('connect', function () {}),
|
||||
start: createNative('start', function () {}),
|
||||
connect: M('connect', 1, () => {}),
|
||||
disconnect: M('disconnect', 0, () => {}),
|
||||
start: M('start', 0, () => {}),
|
||||
stop: M('stop', 0, () => {}),
|
||||
};
|
||||
});
|
||||
OfflineAudioContext.prototype.createDynamicsCompressor = createNative('createDynamicsCompressor', function () {
|
||||
OfflineAudioContext.prototype.createDynamicsCompressor = M('createDynamicsCompressor', 0, function () {
|
||||
return {
|
||||
threshold: { value: -50 }, knee: { value: 40 },
|
||||
ratio: { value: 12 }, attack: { value: 0 }, release: { value: 0.25 },
|
||||
connect: createNative('connect', function () {}),
|
||||
connect: M('connect', 1, () => {}),
|
||||
disconnect: M('disconnect', 0, () => {}),
|
||||
};
|
||||
});
|
||||
OfflineAudioContext.prototype.startRendering = createNative('startRendering', function () {
|
||||
OfflineAudioContext.prototype.createGain = M('createGain', 0, function () {
|
||||
return {
|
||||
gain: { value: 1 },
|
||||
connect: M('connect', 1, () => {}),
|
||||
disconnect: M('disconnect', 0, () => {}),
|
||||
};
|
||||
});
|
||||
OfflineAudioContext.prototype.createBiquadFilter = M('createBiquadFilter', 0, function () {
|
||||
return {
|
||||
type: 'lowpass',
|
||||
frequency: { value: 350 },
|
||||
Q: { value: 1 },
|
||||
gain: { value: 0 },
|
||||
connect: M('connect', 1, () => {}),
|
||||
disconnect: M('disconnect', 0, () => {}),
|
||||
};
|
||||
});
|
||||
OfflineAudioContext.prototype.startRendering = M('startRendering', 0, function () {
|
||||
const len = this.length;
|
||||
// 固定指纹数据,保持每次一致(稳定指纹)
|
||||
const data = new Float32Array(len);
|
||||
for (let i = 0; i < len; i++) data[i] = Math.sin(i * 0.001) * 0.01;
|
||||
return Promise.resolve({ getChannelData: () => data });
|
||||
return Promise.resolve({ getChannelData: M('getChannelData', 1, () => data), length: len, sampleRate: this.sampleRate, numberOfChannels: 1, duration: len / this.sampleRate });
|
||||
});
|
||||
OfflineAudioContext.prototype.addEventListener = createNative('addEventListener', function () {});
|
||||
OfflineAudioContext.prototype.removeEventListener = createNative('removeEventListener', function () {});
|
||||
OfflineAudioContext.prototype.addEventListener = M('addEventListener', 2, () => {});
|
||||
OfflineAudioContext.prototype.removeEventListener = M('removeEventListener', 2, () => {});
|
||||
nativeClass(OfflineAudioContext);
|
||||
|
||||
module.exports = { RTCPeerConnection, OfflineAudioContext };
|
||||
// ── Blob ────────────────────────────────────────────────────────
|
||||
class Blob {
|
||||
constructor(parts, options) {
|
||||
this._parts = parts || [];
|
||||
this.type = (options && options.type) || '';
|
||||
this.size = this._parts.reduce((s, p) => {
|
||||
if (typeof p === 'string') return s + p.length;
|
||||
if (p && p.byteLength !== undefined) return s + p.byteLength;
|
||||
return s + String(p).length;
|
||||
}, 0);
|
||||
}
|
||||
slice(start, end, type) {
|
||||
return new Blob([], { type: type || this.type });
|
||||
}
|
||||
text() {
|
||||
return Promise.resolve(this._parts.join(''));
|
||||
}
|
||||
arrayBuffer() {
|
||||
return Promise.resolve(new ArrayBuffer(this.size));
|
||||
}
|
||||
}
|
||||
nativeClass(Blob);
|
||||
|
||||
// ── Worker (stack depth detection) ──────────────────────────────
|
||||
// hsw creates Blob Workers to run stack depth tests.
|
||||
// Chrome typical stack depth: ~10000-13000
|
||||
// Node.js default: ~15000+
|
||||
// Must return Chrome-range values.
|
||||
class Worker {
|
||||
constructor(url) {
|
||||
this.onmessage = null;
|
||||
this.onerror = null;
|
||||
this._terminated = false;
|
||||
this._url = url;
|
||||
}
|
||||
postMessage(data) {
|
||||
if (this._terminated) return;
|
||||
// Simulate async worker response with Chrome-range stack depth
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
if (self._terminated) return;
|
||||
if (self.onmessage) {
|
||||
// Stack depth test: Chrome returns ~10000-13000
|
||||
const stackDepth = 11847 + Math.floor(Math.random() * 500);
|
||||
self.onmessage({ data: { stackDepth, result: stackDepth } });
|
||||
}
|
||||
}, 5);
|
||||
}
|
||||
terminate() { this._terminated = true; }
|
||||
addEventListener(type, fn) {
|
||||
if (type === 'message') this.onmessage = fn;
|
||||
if (type === 'error') this.onerror = fn;
|
||||
}
|
||||
removeEventListener() {}
|
||||
}
|
||||
nativeClass(Worker);
|
||||
|
||||
// URL.createObjectURL / URL.revokeObjectURL for Blob Workers
|
||||
const blobURLStore = new Map();
|
||||
const createObjectURL = M('createObjectURL', 1, (blob) => {
|
||||
const id = `blob:https://newassets.hcaptcha.com/${Math.random().toString(36).slice(2)}`;
|
||||
blobURLStore.set(id, blob);
|
||||
return id;
|
||||
});
|
||||
const revokeObjectURL = M('revokeObjectURL', 1, (url) => {
|
||||
blobURLStore.delete(url);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
RTCPeerConnection,
|
||||
OfflineAudioContext,
|
||||
RTCRtpSender,
|
||||
RTCRtpReceiver,
|
||||
RTCSessionDescription,
|
||||
Blob,
|
||||
Worker,
|
||||
createObjectURL,
|
||||
revokeObjectURL,
|
||||
};
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
/**
|
||||
* 总装:window 沙盒
|
||||
* 按 P0→P1→P2 顺序挂载所有 mock,并用 Proxy 屏蔽 bot 字段
|
||||
* Now with: proper Window prototype chain, getOwnPropertyDescriptor trap,
|
||||
* Chrome-accurate ownKeys list, missing globals (chrome, matchMedia, etc),
|
||||
* SafeFunction/safeEval for constructor chain escape defense
|
||||
*/
|
||||
|
||||
const { createNative, nativeClass } = require('./native');
|
||||
const { createNative, nativeMethod: M, nativeClass, SafeFunction, safeEval } = require('./native');
|
||||
const { isBotKey } = require('./bot_shield');
|
||||
const performanceMock = require('./performance');
|
||||
const navigatorMock = require('./navigator');
|
||||
const { RTCPeerConnection, OfflineAudioContext } = require('./webapi');
|
||||
const {
|
||||
RTCPeerConnection, OfflineAudioContext,
|
||||
RTCRtpSender, RTCRtpReceiver, RTCSessionDescription,
|
||||
Blob, Worker, createObjectURL, revokeObjectURL,
|
||||
} = require('./webapi');
|
||||
const { HTMLCanvasElement, CanvasRenderingContext2D } = require('./canvas');
|
||||
const { cryptoMock, Storage, IDBFactory, Notification, atob, btoa } = require('./crypto');
|
||||
const screenMock = require('./screen');
|
||||
const HTMLDocument = require('./document');
|
||||
const chromeMath = require('./math');
|
||||
const CR = require('./class_registry');
|
||||
|
||||
// ── 基础 window 对象 ─────────────────────────────────────────
|
||||
const _win = {
|
||||
const _win = Object.create(CR.Window.prototype);
|
||||
|
||||
Object.assign(_win, {
|
||||
|
||||
// ── P0: 核心 API ──────────────────────────────────────
|
||||
performance: performanceMock,
|
||||
@@ -26,6 +37,9 @@ const _win = {
|
||||
RTCPeerConnection,
|
||||
webkitRTCPeerConnection: RTCPeerConnection,
|
||||
OfflineAudioContext,
|
||||
RTCRtpSender,
|
||||
RTCRtpReceiver,
|
||||
RTCSessionDescription,
|
||||
|
||||
// ── P1: Canvas ────────────────────────────────────────
|
||||
HTMLCanvasElement,
|
||||
@@ -48,14 +62,18 @@ const _win = {
|
||||
document: new HTMLDocument(),
|
||||
HTMLDocument,
|
||||
|
||||
// ── P1: Blob / Worker ─────────────────────────────────
|
||||
Blob,
|
||||
Worker,
|
||||
|
||||
// ── P2: 移动端触摸 → 桌面不存在 ──────────────────────
|
||||
// ontouchstart: 不定义,Proxy 返回 undefined
|
||||
|
||||
// ── 基础 JS 全局 ─────────────────────────────────────
|
||||
// ── 基础 JS 全局 (use SafeFunction to block escape) ──
|
||||
Promise,
|
||||
Object,
|
||||
Array,
|
||||
Function,
|
||||
Function: SafeFunction,
|
||||
Number,
|
||||
String,
|
||||
Boolean,
|
||||
@@ -63,7 +81,13 @@ const _win = {
|
||||
Date,
|
||||
RegExp,
|
||||
Error,
|
||||
Math,
|
||||
TypeError,
|
||||
RangeError,
|
||||
SyntaxError,
|
||||
ReferenceError,
|
||||
URIError,
|
||||
EvalError,
|
||||
Math: chromeMath,
|
||||
JSON,
|
||||
parseInt,
|
||||
parseFloat,
|
||||
@@ -75,7 +99,7 @@ const _win = {
|
||||
encodeURIComponent,
|
||||
escape,
|
||||
unescape,
|
||||
eval,
|
||||
eval: safeEval,
|
||||
undefined,
|
||||
Infinity,
|
||||
NaN,
|
||||
@@ -100,6 +124,9 @@ const _win = {
|
||||
search: '',
|
||||
hash: '',
|
||||
ancestorOrigins: { 0: 'https://b.stripecdn.com', 1: 'https://js.stripe.com', length: 2 },
|
||||
assign: M('assign', 1, () => {}),
|
||||
replace: M('replace', 1, () => {}),
|
||||
reload: M('reload', 0, () => {}),
|
||||
},
|
||||
|
||||
innerWidth: 530,
|
||||
@@ -133,20 +160,21 @@ const _win = {
|
||||
length: 1,
|
||||
state: null,
|
||||
scrollRestoration: 'auto',
|
||||
go: createNative('go', function () {}),
|
||||
back: createNative('back', function () {}),
|
||||
forward: createNative('forward', function () {}),
|
||||
pushState: createNative('pushState', function () {}),
|
||||
replaceState: createNative('replaceState', function () {}),
|
||||
go: M('go', 0, () => {}),
|
||||
back: M('back', 0, () => {}),
|
||||
forward: M('forward', 0, () => {}),
|
||||
pushState: M('pushState', 2, () => {}),
|
||||
replaceState: M('replaceState', 2, () => {}),
|
||||
},
|
||||
|
||||
fetch: createNative('fetch', function (url, opts) {
|
||||
// 沙盒里一般不真正发请求,返回 resolved 空 response
|
||||
fetch: M('fetch', 1, (url, opts) => {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve({}),
|
||||
text: () => Promise.resolve(''),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
json: M('json', 0, () => Promise.resolve({})),
|
||||
text: M('text', 0, () => Promise.resolve('')),
|
||||
arrayBuffer: M('arrayBuffer', 0, () => Promise.resolve(new ArrayBuffer(0))),
|
||||
blob: M('blob', 0, () => Promise.resolve(new Blob([]))),
|
||||
headers: new Map(),
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -154,31 +182,49 @@ const _win = {
|
||||
Response: createNative('Response', function (body, opts) { this.status = opts?.status || 200; }),
|
||||
Headers: createNative('Headers', function () { this._h = {}; }),
|
||||
|
||||
URL: createNative('URL', function (url, base) {
|
||||
const u = new (require('url').URL)(url, base);
|
||||
Object.assign(this, u);
|
||||
}),
|
||||
URL: (() => {
|
||||
const _URL = createNative('URL', function (url, base) {
|
||||
const u = new (require('url').URL)(url, base);
|
||||
Object.assign(this, { href: u.href, origin: u.origin, protocol: u.protocol,
|
||||
host: u.host, hostname: u.hostname, port: u.port, pathname: u.pathname,
|
||||
search: u.search, hash: u.hash, searchParams: u.searchParams });
|
||||
});
|
||||
_URL.createObjectURL = createObjectURL;
|
||||
_URL.revokeObjectURL = revokeObjectURL;
|
||||
return _URL;
|
||||
})(),
|
||||
URLSearchParams,
|
||||
|
||||
addEventListener: createNative('addEventListener', function () {}),
|
||||
removeEventListener: createNative('removeEventListener', function () {}),
|
||||
dispatchEvent: createNative('dispatchEvent', function () { return true; }),
|
||||
postMessage: createNative('postMessage', function () {}),
|
||||
addEventListener: M('addEventListener', 2, () => {}),
|
||||
removeEventListener: M('removeEventListener', 2, () => {}),
|
||||
dispatchEvent: M('dispatchEvent', 1, () => true),
|
||||
postMessage: M('postMessage', 1, () => {}),
|
||||
|
||||
alert: createNative('alert', function () {}),
|
||||
confirm: createNative('confirm', function () { return false; }),
|
||||
prompt: createNative('prompt', function () { return null; }),
|
||||
alert: M('alert', 0, () => {}),
|
||||
confirm: M('confirm', 1, () => false),
|
||||
prompt: M('prompt', 0, () => null),
|
||||
close: M('close', 0, () => {}),
|
||||
stop: M('stop', 0, () => {}),
|
||||
focus: M('focus', 0, () => {}),
|
||||
blur: M('blur', 0, () => {}),
|
||||
print: M('print', 0, () => {}),
|
||||
open: M('open', 0, () => null),
|
||||
|
||||
requestAnimationFrame: createNative('requestAnimationFrame', function (cb) { return setTimeout(cb, 16); }),
|
||||
cancelAnimationFrame: createNative('cancelAnimationFrame', function (id) { clearTimeout(id); }),
|
||||
requestIdleCallback: createNative('requestIdleCallback', function (cb) { return setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1); }),
|
||||
cancelIdleCallback: createNative('cancelIdleCallback', function (id) { clearTimeout(id); }),
|
||||
requestAnimationFrame: M('requestAnimationFrame', 1, (cb) => setTimeout(cb, 16)),
|
||||
cancelAnimationFrame: M('cancelAnimationFrame', 1, (id) => clearTimeout(id)),
|
||||
requestIdleCallback: M('requestIdleCallback', 1, (cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1)),
|
||||
cancelIdleCallback: M('cancelIdleCallback', 1, (id) => clearTimeout(id)),
|
||||
|
||||
getComputedStyle: createNative('getComputedStyle', function () {
|
||||
getComputedStyle: M('getComputedStyle', 1, () => {
|
||||
return new Proxy({}, { get: (_, p) => p === 'getPropertyValue' ? (() => '') : '' });
|
||||
}),
|
||||
|
||||
structuredClone: createNative('structuredClone', (v) => JSON.parse(JSON.stringify(v))),
|
||||
getSelection: M('getSelection', 0, () => ({
|
||||
rangeCount: 0, toString: () => '', removeAllRanges: () => {},
|
||||
addRange: () => {}, getRangeAt: () => ({}),
|
||||
})),
|
||||
|
||||
structuredClone: M('structuredClone', 1, (v) => JSON.parse(JSON.stringify(v))),
|
||||
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
@@ -192,22 +238,158 @@ const _win = {
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
ArrayBuffer,
|
||||
SharedArrayBuffer,
|
||||
DataView,
|
||||
Map,
|
||||
Set,
|
||||
WeakMap,
|
||||
WeakSet,
|
||||
WeakRef,
|
||||
Proxy,
|
||||
Reflect,
|
||||
BigInt,
|
||||
BigInt64Array,
|
||||
BigUint64Array,
|
||||
Symbol,
|
||||
WebAssembly,
|
||||
};
|
||||
Atomics,
|
||||
AggregateError,
|
||||
|
||||
// ── Missing globals from probe report (P1) ────────────
|
||||
chrome: {
|
||||
runtime: {
|
||||
connect: M('connect', 0, () => ({})),
|
||||
sendMessage: M('sendMessage', 1, () => {}),
|
||||
onMessage: { addListener: M('addListener', 1, () => {}), removeListener: M('removeListener', 1, () => {}) },
|
||||
id: undefined,
|
||||
},
|
||||
loadTimes: M('loadTimes', 0, () => ({})),
|
||||
csi: M('csi', 0, () => ({})),
|
||||
},
|
||||
|
||||
clientInformation: navigatorMock,
|
||||
|
||||
matchMedia: M('matchMedia', 1, (query) => ({
|
||||
matches: false,
|
||||
media: query || '',
|
||||
onchange: null,
|
||||
addListener: M('addListener', 1, () => {}),
|
||||
removeListener: M('removeListener', 1, () => {}),
|
||||
addEventListener: M('addEventListener', 2, () => {}),
|
||||
removeEventListener: M('removeEventListener', 2, () => {}),
|
||||
dispatchEvent: M('dispatchEvent', 1, () => false),
|
||||
})),
|
||||
|
||||
visualViewport: new CR.VisualViewport(),
|
||||
|
||||
Intl,
|
||||
FinalizationRegistry,
|
||||
|
||||
// ── Class constructors from registry ──────────────────
|
||||
EventTarget: CR.EventTarget,
|
||||
Window: CR.Window,
|
||||
Navigator: CR.Navigator,
|
||||
Performance: CR.Performance,
|
||||
PerformanceEntry: CR.PerformanceEntry,
|
||||
PerformanceResourceTiming: CR.PerformanceResourceTiming,
|
||||
PerformanceNavigationTiming: CR.PerformanceNavigationTiming,
|
||||
Crypto: CR.Crypto,
|
||||
SubtleCrypto: CR.SubtleCrypto,
|
||||
Screen: CR.Screen,
|
||||
Node: CR.Node,
|
||||
Element: CR.Element,
|
||||
HTMLElement: CR.HTMLElement,
|
||||
Document: CR.Document,
|
||||
HTMLIFrameElement: CR.HTMLIFrameElement,
|
||||
SVGElement: CR.SVGElement,
|
||||
SVGTextContentElement: CR.SVGTextContentElement,
|
||||
FontFace: CR.FontFace,
|
||||
CSS: CR.CSS,
|
||||
WebGLRenderingContext: CR.WebGLRenderingContext,
|
||||
WebGL2RenderingContext: CR.WebGL2RenderingContext,
|
||||
AudioContext: CR.AudioContext,
|
||||
AnalyserNode: CR.AnalyserNode,
|
||||
AudioBuffer: CR.AudioBuffer,
|
||||
Audio: CR.Audio,
|
||||
Permissions: CR.Permissions,
|
||||
PermissionStatus: CR.PermissionStatus,
|
||||
PluginArray: CR.PluginArray,
|
||||
MimeTypeArray: CR.MimeTypeArray,
|
||||
NetworkInformation: CR.NetworkInformation,
|
||||
NavigatorUAData: CR.NavigatorUAData,
|
||||
VisualViewport: CR.VisualViewport,
|
||||
DOMException,
|
||||
|
||||
// ── DOMRect / DOMMatrix stubs ────────────────────────
|
||||
DOMRect: createNative('DOMRect', function (x, y, w, h) {
|
||||
this.x = x || 0; this.y = y || 0; this.width = w || 0; this.height = h || 0;
|
||||
this.top = this.y; this.left = this.x; this.bottom = this.y + this.height; this.right = this.x + this.width;
|
||||
}),
|
||||
DOMMatrix: createNative('DOMMatrix', function () {
|
||||
this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.e = 0; this.f = 0;
|
||||
}),
|
||||
|
||||
// ── MessageChannel / MessagePort ─────────────────────
|
||||
MessageChannel: createNative('MessageChannel', function () {
|
||||
this.port1 = { postMessage: M('postMessage', 1, () => {}), onmessage: null, close: M('close', 0, () => {}) };
|
||||
this.port2 = { postMessage: M('postMessage', 1, () => {}), onmessage: null, close: M('close', 0, () => {}) };
|
||||
}),
|
||||
|
||||
// ── BroadcastChannel ─────────────────────────────────
|
||||
BroadcastChannel: createNative('BroadcastChannel', function (name) {
|
||||
this.name = name; this.onmessage = null;
|
||||
this.postMessage = M('postMessage', 1, () => {});
|
||||
this.close = M('close', 0, () => {});
|
||||
}),
|
||||
|
||||
// ── MutationObserver / IntersectionObserver / ResizeObserver ─
|
||||
MutationObserver: createNative('MutationObserver', function (cb) {
|
||||
this.observe = M('observe', 1, () => {}); this.disconnect = M('disconnect', 0, () => {});
|
||||
this.takeRecords = M('takeRecords', 0, () => []);
|
||||
}),
|
||||
IntersectionObserver: createNative('IntersectionObserver', function (cb) {
|
||||
this.observe = M('observe', 1, () => {}); this.unobserve = M('unobserve', 1, () => {});
|
||||
this.disconnect = M('disconnect', 0, () => {});
|
||||
}),
|
||||
ResizeObserver: createNative('ResizeObserver', function (cb) {
|
||||
this.observe = M('observe', 1, () => {}); this.unobserve = M('unobserve', 1, () => {});
|
||||
this.disconnect = M('disconnect', 0, () => {});
|
||||
}),
|
||||
|
||||
// ── XMLHttpRequest (stub) ────────────────────────────
|
||||
XMLHttpRequest: createNative('XMLHttpRequest', function () {
|
||||
this.readyState = 0; this.status = 0; this.responseText = ''; this.response = null;
|
||||
this.onreadystatechange = null; this.onload = null; this.onerror = null;
|
||||
}),
|
||||
|
||||
// ── Image / HTMLImageElement ──────────────────────────
|
||||
Image: createNative('Image', function (w, h) {
|
||||
this.width = w || 0; this.height = h || 0; this.src = '';
|
||||
this.onload = null; this.onerror = null; this.complete = false;
|
||||
}),
|
||||
|
||||
// ── AbortController ──────────────────────────────────
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
});
|
||||
|
||||
// ── Iterator (newer JS global, may not exist in all Node versions) ──
|
||||
if (typeof globalThis.Iterator !== 'undefined') {
|
||||
_win.Iterator = globalThis.Iterator;
|
||||
}
|
||||
// ── SuppressedError (ES2024) ────────────────────────────────────
|
||||
if (typeof globalThis.SuppressedError !== 'undefined') {
|
||||
_win.SuppressedError = globalThis.SuppressedError;
|
||||
}
|
||||
|
||||
// Node.js globals are filtered by bot_shield (NODE_KEYS).
|
||||
// Do NOT define them as own properties — even undefined values
|
||||
// create descriptors that getOwnPropertyDescriptor can detect.
|
||||
|
||||
// ── 建 Proxy:屏蔽 bot 字段 + 回填自引用 ────────────────────
|
||||
const windowProxy = new Proxy(_win, {
|
||||
get(target, prop) {
|
||||
if (isBotKey(prop)) return undefined; // 🚨 bot 字段全部返回 undefined
|
||||
if (isBotKey(prop)) return undefined;
|
||||
const val = target[prop];
|
||||
if (val === null && ['self','window','frames','parent','top','globalThis'].includes(prop)) {
|
||||
return windowProxy;
|
||||
@@ -215,17 +397,30 @@ const windowProxy = new Proxy(_win, {
|
||||
return val;
|
||||
},
|
||||
has(target, prop) {
|
||||
if (isBotKey(prop)) return false; // 拦截 'webdriver' in window
|
||||
if (isBotKey(prop)) return false;
|
||||
return prop in target;
|
||||
},
|
||||
set(target, prop, val) {
|
||||
if (isBotKey(prop)) return true; // 静默丢弃 bot 字段的写入
|
||||
if (isBotKey(prop)) return true;
|
||||
target[prop] = val;
|
||||
return true;
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (isBotKey(prop)) return undefined;
|
||||
if (prop in target) {
|
||||
const desc = Object.getOwnPropertyDescriptor(target, prop);
|
||||
if (desc) return desc;
|
||||
// For inherited properties, make them appear as own (Chrome behavior)
|
||||
return { value: target[prop], writable: true, enumerable: true, configurable: true };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(target).filter(k => !isBotKey(k));
|
||||
},
|
||||
getPrototypeOf() {
|
||||
return CR.Window.prototype;
|
||||
},
|
||||
});
|
||||
|
||||
// 回填自引用
|
||||
@@ -236,4 +431,7 @@ _win.frames = windowProxy;
|
||||
_win.parent = windowProxy;
|
||||
_win.top = windowProxy;
|
||||
|
||||
// global 别名 (some code checks for `global`)
|
||||
_win.global = windowProxy;
|
||||
|
||||
module.exports = windowProxy;
|
||||
|
||||
Reference in New Issue
Block a user