Files

350 lines
18 KiB
JavaScript

// 指纹值回放模板:用于 Canvas / WebGL / DOM 几何等终端 API 的真实浏览器采样值回放。
// 使用前复制到 case/result/src/env/ 中,并接入 case/fixtures/fingerprint.fixture.json。
// 目标:回放真实浏览器最终返回值,而不是在 Node.js 中真实模拟渲染。
'use strict';
let native = {};
try { native = require('./native-protect'); } catch {}
const defineValue = native.defineValue || function defineValue(obj, key, value, options = {}) {
Object.defineProperty(obj, key, {
value,
writable: options.writable ?? true,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return value;
};
const defineNativeValue = native.defineNativeValue || function defineNativeValue(obj, key, impl, options = {}) {
return defineValue(obj, key, impl, options);
};
const defineNativeGetter = native.defineNativeGetter || function defineNativeGetter(obj, key, impl, options = {}) {
Object.defineProperty(obj, key, {
get: impl,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return impl;
};
const markObjectToString = native.markObjectToString || function markObjectToString(obj, tag) {
try {
Object.defineProperty(obj, Symbol.toStringTag, {
value: tag,
enumerable: false,
configurable: true,
});
} catch {}
return obj;
};
function stackText() {
try { return String(new Error().stack || ''); } catch { return ''; }
}
function b64ToBytes(value) {
if (!value) return new Uint8ClampedArray();
const buf = Buffer.from(String(value), 'base64');
return new Uint8ClampedArray(buf.buffer, buf.byteOffset, buf.byteLength);
}
function includesStack(match, stack) {
if (!match || !match.stackIncludes) return true;
const needles = Array.isArray(match.stackIncludes) ? match.stackIncludes : [match.stackIncludes];
return needles.some(s => stack.includes(String(s)));
}
function matchScalar(expected, actual) {
if (expected === undefined) return true;
return String(expected) === String(actual);
}
function findReplay(list, ctx, options = {}) {
const items = Array.isArray(list) ? list : [];
const stack = ctx.stack || stackText();
for (const item of items) {
const match = item.match || {};
let ok = includesStack(match, stack);
for (const [key, expected] of Object.entries(match)) {
if (key === 'stackIncludes') continue;
ok = ok && matchScalar(expected, ctx[key]);
}
if (ok) return item.result;
}
if (options.strict !== false) {
const brief = Object.fromEntries(Object.entries(ctx).filter(([k]) => k !== 'stack'));
throw new Error(`缺少浏览器指纹回放样本:${ctx.api || 'unknown'} ${JSON.stringify(brief)}`);
}
return undefined;
}
function createImageData(result) {
const width = Number(result && result.width) || 0;
const height = Number(result && result.height) || 0;
const data = result && result.dataBase64 ? b64ToBytes(result.dataBase64) : new Uint8ClampedArray(width * height * 4);
return markObjectToString({ width, height, data }, 'ImageData');
}
function ensureConstructors(g, options = {}) {
const addon = options.addon;
if (!g.HTMLCanvasElement) {
const HTMLCanvasElement = native.createNativeConstructor ? native.createNativeConstructor('HTMLCanvasElement', 0, function HTMLCanvasElement() {}, addon) : function HTMLCanvasElement() {};
defineValue(g, 'HTMLCanvasElement', HTMLCanvasElement, { enumerable: false, configurable: true });
}
if (!g.CanvasRenderingContext2D) {
const CanvasRenderingContext2D = native.createNativeConstructor ? native.createNativeConstructor('CanvasRenderingContext2D', 0, function CanvasRenderingContext2D() {}, addon) : function CanvasRenderingContext2D() {};
defineValue(g, 'CanvasRenderingContext2D', CanvasRenderingContext2D, { enumerable: false, configurable: true });
}
if (!g.WebGLRenderingContext) {
const WebGLRenderingContext = native.createNativeConstructor ? native.createNativeConstructor('WebGLRenderingContext', 0, function WebGLRenderingContext() {}, addon) : function WebGLRenderingContext() {};
defineValue(g, 'WebGLRenderingContext', WebGLRenderingContext, { enumerable: false, configurable: true });
}
if (!g.DOMRect) {
const DOMRect = native.createNativeConstructor ? native.createNativeConstructor('DOMRect', 4, function DOMRect(isNew, x = 0, y = 0, width = 0, height = 0) {
if (!isNew && !(this instanceof DOMRect)) return new g.DOMRect(x, y, width, height);
defineValue(this, 'x', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'y', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'width', Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'height', Number(height), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'top', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'left', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'right', Number(x) + Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'bottom', Number(y) + Number(height), { enumerable: true, writable: false, configurable: true });
markObjectToString(this, 'DOMRect');
}, addon) : function DOMRect(x = 0, y = 0, width = 0, height = 0) {
defineValue(this, 'x', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'y', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'width', Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'height', Number(height), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'top', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'left', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'right', Number(x) + Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'bottom', Number(y) + Number(height), { enumerable: true, writable: false, configurable: true });
markObjectToString(this, 'DOMRect');
};
defineValue(g, 'DOMRect', DOMRect, { enumerable: false, configurable: true });
}
for (const Ctor of [g.HTMLCanvasElement, g.CanvasRenderingContext2D, g.WebGLRenderingContext, g.DOMRect]) {
try {
defineValue(Ctor.prototype, 'constructor', Ctor, { enumerable: false, writable: true, configurable: true });
} catch {}
}
try { markObjectToString(g.HTMLCanvasElement.prototype, 'HTMLCanvasElement'); } catch {}
try { markObjectToString(g.CanvasRenderingContext2D.prototype, 'CanvasRenderingContext2D'); } catch {}
try { markObjectToString(g.WebGLRenderingContext.prototype, 'WebGLRenderingContext'); } catch {}
}
function create2DContext(g, canvas, state) {
const ctx = Object.create(g.CanvasRenderingContext2D.prototype);
ctx.__canvas = canvas;
ctx.__state = state || { font: '10px sans-serif' };
markObjectToString(ctx, 'CanvasRenderingContext2D');
return ctx;
}
function createWebGLContext(g, canvas, state) {
const gl = Object.create(g.WebGLRenderingContext.prototype);
gl.__canvas = canvas;
gl.__state = state || {};
markObjectToString(gl, 'WebGLRenderingContext');
return gl;
}
function installCanvasReplay(g, fixture, options) {
const canvasFx = fixture.canvas || {};
const proto = g.HTMLCanvasElement.prototype;
const ctx2dProto = g.CanvasRenderingContext2D.prototype;
defineNativeValue(proto, 'getContext', function getContext(type) {
const kind = String(type || '').toLowerCase();
if (kind === '2d') return this.__ctx2d || (this.__ctx2d = create2DContext(g, this, { font: '10px sans-serif' }));
if (kind === 'webgl' || kind === 'experimental-webgl' || kind === 'webgl2') return this.__webgl || (this.__webgl = createWebGLContext(g, this, {}));
return null;
}, { name: 'getContext', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'toDataURL', function toDataURL(type = 'image/png', quality) {
const result = findReplay(canvasFx.toDataURL, {
api: 'canvas.toDataURL',
width: this.width,
height: this.height,
type,
quality,
stack: stackText(),
}, options);
return result === undefined ? '' : String(result);
}, { name: 'toDataURL', length: 0, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'toBlob', function toBlob(callback, type = 'image/png', quality) {
const result = findReplay(canvasFx.toBlob, {
api: 'canvas.toBlob',
width: this.width,
height: this.height,
type,
quality,
stack: stackText(),
}, { ...options, strict: options.strictToBlob ?? options.strict });
if (typeof callback === 'function') {
const blob = markObjectToString({ type, size: result && result.size ? Number(result.size) : 0, __replay: result }, 'Blob');
callback(blob);
}
}, { name: 'toBlob', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeGetter(ctx2dProto, 'font', function getFont() {
return this.__state && this.__state.font || '10px sans-serif';
}, { name: 'font', enumerable: true, configurable: true }, options.addon);
Object.defineProperty(ctx2dProto, 'font', {
get: Object.getOwnPropertyDescriptor(ctx2dProto, 'font').get,
set: function setFont(value) { this.__state.font = String(value); },
enumerable: true,
configurable: true,
});
defineNativeValue(ctx2dProto, 'measureText', function measureText(text) {
const result = findReplay(canvasFx.measureText, {
api: 'canvas.measureText',
text: String(text),
font: this.font,
stack: stackText(),
}, options) || { width: 0 };
return markObjectToString({ ...result }, 'TextMetrics');
}, { name: 'measureText', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(ctx2dProto, 'getImageData', function getImageData(sx, sy, sw, sh) {
const result = findReplay(canvasFx.getImageData, {
api: 'canvas.getImageData',
sx, sy, sw, sh,
stack: stackText(),
}, options);
return createImageData(result || { width: sw, height: sh });
}, { name: 'getImageData', length: 4, enumerable: true, configurable: true }, options.addon);
for (const name of ['fillText', 'strokeText', 'drawImage', 'fillRect', 'clearRect', 'rect', 'arc', 'beginPath', 'closePath', 'stroke', 'fill', 'putImageData']) {
if (!ctx2dProto[name]) {
defineNativeValue(ctx2dProto, name, function noop() {}, { name, length: 0, enumerable: true, configurable: true }, options.addon);
}
}
}
function installWebGLReplay(g, fixture, options) {
const webglFx = fixture.webgl || {};
const proto = g.WebGLRenderingContext.prototype;
defineNativeValue(proto, 'getParameter', function getParameter(pname) {
return findReplay(webglFx.getParameter, { api: 'webgl.getParameter', pname, stack: stackText() }, options);
}, { name: 'getParameter', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getSupportedExtensions', function getSupportedExtensions() {
const configured = webglFx.getSupportedExtensions;
if (configured && Array.isArray(configured.result)) return configured.result.slice();
return findReplay(webglFx.getSupportedExtensions, { api: 'webgl.getSupportedExtensions', stack: stackText() }, options) || [];
}, { name: 'getSupportedExtensions', length: 0, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getExtension', function getExtension(name) {
const configured = webglFx.getExtension;
const result = findReplay(configured, { api: 'webgl.getExtension', name: String(name), stack: stackText() }, { ...options, strict: false });
return result === undefined ? null : markObjectToString({ name: String(name), ...result }, String(name));
}, { name: 'getExtension', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getShaderPrecisionFormat', function getShaderPrecisionFormat(shaderType, precisionType) {
const result = findReplay(webglFx.getShaderPrecisionFormat, {
api: 'webgl.getShaderPrecisionFormat',
shaderType,
precisionType,
stack: stackText(),
}, options) || { rangeMin: 0, rangeMax: 0, precision: 0 };
return markObjectToString({ ...result }, 'WebGLShaderPrecisionFormat');
}, { name: 'getShaderPrecisionFormat', length: 2, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'readPixels', function readPixels(x, y, width, height, format, type, pixels) {
const result = findReplay(webglFx.readPixels, {
api: 'webgl.readPixels',
x, y, width, height, format, type,
stack: stackText(),
}, options);
if (pixels && result && result.dataBase64) {
const bytes = b64ToBytes(result.dataBase64);
for (let i = 0; i < Math.min(pixels.length, bytes.length); i++) pixels[i] = bytes[i];
}
}, { name: 'readPixels', length: 7, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getContextAttributes', function getContextAttributes() {
return { alpha: true, antialias: true, depth: true, desynchronized: false, failIfMajorPerformanceCaveat: false, powerPreference: 'default', premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: false, xrCompatible: false };
}, { name: 'getContextAttributes', length: 0, enumerable: true, configurable: true }, options.addon);
}
function installDomGeometryReplay(g, fixture, options) {
const domFx = fixture.domGeometry || {};
if (!g.Element) return;
const proto = g.Element.prototype;
function selectorOf(el) {
return el.__fingerprintSelector || el.id && `#${el.id}` || el.className && `.${String(el.className).split(/\s+/)[0]}` || String(el.tagName || '').toLowerCase();
}
defineNativeValue(proto, 'getBoundingClientRect', function getBoundingClientRect() {
const result = findReplay(domFx.getBoundingClientRect, {
api: 'dom.getBoundingClientRect',
selector: selectorOf(this),
stack: stackText(),
}, options) || {};
return new g.DOMRect(result.x || result.left || 0, result.y || result.top || 0, result.width || 0, result.height || 0);
}, { name: 'getBoundingClientRect', length: 0, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getClientRects', function getClientRects() {
const rect = this.getBoundingClientRect();
return markObjectToString({ 0: rect, length: 1, item(i) { return i === 0 ? rect : null; } }, 'DOMRectList');
}, { name: 'getClientRects', length: 0, enumerable: true, configurable: true }, options.addon);
for (const key of ['offsetWidth', 'offsetHeight', 'scrollWidth', 'scrollHeight', 'clientWidth', 'clientHeight']) {
defineNativeGetter(proto, key, function geometryGetter() {
const result = findReplay(domFx.offset, {
api: `dom.${key}`,
selector: selectorOf(this),
stack: stackText(),
}, { ...options, strict: false });
if (result && result[key] !== undefined) return Number(result[key]);
const rect = this.getBoundingClientRect();
return /height/i.test(key) ? Math.round(rect.height || 0) : Math.round(rect.width || 0);
}, { name: key, enumerable: true, configurable: true }, options.addon);
}
}
function createCanvasElement(globalObj, width = 300, height = 150) {
const g = globalObj || globalThis;
ensureConstructors(g, options);
const canvas = Object.create(g.HTMLCanvasElement.prototype);
defineValue(canvas, 'width', Number(width), { enumerable: true, writable: true, configurable: true });
defineValue(canvas, 'height', Number(height), { enumerable: true, writable: true, configurable: true });
markObjectToString(canvas, 'HTMLCanvasElement');
return canvas;
}
function installFingerprintValueReplay(globalObj, fixture = {}, options = {}) {
const g = globalObj || globalThis;
const replayOptions = { strict: true, ...options };
ensureConstructors(g, replayOptions);
installCanvasReplay(g, fixture, replayOptions);
installWebGLReplay(g, fixture, replayOptions);
installDomGeometryReplay(g, fixture, replayOptions);
if (!g.document) return { createCanvasElement: (w, h) => createCanvasElement(g, w, h) };
if (g.document && typeof g.document.createElement === 'function' && !g.document.__fingerprintCreateElementPatched) {
const rawCreateElement = g.document.createElement;
defineNativeValue(g.document, 'createElement', function createElement(tagName, optionsArg) {
const tag = String(tagName || '').toLowerCase();
if (tag === 'canvas') return createCanvasElement(g);
return rawCreateElement.call(this, tagName, optionsArg);
}, { name: 'createElement', length: 1, enumerable: true, configurable: true }, replayOptions.addon);
defineValue(g.document, '__fingerprintCreateElementPatched', true, { enumerable: false, configurable: true });
}
return { createCanvasElement: (w, h) => createCanvasElement(g, w, h) };
}
module.exports = {
installFingerprintValueReplay,
createCanvasElement,
findReplay,
};