// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at https://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const kPrefResistFingerprinting = "privacy.resistFingerprinting"; const kPrefSpoofEnglish = "privacy.spoof_english"; const kTopicHttpOnModifyRequest = "http-on-modify-request"; const kPrefLetterboxing = "privacy.resistFingerprinting.letterboxing"; const kPrefLetterboxingDimensions = "privacy.resistFingerprinting.letterboxing.dimensions"; const kPrefLetterboxingTesting = "privacy.resistFingerprinting.letterboxing.testing"; const kTopicDOMWindowOpened = "domwindowopened"; var logConsole; function log(msg) { if (!logConsole) { logConsole = console.createInstance({ prefix: "RFPHelper", maxLogLevelPref: "privacy.resistFingerprinting.jsmloglevel", }); } logConsole.log(msg); } class _RFPHelper { // ============================================================================ // Shared Setup // ============================================================================ constructor() { this._initialized = false; } init() { if (this._initialized) { return; } this._initialized = true; // Add unconditional observers Services.obs.addObserver(this, "user-characteristics-populating-data"); Services.obs.addObserver(this, "user-characteristics-populating-data-done"); Services.prefs.addObserver(kPrefResistFingerprinting, this); Services.prefs.addObserver(kPrefLetterboxing, this); XPCOMUtils.defineLazyPreferenceGetter( this, "_letterboxingDimensions", kPrefLetterboxingDimensions, "", null, this._parseLetterboxingDimensions ); XPCOMUtils.defineLazyPreferenceGetter( this, "_isLetterboxingTesting", kPrefLetterboxingTesting, false ); // Add RFP and Letterboxing observers if prefs are enabled this._handleResistFingerprintingChanged(); this._handleLetterboxingPrefChanged(); } uninit() { if (!this._initialized) { return; } this._initialized = false; // Remove unconditional observers Services.prefs.removeObserver(kPrefResistFingerprinting, this); Services.prefs.removeObserver(kPrefLetterboxing, this); // Remove the RFP observers, swallowing exceptions if they weren't present this._removeRFPObservers(); } observe(subject, topic, data) { switch (topic) { case "user-characteristics-populating-data": this._registerUserCharacteristicsActor(); break; case "user-characteristics-populating-data-done": this._unregisterUserCharacteristicsActor(); break; case "nsPref:changed": this._handlePrefChanged(data); break; case kTopicHttpOnModifyRequest: this._handleHttpOnModifyRequest(subject, data); break; case kTopicDOMWindowOpened: // We attach to the newly created window by adding tabsProgressListener // and event listener on it. We listen for new tabs being added or // the change of the content principal and apply margins accordingly. this._handleDOMWindowOpened(subject); break; default: break; } } _registerUserCharacteristicsActor() { log("_registerUserCharacteristicsActor()"); ChromeUtils.registerWindowActor("UserCharacteristics", { parent: { esModuleURI: "resource://gre/actors/UserCharacteristicsParent.sys.mjs", }, child: { esModuleURI: "resource://gre/actors/UserCharacteristicsChild.sys.mjs", events: { UserCharacteristicsDataDone: { wantUntrusted: true }, }, }, matches: ["about:fingerprinting"], remoteTypes: ["privilegedabout"], }); } _unregisterUserCharacteristicsActor() { log("_unregisterUserCharacteristicsActor()"); ChromeUtils.unregisterWindowActor("UserCharacteristics"); } handleEvent(aMessage) { switch (aMessage.type) { case "TabOpen": { let tab = aMessage.target; this._addOrClearContentMargin(tab.linkedBrowser); break; } default: break; } } _handlePrefChanged(data) { switch (data) { case kPrefResistFingerprinting: this._handleResistFingerprintingChanged(); break; case kPrefSpoofEnglish: this._handleSpoofEnglishChanged(); break; case kPrefLetterboxing: this._handleLetterboxingPrefChanged(); break; default: break; } } contentSizeUpdated(win) { this._updateMarginsForTabsInWindow(win); } // ============================================================================ // Language Prompt // ============================================================================ _addRFPObservers() { Services.prefs.addObserver(kPrefSpoofEnglish, this); if (this._shouldPromptForLanguagePref()) { Services.obs.addObserver(this, kTopicHttpOnModifyRequest); } } _removeRFPObservers() { try { Services.prefs.removeObserver(kPrefSpoofEnglish, this); } catch (e) { // do nothing } try { Services.obs.removeObserver(this, kTopicHttpOnModifyRequest); } catch (e) { // do nothing } } _handleResistFingerprintingChanged() { if (Services.prefs.getBoolPref(kPrefResistFingerprinting)) { this._addRFPObservers(); } else { this._removeRFPObservers(); } } _handleSpoofEnglishChanged() { switch (Services.prefs.getIntPref(kPrefSpoofEnglish)) { case 0: // will prompt // This should only happen when turning privacy.resistFingerprinting off. // Works like disabling accept-language spoofing. // fall through case 1: // don't spoof // We don't reset intl.accept_languages. Instead, setting // privacy.spoof_english to 1 allows user to change preferred language // settings through Preferences UI. break; case 2: // spoof Services.prefs.setCharPref("intl.accept_languages", "en-US, en"); break; default: break; } } _shouldPromptForLanguagePref() { return ( Services.locale.appLocaleAsBCP47.substr(0, 2) !== "en" && Services.prefs.getIntPref(kPrefSpoofEnglish) === 0 ); } _handleHttpOnModifyRequest(subject) { // If we are loading an HTTP page from content, show the // "request English language web pages?" prompt. let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel); let notificationCallbacks = httpChannel.notificationCallbacks; if (!notificationCallbacks) { return; } let loadContext = notificationCallbacks.getInterface(Ci.nsILoadContext); if (!loadContext || !loadContext.isContent) { return; } if (!subject.URI.schemeIs("http") && !subject.URI.schemeIs("https")) { return; } // The above QI did not throw, the scheme is http[s], and we know the // load context is content, so we must have a true HTTP request from content. // Stop the observer and display the prompt if another window has // not already done so. Services.obs.removeObserver(this, kTopicHttpOnModifyRequest); if (!this._shouldPromptForLanguagePref()) { return; } this._promptForLanguagePreference(); // The Accept-Language header for this request was set when the // channel was created. Reset it to match the value that will be // used for future requests. let val = this._getCurrentAcceptLanguageValue(subject.URI); if (val) { httpChannel.setRequestHeader("Accept-Language", val, false); } } _promptForLanguagePreference() { // Display two buttons, both with string titles. const l10n = new Localization( ["toolkit/global/resistFingerPrinting.ftl"], true ); const message = l10n.formatValueSync("privacy-spoof-english"); const flags = Services.prompt.STD_YES_NO_BUTTONS; const response = Services.prompt.confirmEx( null, "", message, flags, null, null, null, null, { value: false } ); // Update preferences to reflect their response and to prevent the prompt // from being displayed again. Services.prefs.setIntPref(kPrefSpoofEnglish, response == 0 ? 2 : 1); } _getCurrentAcceptLanguageValue(uri) { let channel = Services.io.newChannelFromURI( uri, null, // aLoadingNode Services.scriptSecurityManager.getSystemPrincipal(), null, // aTriggeringPrincipal Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, Ci.nsIContentPolicy.TYPE_OTHER ); let httpChannel; try { httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); } catch (e) { return null; } return httpChannel.getRequestHeader("Accept-Language"); } // ============================================================================== // Letterboxing // ============================================================================ /** * We use the TabsProgressListener to catch the change of the content * principal. We would clear the margins around the content viewport if * it is the system principal. */ onLocationChange(aBrowser) { this._addOrClearContentMargin(aBrowser); } _handleLetterboxingPrefChanged() { if (Services.prefs.getBoolPref(kPrefLetterboxing, false)) { Services.ww.registerNotification(this); this._registerLetterboxingActor(); this._attachAllWindows(); } else { this._unregisterLetterboxingActor(); this._detachAllWindows(); Services.ww.unregisterNotification(this); } } _registerLetterboxingActor() { ChromeUtils.registerWindowActor("RFPHelper", { parent: { esModuleURI: "resource:///actors/RFPHelperParent.sys.mjs", }, child: { esModuleURI: "resource:///actors/RFPHelperChild.sys.mjs", events: { resize: {}, }, }, allFrames: true, }); } _unregisterLetterboxingActor() { ChromeUtils.unregisterWindowActor("RFPHelper"); } // The function to parse the dimension set from the pref value. The pref value // should be formated as 'width1xheight1, width2xheight2, ...'. For // example, '100x100, 200x200, 400x200 ...'. _parseLetterboxingDimensions(aPrefValue) { if (!aPrefValue || !aPrefValue.match(/^(?:\d+x\d+,\s*)*(?:\d+x\d+)$/)) { if (aPrefValue) { console.error( `Invalid pref value for ${kPrefLetterboxingDimensions}: ${aPrefValue}` ); } return []; } return aPrefValue.split(",").map(item => { let sizes = item.split("x").map(size => parseInt(size, 10)); return { width: sizes[0], height: sizes[1], }; }); } _addOrClearContentMargin(aBrowser) { let tab = aBrowser.getTabBrowser().getTabForBrowser(aBrowser); // We won't do anything for lazy browsers. if (!aBrowser.isConnected) { return; } // We should apply no margin around an empty tab or a tab with system // principal. if (tab.isEmpty || aBrowser.contentPrincipal.isSystemPrincipal) { this._clearContentViewMargin(aBrowser); } else { this._roundContentView(aBrowser); } } /** * Given a width or height, returns the appropriate margin to apply. */ steppedRange(aDimension) { let stepping; if (aDimension <= 50) { return 0; } else if (aDimension <= 500) { stepping = 50; } else if (aDimension <= 1600) { stepping = 100; } else { stepping = 200; } return (aDimension % stepping) / 2; } /** * The function will round the given browser by adding margins around the * content viewport. */ async _roundContentView(aBrowser) { let logId = Math.random(); log("_roundContentView[" + logId + "]"); let win = aBrowser.ownerGlobal; let browserContainer = aBrowser .getTabBrowser() .getBrowserContainer(aBrowser); let { contentWidth, contentHeight, containerWidth, containerHeight } = await win.promiseDocumentFlushed(() => { let contentWidth = aBrowser.clientWidth; let contentHeight = aBrowser.clientHeight; let containerWidth = browserContainer.clientWidth; let containerHeight = browserContainer.clientHeight; // If the findbar or devtools are out, we need to subtract their height (plus 1 // for the separator) from the container height, because we need to adjust our // letterboxing to account for it; however it is not included in that dimension // (but rather is subtracted from the content height.) let findBar = win.gFindBarInitialized ? win.gFindBar : undefined; let findBarOffset = findBar && !findBar.hidden ? findBar.clientHeight + 1 : 0; let devtools = browserContainer.getElementsByClassName( "devtools-toolbox-bottom-iframe" ); let devtoolsOffset = devtools.length ? devtools[0].clientHeight : 0; return { contentWidth, contentHeight, containerWidth, containerHeight: containerHeight - findBarOffset - devtoolsOffset, }; }); log( "_roundContentView[" + logId + "] contentWidth=" + contentWidth + " contentHeight=" + contentHeight + " containerWidth=" + containerWidth + " containerHeight=" + containerHeight + " " ); let calcMargins = (aWidth, aHeight) => { let result; log( "_roundContentView[" + logId + "] calcMargins(" + aWidth + ", " + aHeight + ")" ); // If the set is empty, we will round the content with the default // stepping size. if (!this._letterboxingDimensions.length) { result = { width: this.steppedRange(aWidth), height: this.steppedRange(aHeight), }; log( "_roundContentView[" + logId + "] calcMargins(" + aWidth + ", " + aHeight + ") = " + result.width + " x " + result.height ); return result; } let matchingArea = aWidth * aHeight; let minWaste = Number.MAX_SAFE_INTEGER; let targetDimensions = undefined; // Find the desired dimensions which waste the least content area. for (let dim of this._letterboxingDimensions) { // We don't need to consider the dimensions which cannot fit into the // real content size. if (dim.width > aWidth || dim.height > aHeight) { continue; } let waste = matchingArea - dim.width * dim.height; if (waste >= 0 && waste < minWaste) { targetDimensions = dim; minWaste = waste; } } // If we cannot find any dimensions match to the real content window, this // means the content area is smaller the smallest size in the set. In this // case, we won't apply any margins. if (!targetDimensions) { result = { width: 0, height: 0, }; } else { result = { width: (aWidth - targetDimensions.width) / 2, height: (aHeight - targetDimensions.height) / 2, }; } log( "_roundContentView[" + logId + "] calcMargins(" + aWidth + ", " + aHeight + ") = " + result.width + " x " + result.height ); return result; }; // Calculating the margins around the browser element in order to round the // content viewport. We will use a 200x100 stepping if the dimension set // is not given. let margins = calcMargins(containerWidth, containerHeight); // If the size of the content is already quantized, we do nothing. if (aBrowser.style.margin == `${margins.height}px ${margins.width}px`) { log("_roundContentView[" + logId + "] is_rounded == true"); if (this._isLetterboxingTesting) { log( "_roundContentView[" + logId + "] is_rounded == true test:letterboxing:update-margin-finish" ); Services.obs.notifyObservers( null, "test:letterboxing:update-margin-finish" ); } return; } win.requestAnimationFrame(() => { log( "_roundContentView[" + logId + "] setting margins to " + margins.width + " x " + margins.height ); // One cannot (easily) control the color of a margin unfortunately. // An initial attempt to use a border instead of a margin resulted // in offset event dispatching; so for now we use a colorless margin. aBrowser.style.margin = `${margins.height}px ${margins.width}px`; }); } _clearContentViewMargin(aBrowser) { aBrowser.ownerGlobal.requestAnimationFrame(() => { aBrowser.style.margin = ""; }); } _updateMarginsForTabsInWindow(aWindow) { let tabBrowser = aWindow.gBrowser; for (let tab of tabBrowser.tabs) { let browser = tab.linkedBrowser; this._addOrClearContentMargin(browser); } } _attachWindow(aWindow) { aWindow.gBrowser.addTabsProgressListener(this); aWindow.addEventListener("TabOpen", this); // Rounding the content viewport. this._updateMarginsForTabsInWindow(aWindow); } _attachAllWindows() { let windowList = Services.wm.getEnumerator("navigator:browser"); while (windowList.hasMoreElements()) { let win = windowList.getNext(); if (win.closed || !win.gBrowser) { continue; } this._attachWindow(win); } } _detachWindow(aWindow) { let tabBrowser = aWindow.gBrowser; tabBrowser.removeTabsProgressListener(this); aWindow.removeEventListener("TabOpen", this); // Clear all margins and tooltip for all browsers. for (let tab of tabBrowser.tabs) { let browser = tab.linkedBrowser; this._clearContentViewMargin(browser); } } _detachAllWindows() { let windowList = Services.wm.getEnumerator("navigator:browser"); while (windowList.hasMoreElements()) { let win = windowList.getNext(); if (win.closed || !win.gBrowser) { continue; } this._detachWindow(win); } } _handleDOMWindowOpened(win) { let self = this; win.addEventListener( "load", () => { // We attach to the new window when it has been loaded if the new loaded // window is a browsing window. if ( win.document.documentElement.getAttribute("windowtype") !== "navigator:browser" ) { return; } self._attachWindow(win); }, { once: true } ); } } export let RFPHelper = new _RFPHelper();