summaryrefslogtreecommitdiffstats
path: root/toolkit/components/resistfingerprinting/RFPHelper.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/resistfingerprinting/RFPHelper.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/resistfingerprinting/RFPHelper.sys.mjs')
-rw-r--r--toolkit/components/resistfingerprinting/RFPHelper.sys.mjs630
1 files changed, 630 insertions, 0 deletions
diff --git a/toolkit/components/resistfingerprinting/RFPHelper.sys.mjs b/toolkit/components/resistfingerprinting/RFPHelper.sys.mjs
new file mode 100644
index 0000000000..a085aa492a
--- /dev/null
+++ b/toolkit/components/resistfingerprinting/RFPHelper.sys.mjs
@@ -0,0 +1,630 @@
+// -*- 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 http://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.jsm",
+ 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.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 "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;
+ }
+ }
+
+ 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, data) {
+ // 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._registerActor();
+ this._attachAllWindows();
+ } else {
+ this._unregisterActor();
+ this._detachAllWindows();
+ Services.ww.unregisterNotification(this);
+ }
+ }
+
+ _registerActor() {
+ ChromeUtils.registerWindowActor("RFPHelper", {
+ parent: {
+ esModuleURI: "resource:///actors/RFPHelperParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource:///actors/RFPHelperChild.sys.mjs",
+ events: {
+ resize: {},
+ },
+ },
+ allFrames: true,
+ });
+ }
+
+ _unregisterActor() {
+ 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();