Anti Font Fingerprinting (Limited Probes)

Stops fingerprinting scripts from testing 3000+ fonts by faking layout results after 200 probes. Includes metric spoofing and font stability per session.

// ==UserScript==
// @name         Anti Font Fingerprinting (Limited Probes)
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Stops fingerprinting scripts from testing 3000+ fonts by faking layout results after 200 probes. Includes metric spoofing and font stability per session.
// @author       Atom
// @match        *://*/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // Exclude trusted domains if needed
    const excludeSites = [
        "gmail.com", "outlook.com", "notion.so", "office.com", "figma.com"
    ];
    const currentHost = location.hostname;
    if (excludeSites.some(site => currentHost.includes(site))) return;

    const randomOffset = () => (Math.random() - 0.5) * 2;

    const MAX_FONT_PROBES = 200;
    let fontProbeCount = 0;

    // Monkey patch getBoundingClientRect to limit effectiveness of font testing
    const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
    Element.prototype.getBoundingClientRect = function(...args) {
        const tag = this.tagName.toLowerCase();
        const font = this.style?.fontFamily?.toLowerCase?.() || "";
        const isHidden = this.offsetParent === null || this.offsetWidth === 0;
        const likelyProbe = tag === "span" && font && isHidden;

        const rect = originalGetBoundingClientRect.apply(this, args);
        if (likelyProbe) {
            fontProbeCount++;
            if (fontProbeCount > MAX_FONT_PROBES) {
                // Return fake, constant rect to break detection
                return {
                    ...rect,
                    width: 100,
                    height: 10,
                    top: 0, left: 0, bottom: 10, right: 100,
                    x: 0, y: 0,
                    toJSON: rect.toJSON.bind(rect)
                };
            }
        }
        // Otherwise slightly randomize
        return {
            ...rect,
            width: rect.width + randomOffset(),
            height: rect.height + randomOffset(),
            x: rect.x + randomOffset(),
            y: rect.y + randomOffset(),
            top: rect.top + randomOffset(),
            left: rect.left + randomOffset(),
            right: rect.right + randomOffset(),
            bottom: rect.bottom + randomOffset(),
            toJSON: rect.toJSON.bind(rect)
        };
    };

    // Same for offsetWidth / offsetHeight
    const patchOffset = (prop) => {
        const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop);
        Object.defineProperty(HTMLElement.prototype, prop, {
            get: function() {
                const font = this.style?.fontFamily?.toLowerCase?.() || "";
                const tag = this.tagName.toLowerCase();
                const isHidden = this.offsetParent === null || descriptor.get.call(this) === 0;
                const likelyProbe = tag === "span" && font && isHidden;

                if (likelyProbe) {
                    fontProbeCount++;
                    if (fontProbeCount > MAX_FONT_PROBES) {
                        return prop.includes("Width") ? 100 : 10;
                    }
                }
                return descriptor.get.call(this) + randomOffset();
            },
            configurable: true
        });
    };

    patchOffset("offsetWidth");
    patchOffset("offsetHeight");

    // Patch Canvas measureText
    const originalMeasureText = CanvasRenderingContext2D.prototype.measureText;
    CanvasRenderingContext2D.prototype.measureText = function(text) {
        const metrics = originalMeasureText.apply(this, arguments);
        return {
            ...metrics,
            width: metrics.width + randomOffset(),
            actualBoundingBoxLeft: metrics.actualBoundingBoxLeft + randomOffset(),
            actualBoundingBoxRight: metrics.actualBoundingBoxRight + randomOffset(),
            actualBoundingBoxAscent: metrics.actualBoundingBoxAscent + randomOffset(),
            actualBoundingBoxDescent: metrics.actualBoundingBoxDescent + randomOffset()
        };
    };

    // Optional: Hide document.fonts completely (not used by browserleaks, but useful elsewhere)
    Object.defineProperty(document, 'fonts', {
        get: () => ({
            forEach: () => {},
            ready: Promise.resolve(),
            add: () => {},
            delete: () => {},
            clear: () => {},
            size: 0
        }),
        configurable: true
    });

    console.log("[Anti-Fingerprint] Font probe limiter active. Max probes:", MAX_FONT_PROBES);
})();