diff --git a/browser/components/resistfingerprinting/test/browser/browser.ini b/browser/components/resistfingerprinting/test/browser/browser.ini index 024ee29907b4..1aa918b4574b 100644 --- a/browser/components/resistfingerprinting/test/browser/browser.ini +++ b/browser/components/resistfingerprinting/test/browser/browser.ini @@ -11,6 +11,7 @@ support-files = head.js [browser_block_mozAddonManager.js] +[browser_dynamical_window_rounding.js] [browser_navigator.js] [browser_netInfo.js] [browser_performanceAPI.js] diff --git a/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js b/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js new file mode 100644 index 000000000000..ea261b7820d7 --- /dev/null +++ b/browser/components/resistfingerprinting/test/browser/browser_dynamical_window_rounding.js @@ -0,0 +1,277 @@ +/* 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/. + * + * Bug 1407366 - A test case for reassuring the size of the content viewport is + * rounded if the window is resized when letterboxing is enabled. + */ + +const TEST_PATH = "http://example.net/browser/browser/components/resistfingerprinting/test/browser/"; + +const DEFAULT_ROUNDED_WIDTH_STEP = 200; +const DEFAULT_ROUNDED_HEIGHT_STEP = 100; + +// A set of test cases which defines the width and the height of the outer window. +const TEST_CASES = [ + {width: 1250, height: 1000}, + {width: 1500, height: 1050}, + {width: 1120, height: 760}, + {width: 800, height: 600}, + {width: 640, height: 400}, + {width: 500, height: 350}, + {width: 300, height: 170}, +]; + +function getPlatform() { + const {OS} = Services.appinfo; + if (OS == "WINNT") { + return "win"; + } else if (OS == "Darwin") { + return "mac"; + } + return "linux"; +} + +function handleOSFuzziness(aContent, aTarget) { + /* + * On Windows, we observed off-by-one pixel differences that + * couldn't be expained. When manually setting the window size + * to try to reproduce it; it did not occur. + */ + if (getPlatform() == "win") { + return Math.abs(aContent - aTarget) <= 1; + } + return aContent == aTarget; +} + +function checkForDefaultSetting( + aContentWidth, aContentHeight, aRealWidth, aRealHeight) { + // The default behavior for rounding is to round window with 200x100 stepping. + // So, we can get the rounded size by subtracting the remainder. + let targetWidth = aRealWidth - (aRealWidth % DEFAULT_ROUNDED_WIDTH_STEP); + let targetHeight = aRealHeight - (aRealHeight % DEFAULT_ROUNDED_HEIGHT_STEP); + + // This platform-specific code is explained in the large comment below. + if (getPlatform() != "linux") { + ok(handleOSFuzziness(aContentWidth, targetWidth), + `Default Dimensions: The content window width is correctly rounded into. ${aRealWidth}px -> ${aContentWidth}px should equal ${targetWidth}px`); + + ok(handleOSFuzziness(aContentHeight, targetHeight), + `Default Dimensions: The content window height is correctly rounded into. ${aRealHeight}px -> ${aContentHeight}px should equal ${targetHeight}px`); + + // Using ok() above will cause Win/Mac to fail on even the first test, we don't need to repeat it, return true so waitForCondition ends + return true; + } + // Returning true or false depending on if the test succeeded will cause Linux to repeat until it succeeds. + return handleOSFuzziness(aContentWidth, targetWidth) && handleOSFuzziness(aContentHeight, targetHeight); +} + +async function test_dynamical_window_rounding(aWindow, aCheckFunc) { + // We need to wait for the updating the margins for the newly opened tab, or + // it will affect the following tests. + let promiseForTheFirstRounding = + TestUtils.topicObserved("test:letterboxing:update-margin-finish"); + + info("Open a content tab for testing."); + let tab = await BrowserTestUtils.openNewForegroundTab( + aWindow.gBrowser, TEST_PATH + "file_dummy.html"); + + info("Wait until the margins are applied for the opened tab."); + await promiseForTheFirstRounding; + + let getContainerSize = (aTab) => { + let browserContainer = aWindow.gBrowser + .getBrowserContainer(aTab.linkedBrowser); + return { + containerWidth: browserContainer.clientWidth, + containerHeight: browserContainer.clientHeight, + }; + }; + + for (let {width, height} of TEST_CASES) { + let caseString = "Case " + width + "x" + height + ": "; + // Create a promise for waiting for the margin update. + let promiseRounding = + TestUtils.topicObserved("test:letterboxing:update-margin-finish"); + + let {containerWidth, containerHeight} = getContainerSize(tab); + + info(caseString + "Resize the window and wait until resize event happened (currently " + + containerWidth + "x" + containerHeight + ")"); + await new Promise(resolve => { + ({containerWidth, containerHeight} = getContainerSize(tab)); + info(caseString + "Resizing (currently " + containerWidth + "x" + containerHeight + ")"); + + aWindow.onresize = () => { + ({containerWidth, containerHeight} = getContainerSize(tab)); + info(caseString + "Resized (currently " + containerWidth + "x" + containerHeight + ")"); + if (getPlatform() == "linux" && containerWidth != width) { + /* + * We observed frequent test failures that resulted from receiving an onresize + * event where the browser was resized to an earlier requested dimension. This + * resize event happens on Linux only, and is an artifact of the asynchronous + * resizing. (See more discussion on 1407366#53) + * + * We cope with this problem in two ways. + * + * 1: If we detect that the browser was resized to the wrong value; we + * redo the resize. (This is the lines of code immediately following this + * comment) + * 2: We repeat the test until it works using waitForCondition(). But we still + * test Win/Mac more thoroughly: they do not loop in waitForCondition more + * than once, and can fail the test on the first attempt (because their + * check() functions use ok() while on Linux, we do not all ok() and instead + * rely on waitForCondition to fail). + * + * The logging statements in this test, and RFPHelper.jsm, help narrow down and + * illustrate the issue. + */ + info(caseString + "We hit the weird resize bug. Resize it again."); + aWindow.resizeTo(width, height); + } else { + resolve(); + } + }; + aWindow.resizeTo(width, height); + }); + + ({containerWidth, containerHeight} = getContainerSize(tab)); + info(caseString + "Waiting until margin has been updated on browser element. (currently " + + containerWidth + "x" + containerHeight + ")"); + await promiseRounding; + + info(caseString + "Get innerWidth/Height from the content."); + await BrowserTestUtils.waitForCondition(async () => { + let {contentWidth, contentHeight} = await ContentTask.spawn( + tab.linkedBrowser, null, () => { + return { + contentWidth: content.innerWidth, + contentHeight: content.innerHeight, + }; + }); + + info(caseString + "Check the result."); + return aCheckFunc(contentWidth, contentHeight, containerWidth, containerHeight); + }, "Default Dimensions: The content window width is correctly rounded into."); + } + + BrowserTestUtils.removeTab(tab); +} + +async function test_customize_width_and_height(aWindow) { + const test_dimensions = `120x80, 200x143, 335x255, 600x312, 742x447, 813x558, + 990x672, 1200x733, 1470x858`; + + await SpecialPowers.pushPrefEnv({"set": + [ + ["privacy.resistFingerprinting.letterboxing.dimensions", test_dimensions], + ], + }); + + let dimensions_set = test_dimensions.split(",").map(item => { + let sizes = item.split("x").map(size => parseInt(size, 10)); + + return { + width: sizes[0], + height: sizes[1], + }; + }); + + let checkDimension = + (aContentWidth, aContentHeight, aRealWidth, aRealHeight) => { + let matchingArea = aRealWidth * aRealHeight; + let minWaste = Number.MAX_SAFE_INTEGER; + let targetDimensions = undefined; + + // Find the dimensions which waste the least content area. + for (let dim of dimensions_set) { + if (dim.width > aRealWidth || dim.height > aRealHeight) { + continue; + } + + let waste = matchingArea - dim.width * dim.height; + + if (waste >= 0 && waste < minWaste) { + targetDimensions = dim; + minWaste = waste; + } + } + + // This platform-specific code is explained in the large comment above. + if (getPlatform() != "linux") { + ok(handleOSFuzziness(aContentWidth, targetDimensions.width), + `Custom Dimension: The content window width is correctly rounded into. ${aRealWidth}px -> ${aContentWidth}px should equal ${targetDimensions.width}`); + + ok(handleOSFuzziness(aContentHeight, targetDimensions.height), + `Custom Dimension: The content window height is correctly rounded into. ${aRealHeight}px -> ${aContentHeight}px should equal ${targetDimensions.height}`); + + // Using ok() above will cause Win/Mac to fail on even the first test, we don't need to repeat it, return true so waitForCondition ends + return true; + } + // Returning true or false depending on if the test succeeded will cause Linux to repeat until it succeeds. + return handleOSFuzziness(aContentWidth, targetDimensions.width) && handleOSFuzziness(aContentHeight, targetDimensions.height); + }; + + await test_dynamical_window_rounding(aWindow, checkDimension); + + await SpecialPowers.popPrefEnv(); +} + +async function test_no_rounding_for_chrome(aWindow) { + // First, resize the window to a size with is not rounded. + await new Promise(resolve => { + aWindow.onresize = () => resolve(); + aWindow.resizeTo(700, 450); + }); + + // open a chrome privilege tab, like about:config. + let tab = await BrowserTestUtils.openNewForegroundTab( + aWindow.gBrowser, "about:config"); + + // Check that the browser element should not have a margin. + is(tab.linkedBrowser.style.margin, "", "There is no margin around chrome tab."); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": + [ + ["privacy.resistFingerprinting.letterboxing", true], + ["privacy.resistFingerprinting.letterboxing.testing", true], + ], + }); +}); + +add_task(async function do_tests() { + // Store the original window size before testing. + let originalOuterWidth = window.outerWidth; + let originalOuterHeight = window.outerHeight; + + info("Run test for the default window rounding."); + await test_dynamical_window_rounding(window, checkForDefaultSetting); + + info("Run test for the window rounding with customized dimensions."); + await test_customize_width_and_height(window); + + info("Run test for no margin around tab with the chrome privilege."); + await test_no_rounding_for_chrome(window); + + // Restore the original window size. + window.outerWidth = originalOuterWidth; + window.outerHeight = originalOuterHeight; + + // Testing that whether the dynamical rounding works for new windows. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Run test for the default window rounding in new window."); + await test_dynamical_window_rounding(win, checkForDefaultSetting); + + info("Run test for the window rounding with customized dimensions in new window."); + await test_customize_width_and_height(win); + + info("Run test for no margin around tab with the chrome privilege in new window."); + await test_no_rounding_for_chrome(win); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js index 471e844a1dbe..1ec585d0bd1c 100644 --- a/modules/libpref/init/all.js +++ b/modules/libpref/init/all.js @@ -1442,6 +1442,9 @@ pref("privacy.firstparty.isolate.restrict_opener_access", true); // If you do set it, to work around some broken website, please file a bug with // information so we can understand why it is needed. pref("privacy.resistFingerprinting.autoDeclineNoUserInputCanvasPrompts", true); +// The log level for browser console messages logged in RFPHelper.jsm +// Change to 'All' and restart to see the messages +pref("privacy.resistFingerprinting.jsmloglevel", "Warn"); // A subset of Resist Fingerprinting protections focused specifically on timers for testing // This affects the Animation API, the performance APIs, Date.getTime, Event.timestamp, // File.lastModified, audioContext.currentTime, canvas.captureStream.currentTime diff --git a/toolkit/components/resistfingerprinting/RFPHelper.jsm b/toolkit/components/resistfingerprinting/RFPHelper.jsm index 6d7182f69854..010443f6b33b 100644 --- a/toolkit/components/resistfingerprinting/RFPHelper.jsm +++ b/toolkit/components/resistfingerprinting/RFPHelper.jsm @@ -16,12 +16,26 @@ 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"; const kEventLetterboxingSizeUpdate = "Letterboxing:ContentSizeUpdated"; const kDefaultWidthStepping = 200; const kDefaultHeightStepping = 100; +var logConsole; +function log(msg) { + if (!logConsole) { + logConsole = console.createInstance({ + prefix: "RFPHelper.jsm", + maxLogLevelPref: "privacy.resistFingerprinting.jsmloglevel", + }); + } + + logConsole.log(msg); +} + class _RFPHelper { // ============================================================================ // Shared Setup @@ -41,6 +55,8 @@ class _RFPHelper { 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(); @@ -326,6 +342,8 @@ class _RFPHelper { * content viewport. */ async _roundContentView(aBrowser) { + let logId = Math.random(); + log("_roundContentView[" + logId + "]"); let win = aBrowser.ownerGlobal; let browserContainer = aBrowser.getTabBrowser() .getBrowserContainer(aBrowser); @@ -345,14 +363,21 @@ class _RFPHelper { }; }); + 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) { - return { + result = { width: (aWidth % kDefaultWidthStepping) / 2, height: (aHeight % kDefaultHeightStepping) / 2, }; + log("_roundContentView[" + logId + "] calcMargins(" + aWidth + ", " + aHeight + ") = " + result.width + " x " + result.height); + return result; } let matchingArea = aWidth * aHeight; @@ -375,7 +400,6 @@ class _RFPHelper { } } - let result; // 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. @@ -391,6 +415,7 @@ class _RFPHelper { }; } + log("_roundContentView[" + logId + "] calcMargins(" + aWidth + ", " + aHeight + ") = " + result.width + " x " + result.height); return result; }; @@ -401,10 +426,16 @@ class _RFPHelper { // 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.