forked from mirrors/gecko-dev
The purpose of this page is to improve the fingerprinting protections in Firefox. Differential Revision: https://phabricator.services.mozilla.com/D209599
676 lines
19 KiB
JavaScript
676 lines
19 KiB
JavaScript
// -*- 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";
|
|
import * as constants from "resource://gre/modules/RFPTargetConstants.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:fingerprintingprotection"],
|
|
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() {
|
|
/*
|
|
* It turns out that this triggers a warning that we're registering a Desktop-only actor
|
|
* in toolkit (which will also run on mobile.) It just happens this actor only handles
|
|
* letterboxing, which isn't used on mobile, but we should resolve this.
|
|
*/
|
|
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 }
|
|
);
|
|
}
|
|
|
|
getTargets() {
|
|
return constants.Targets;
|
|
}
|
|
|
|
getTargetDefaults() {
|
|
const key =
|
|
Services.appinfo.OS === "Android" ? "ANDROID_DEFAULT" : "DESKTOP_DEFAULT";
|
|
return constants.DefaultTargets[key];
|
|
}
|
|
}
|
|
|
|
export let RFPHelper = new _RFPHelper();
|