diff options
Diffstat (limited to '')
27 files changed, 4237 insertions, 0 deletions
diff --git a/browser/components/doh/DoHConfig.sys.mjs b/browser/components/doh/DoHConfig.sys.mjs new file mode 100644 index 0000000000..a8736bdc2e --- /dev/null +++ b/browser/components/doh/DoHConfig.sys.mjs @@ -0,0 +1,342 @@ +/* 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/. */ + +/* + * This module provides an interface to access DoH configuration - e.g. whether + * DoH is enabled, whether capabilities are enabled, etc. The configuration is + * sourced from either Remote Settings or pref values, with Remote Settings + * being preferred. + */ + +import { RemoteSettings } from "resource://services-settings/remote-settings.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", +}); + +const kGlobalPrefBranch = "doh-rollout"; +var kRegionPrefBranch; + +const kConfigPrefs = { + kEnabledPref: "enabled", + kProvidersPref: "provider-list", + kTRRSelectionEnabledPref: "trr-selection.enabled", + kTRRSelectionProvidersPref: "trr-selection.provider-list", + kTRRSelectionCommitResultPref: "trr-selection.commit-result", + kProviderSteeringEnabledPref: "provider-steering.enabled", + kProviderSteeringListPref: "provider-steering.provider-list", +}; + +const kPrefChangedTopic = "nsPref:changed"; + +const gProvidersCollection = RemoteSettings("doh-providers"); +const gConfigCollection = RemoteSettings("doh-config"); + +function getPrefValueRegionFirst(prefName) { + let regionalPrefName = `${kRegionPrefBranch}.${prefName}`; + let regionalPrefValue = lazy.Preferences.get(regionalPrefName); + if (regionalPrefValue !== undefined) { + return regionalPrefValue; + } + return lazy.Preferences.get(`${kGlobalPrefBranch}.${prefName}`); +} + +function getProviderListFromPref(prefName) { + let prefVal = getPrefValueRegionFirst(prefName); + if (prefVal) { + try { + return JSON.parse(prefVal); + } catch (e) { + console.error(`DoH provider list not a valid JSON array: ${prefName}`); + } + } + return undefined; +} + +// Generate a base config object with getters that return pref values. When +// Remote Settings values become available, a new config object will be +// generated from this and specific fields will be replaced by the RS value. +// If we use a class to store base config and instantiate new config objects +// from it, we lose the ability to override getters because they are defined +// as non-configureable properties on class instances. So just use a function. +function makeBaseConfigObject() { + function makeConfigProperty({ + obj, + propName, + defaultVal, + prefName, + isProviderList, + }) { + let prefFn = isProviderList + ? getProviderListFromPref + : getPrefValueRegionFirst; + + let overridePropName = "_" + propName; + + Object.defineProperty(obj, propName, { + get() { + // If a pref value exists, it gets top priority. Otherwise, if it has an + // explicitly set value (from Remote Settings), we return that. + let prefVal = prefFn(prefName); + if (prefVal !== undefined) { + return prefVal; + } + if (this[overridePropName] !== undefined) { + return this[overridePropName]; + } + return defaultVal; + }, + set(val) { + this[overridePropName] = val; + }, + }); + } + let newConfig = { + get fallbackProviderURI() { + return this.providerList[0]?.uri; + }, + trrSelection: {}, + providerSteering: {}, + }; + makeConfigProperty({ + obj: newConfig, + propName: "enabled", + defaultVal: false, + prefName: kConfigPrefs.kEnabledPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig, + propName: "providerList", + defaultVal: [], + prefName: kConfigPrefs.kProvidersPref, + isProviderList: true, + }); + makeConfigProperty({ + obj: newConfig.trrSelection, + propName: "enabled", + defaultVal: false, + prefName: kConfigPrefs.kTRRSelectionEnabledPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig.trrSelection, + propName: "commitResult", + defaultVal: false, + prefName: kConfigPrefs.kTRRSelectionCommitResultPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig.trrSelection, + propName: "providerList", + defaultVal: [], + prefName: kConfigPrefs.kTRRSelectionProvidersPref, + isProviderList: true, + }); + makeConfigProperty({ + obj: newConfig.providerSteering, + propName: "enabled", + defaultVal: false, + prefName: kConfigPrefs.kProviderSteeringEnabledPref, + isProviderList: false, + }); + makeConfigProperty({ + obj: newConfig.providerSteering, + propName: "providerList", + defaultVal: [], + prefName: kConfigPrefs.kProviderSteeringListPref, + isProviderList: true, + }); + return newConfig; +} + +export const DoHConfigController = { + initComplete: null, + _resolveInitComplete: null, + + // This field always contains the current config state, for + // consumer use. + currentConfig: makeBaseConfigObject(), + + // Loads the client's region via Region.sys.mjs. This might mean waiting + // until the region is available. + async loadRegion() { + await new Promise(resolve => { + let homeRegion = lazy.Preferences.get(`${kGlobalPrefBranch}.home-region`); + if (homeRegion) { + kRegionPrefBranch = `${kGlobalPrefBranch}.${homeRegion.toLowerCase()}`; + resolve(); + return; + } + + let updateRegionAndResolve = () => { + kRegionPrefBranch = `${kGlobalPrefBranch}.${lazy.Region.home.toLowerCase()}`; + lazy.Preferences.set( + `${kGlobalPrefBranch}.home-region`, + lazy.Region.home + ); + resolve(); + }; + + if (lazy.Region.home) { + updateRegionAndResolve(); + return; + } + + Services.obs.addObserver(function obs(sub, top, data) { + Services.obs.removeObserver(obs, lazy.Region.REGION_TOPIC); + updateRegionAndResolve(); + }, lazy.Region.REGION_TOPIC); + }); + + // Finally, reload config. + await this.updateFromRemoteSettings(); + }, + + async init() { + await this.loadRegion(); + + Services.prefs.addObserver(`${kGlobalPrefBranch}.`, this, true); + + gProvidersCollection.on("sync", this.updateFromRemoteSettings); + gConfigCollection.on("sync", this.updateFromRemoteSettings); + + this._resolveInitComplete(); + }, + + // Useful for tests to set prior state before init() + async _uninit() { + await this.initComplete; + + Services.prefs.removeObserver(`${kGlobalPrefBranch}`, this); + + gProvidersCollection.off("sync", this.updateFromRemoteSettings); + gConfigCollection.off("sync", this.updateFromRemoteSettings); + + this.initComplete = new Promise(resolve => { + this._resolveInitComplete = resolve; + }); + }, + + observe(subject, topic, data) { + switch (topic) { + case kPrefChangedTopic: + let allowedPrefs = Object.getOwnPropertyNames(kConfigPrefs).map( + k => kConfigPrefs[k] + ); + if ( + !allowedPrefs.some(pref => + [ + `${kRegionPrefBranch}.${pref}`, + `${kGlobalPrefBranch}.${pref}`, + ].includes(data) + ) + ) { + break; + } + this.notifyNewConfig(); + break; + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + // Creates new config object from currently available + // Remote Settings values. + async updateFromRemoteSettings() { + let providers = await gProvidersCollection.get(); + let config = await gConfigCollection.get(); + + let providersById = new Map(); + providers.forEach(p => providersById.set(p.id, p)); + + let configByRegion = new Map(); + config.forEach(c => { + c.id = c.id.toLowerCase(); + configByRegion.set(c.id, c); + }); + + let homeRegion = lazy.Preferences.get(`${kGlobalPrefBranch}.home-region`); + let localConfig = + configByRegion.get(homeRegion?.toLowerCase()) || + configByRegion.get("global"); + + // Make a new config object first, mutate it as needed, then synchronously + // replace the currentConfig object at the end to ensure atomicity. + let newConfig = makeBaseConfigObject(); + + if (!localConfig) { + DoHConfigController.currentConfig = newConfig; + DoHConfigController.notifyNewConfig(); + return; + } + + if (localConfig.rolloutEnabled) { + newConfig.enabled = true; + } + + let parseProviderList = (list, checkFn) => { + let parsedList = []; + list?.split(",")?.forEach(p => { + p = p.trim(); + if (!p.length) { + return; + } + p = providersById.get(p); + if (!p || (checkFn && !checkFn(p))) { + return; + } + parsedList.push(p); + }); + return parsedList; + }; + + let regionalProviders = parseProviderList(localConfig.providers); + if (regionalProviders?.length) { + newConfig.providerList = regionalProviders; + } + + if (localConfig.steeringEnabled) { + let steeringProviders = parseProviderList( + localConfig.steeringProviders, + p => p.canonicalName?.length + ); + if (steeringProviders?.length) { + newConfig.providerSteering.providerList = steeringProviders; + newConfig.providerSteering.enabled = true; + } + } + + if (localConfig.autoDefaultEnabled) { + let defaultProviders = parseProviderList( + localConfig.autoDefaultProviders + ); + if (defaultProviders?.length) { + newConfig.trrSelection.providerList = defaultProviders; + newConfig.trrSelection.enabled = true; + } + } + + // Finally, update the currentConfig object synchronously. + DoHConfigController.currentConfig = newConfig; + + DoHConfigController.notifyNewConfig(); + }, + + kConfigUpdateTopic: "doh-config-updated", + notifyNewConfig() { + Services.obs.notifyObservers(null, this.kConfigUpdateTopic); + }, +}; + +DoHConfigController.initComplete = new Promise(resolve => { + DoHConfigController._resolveInitComplete = resolve; +}); +DoHConfigController.init(); diff --git a/browser/components/doh/DoHController.sys.mjs b/browser/components/doh/DoHController.sys.mjs new file mode 100644 index 0000000000..3672638e5e --- /dev/null +++ b/browser/components/doh/DoHController.sys.mjs @@ -0,0 +1,708 @@ +/* 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/. */ + +/* + * This module runs the automated heuristics to enable/disable DoH on different + * networks. Heuristics are run at startup and upon network changes. + * Heuristics are disabled if the user sets their DoH provider or mode manually. + */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + ClientID: "resource://gre/modules/ClientID.sys.mjs", + DoHConfigController: "resource:///modules/DoHConfig.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + Heuristics: "resource:///modules/DoHHeuristics.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// When this is set we suppress automatic TRR selection beyond dry-run as well +// as sending observer notifications during heuristics throttling. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kIsInAutomation", + "doh-rollout._testing", + false +); + +// We wait until the network has been stably up for this many milliseconds +// before triggering a heuristics run. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kNetworkDebounceTimeout", + "doh-rollout.network-debounce-timeout", + 1000 +); + +// If consecutive heuristics runs are attempted within this period after a first, +// we suppress them for this duration, at the end of which point we decide whether +// to do one coalesced run or to extend the timer if the rate limit was exceeded. +// Note that the very first run is allowed, after which we start the timer. +// This throttling is necessary due to evidence of clients that experience +// network volatility leading to thousands of runs per hour. See bug 1626083. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kHeuristicsThrottleTimeout", + "doh-rollout.heuristics-throttle-timeout", + 15000 +); + +// After the throttle timeout described above, if there are more than this many +// heuristics attempts during the timeout, we restart the timer without running +// heuristics. Thus, heuristics are suppressed completely as long as the rate +// exceeds this limit. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kHeuristicsRateLimit", + "doh-rollout.heuristics-throttle-rate-limit", + 2 +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gCaptivePortalService", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +// Stores whether we've done first-run. +const FIRST_RUN_PREF = "doh-rollout.doneFirstRun"; + +// Set when we detect that the user set their DoH provider or mode manually. +// If set, we don't run heuristics. +const DISABLED_PREF = "doh-rollout.disable-heuristics"; + +// Set when we detect either a non-DoH enterprise policy, or a DoH policy that +// tells us to disable it. This pref's effect is to suppress the opt-out CFR. +const SKIP_HEURISTICS_PREF = "doh-rollout.skipHeuristicsCheck"; + +// Whether to clear doh-rollout.mode on shutdown. When false, the mode value +// that exists at shutdown will be used at startup until heuristics re-run. +const CLEAR_ON_SHUTDOWN_PREF = "doh-rollout.clearModeOnShutdown"; + +const BREADCRUMB_PREF = "doh-rollout.self-enabled"; + +// Necko TRR prefs to watch for user-set values. +const NETWORK_TRR_MODE_PREF = "network.trr.mode"; +const NETWORK_TRR_URI_PREF = "network.trr.uri"; + +const ROLLOUT_MODE_PREF = "doh-rollout.mode"; +const ROLLOUT_URI_PREF = "doh-rollout.uri"; + +const TRR_SELECT_DRY_RUN_RESULT_PREF = + "doh-rollout.trr-selection.dry-run-result"; + +const NATIVE_FALLBACK_WARNING_PREF = "network.trr.display_fallback_warning"; +const NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF = + "network.trr.fallback_warning_heuristic_list"; + +const HEURISTICS_TELEMETRY_CATEGORY = "doh"; +const TRRSELECT_TELEMETRY_CATEGORY = "security.doh.trrPerformance"; + +const kLinkStatusChangedTopic = "network:link-status-changed"; +const kConnectivityTopic = "network:captive-portal-connectivity-changed"; +const kPrefChangedTopic = "nsPref:changed"; + +// Helper function to hash the network ID concatenated with telemetry client ID. +// This prevents us from being able to tell if 2 clients are on the same network. +function getHashedNetworkID() { + let currentNetworkID = lazy.gNetworkLinkService.networkID; + if (!currentNetworkID) { + return ""; + } + + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + + hasher.init(Ci.nsICryptoHash.SHA256); + // Concat the client ID with the network ID before hashing. + let clientNetworkID = lazy.ClientID.getClientID() + currentNetworkID; + hasher.update( + clientNetworkID.split("").map(c => c.charCodeAt(0)), + clientNetworkID.length + ); + return hasher.finish(true); +} + +export const DoHController = { + _heuristicsAreEnabled: false, + + async init() { + Services.telemetry.setEventRecordingEnabled( + HEURISTICS_TELEMETRY_CATEGORY, + true + ); + Services.telemetry.setEventRecordingEnabled( + TRRSELECT_TELEMETRY_CATEGORY, + true + ); + + await lazy.DoHConfigController.initComplete; + + Services.obs.addObserver(this, lazy.DoHConfigController.kConfigUpdateTopic); + lazy.Preferences.observe(NETWORK_TRR_MODE_PREF, this); + lazy.Preferences.observe(NETWORK_TRR_URI_PREF, this); + lazy.Preferences.observe(NATIVE_FALLBACK_WARNING_PREF, this); + lazy.Preferences.observe(NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF, this); + + if (lazy.DoHConfigController.currentConfig.enabled) { + await this.maybeEnableHeuristics(); + } else if (lazy.Preferences.get(FIRST_RUN_PREF, false)) { + await this.rollback(); + } + + this._asyncShutdownBlocker = async () => { + await this.disableHeuristics("shutdown"); + }; + + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "DoHController: clear state and remove observers", + this._asyncShutdownBlocker + ); + + lazy.Preferences.set(FIRST_RUN_PREF, true); + }, + + // Also used by tests to reset DoHController state (prefs are not cleared + // here - tests do that when needed between _uninit and init). + async _uninit() { + Services.obs.removeObserver( + this, + lazy.DoHConfigController.kConfigUpdateTopic + ); + lazy.Preferences.ignore(NETWORK_TRR_MODE_PREF, this); + lazy.Preferences.ignore(NETWORK_TRR_URI_PREF, this); + lazy.AsyncShutdown.profileBeforeChange.removeBlocker( + this._asyncShutdownBlocker + ); + await this.disableHeuristics("shutdown"); + }, + + // Called to reset state when a new config is available. + resetPromise: Promise.resolve(), + async reset() { + this.resetPromise = this.resetPromise.then(async () => { + await this._uninit(); + await this.init(); + Services.obs.notifyObservers(null, "doh:controller-reloaded"); + }); + + return this.resetPromise; + }, + + // The "maybe" is because there are two cases when we don't enable heuristics: + // 1. If we detect that TRR mode or URI have user values, or we previously + // detected this (i.e. DISABLED_PREF is true) + // 2. If there are any non-DoH enterprise policies active + async maybeEnableHeuristics() { + if (lazy.Preferences.get(DISABLED_PREF)) { + return; + } + + let policyResult = await lazy.Heuristics.checkEnterprisePolicy(); + + if (["policy_without_doh", "disable_doh"].includes(policyResult)) { + await this.setState("policyDisabled"); + lazy.Preferences.set(SKIP_HEURISTICS_PREF, true); + return; + } + + lazy.Preferences.reset(SKIP_HEURISTICS_PREF); + + if ( + lazy.Preferences.isSet(NETWORK_TRR_MODE_PREF) || + lazy.Preferences.isSet(NETWORK_TRR_URI_PREF) + ) { + await this.setState("manuallyDisabled"); + lazy.Preferences.set(DISABLED_PREF, true); + return; + } + + await this.runTRRSelection(); + // If we enter this branch it means that no automatic selection was possible. + // In this case, we try to set a fallback (as defined by DoHConfigController). + if (!lazy.Preferences.isSet(ROLLOUT_URI_PREF)) { + let uri = lazy.DoHConfigController.currentConfig.fallbackProviderURI; + + // If part of the treatment branch use the URL from the experiment. + try { + let ohttpURI = lazy.NimbusFeatures.dooh.getVariable("ohttpUri"); + if (ohttpURI) { + uri = ohttpURI; + } + } catch (e) { + console.error(`Error getting dooh.ohttpURI: ${e.message}`); + } + + lazy.Preferences.set(ROLLOUT_URI_PREF, uri || ""); + } + this.runHeuristicsThrottled("startup"); + Services.obs.addObserver(this, kLinkStatusChangedTopic); + Services.obs.addObserver(this, kConnectivityTopic); + + this._heuristicsAreEnabled = true; + }, + + _runsWhileThrottling: 0, + _wasThrottleExtended: false, + _throttleHeuristics() { + if (lazy.kHeuristicsThrottleTimeout < 0) { + // Skip throttling in tests that set timeout to a negative value. + return false; + } + + if (this._throttleTimer) { + // Already throttling - nothing to do. + this._runsWhileThrottling++; + return true; + } + + this._runsWhileThrottling = 0; + + this._throttleTimer = lazy.setTimeout( + this._handleThrottleTimeout.bind(this), + lazy.kHeuristicsThrottleTimeout + ); + + return false; + }, + + _handleThrottleTimeout() { + delete this._throttleTimer; + if (this._runsWhileThrottling > lazy.kHeuristicsRateLimit) { + // During the throttle period, we saw that the rate limit was exceeded. + // We extend the throttle period, and don't bother running heuristics yet. + this._wasThrottleExtended = true; + // Restart the throttle timer. + this._throttleHeuristics(); + if (lazy.kIsInAutomation) { + Services.obs.notifyObservers(null, "doh:heuristics-throttle-extend"); + } + return; + } + + // If this was an extended throttle and there were no runs during the + // extended period, we still want to run heuristics, since the extended + // throttle implies we had a non-zero number of attempts before extension. + if (this._runsWhileThrottling > 0 || this._wasThrottleExtended) { + this.runHeuristicsThrottled("throttled"); + } + + this._wasThrottleExtended = false; + + if (lazy.kIsInAutomation) { + Services.obs.notifyObservers(null, "doh:heuristics-throttle-done"); + } + }, + + runHeuristicsThrottled(evaluateReason) { + // _throttleHeuristics returns true if we've already witnessed a run and the + // timeout period hasn't lapsed yet. If it does so, we suppress this run. + if (this._throttleHeuristics()) { + return; + } + + // _throttleHeuristics returned false - we're good to run heuristics. + // At this point the timer has been started and subsequent calls will be + // suppressed if it hasn't fired yet. + this.runHeuristics(evaluateReason); + }, + + async runHeuristics(evaluateReason) { + let start = Date.now(); + + let results = await lazy.Heuristics.run(); + + if ( + !lazy.gNetworkLinkService.isLinkUp || + this._lastDebounceTimestamp > start || + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + // If the network is currently down or there was a debounce triggered + // while we were running heuristics, it means the network fluctuated + // during this heuristics run. We simply discard the results in this case. + // Same thing if there was another heuristics run triggered or if we have + // detected a locked captive portal while this one was ongoing. + return; + } + + let decision = Object.values(results).includes(lazy.Heuristics.DISABLE_DOH) + ? lazy.Heuristics.DISABLE_DOH + : lazy.Heuristics.ENABLE_DOH; + + let getCaptiveStateString = () => { + switch (lazy.gCaptivePortalService.state) { + case lazy.gCaptivePortalService.NOT_CAPTIVE: + return "not_captive"; + case lazy.gCaptivePortalService.UNLOCKED_PORTAL: + return "unlocked"; + case lazy.gCaptivePortalService.LOCKED_PORTAL: + return "locked"; + default: + return "unknown"; + } + }; + + let resultsForTelemetry = { + evaluateReason, + steeredProvider: "", + captiveState: getCaptiveStateString(), + // NOTE: This might not yet be available after a network change. We mainly + // care about the startup case though - we want to look at whether the + // heuristics result is consistent for networkIDs often seen at startup. + // TODO: Use this data to implement cached results to use early at startup. + networkID: getHashedNetworkID(), + }; + + const oHTTPexperiment = lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "dooh", + }); + + // When the OHTTP experiment is active we don't want to enable steering. + if (results.steeredProvider && !oHTTPexperiment) { + Services.dns.setDetectedTrrURI(results.steeredProvider.uri); + resultsForTelemetry.steeredProvider = results.steeredProvider.id; + } + + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + if (decision === lazy.Heuristics.DISABLE_DOH) { + let fallbackHeuristicTripped = undefined; + if (lazy.Preferences.get(NATIVE_FALLBACK_WARNING_PREF, false)) { + let heuristics = lazy.Preferences.get( + NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF, + "" + ).split(","); + for (let [heuristicName, result] of Object.entries(results)) { + if (result !== lazy.Heuristics.DISABLE_DOH) { + continue; + } + if (heuristics.includes(heuristicName)) { + fallbackHeuristicTripped = heuristicName; + break; + } + } + } + + // If none of the fallback heuristics failed, the detection result will be TRR_OK + // Otherwise it will be the skip reason for the failed heuristic. + let heuristicSkipReason = Ci.nsITRRSkipReason.TRR_OK; + if (fallbackHeuristicTripped != undefined) { + heuristicSkipReason = lazy.Heuristics.heuristicNameToSkipReason( + fallbackHeuristicTripped + ); + } + this.setHeuristicResult(heuristicSkipReason); + + await this.setState("disabled"); + } else { + await this.setState("enabled"); + } + + // For telemetry, we group the heuristics results into three categories. + // Only heuristics with a DISABLE_DOH result are included. + // Each category is finally included in the event as a comma-separated list. + let canaries = []; + let filtering = []; + let enterprise = []; + let platform = []; + + for (let [heuristicName, result] of Object.entries(results)) { + if (result !== lazy.Heuristics.DISABLE_DOH) { + continue; + } + + if (["canary", "zscalerCanary"].includes(heuristicName)) { + canaries.push(heuristicName); + } else if ( + ["browserParent", "google", "youtube"].includes(heuristicName) + ) { + filtering.push(heuristicName); + } else if ( + ["policy", "modifiedRoots", "thirdPartyRoots"].includes(heuristicName) + ) { + enterprise.push(heuristicName); + } else if (["vpn", "proxy", "nrpt"].includes(heuristicName)) { + platform.push(heuristicName); + } + } + + resultsForTelemetry.canaries = canaries.join(","); + resultsForTelemetry.filtering = filtering.join(","); + resultsForTelemetry.enterprise = enterprise.join(","); + resultsForTelemetry.platform = platform.join(","); + + Services.telemetry.recordEvent( + HEURISTICS_TELEMETRY_CATEGORY, + "evaluate_v2", + "heuristics", + decision, + resultsForTelemetry + ); + }, + + async setState(state) { + switch (state) { + case "disabled": + lazy.Preferences.set(ROLLOUT_MODE_PREF, 0); + break; + case "UIOk": + lazy.Preferences.set(BREADCRUMB_PREF, true); + break; + case "enabled": + lazy.Preferences.set(ROLLOUT_MODE_PREF, 2); + lazy.Preferences.set(BREADCRUMB_PREF, true); + break; + case "policyDisabled": + case "manuallyDisabled": + case "UIDisabled": + lazy.Preferences.reset(BREADCRUMB_PREF); + // Fall through. + case "rollback": + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + lazy.Preferences.reset(ROLLOUT_MODE_PREF); + break; + case "shutdown": + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + if (lazy.Preferences.get(CLEAR_ON_SHUTDOWN_PREF, true)) { + lazy.Preferences.reset(ROLLOUT_MODE_PREF); + } + break; + } + + Services.telemetry.recordEvent( + HEURISTICS_TELEMETRY_CATEGORY, + "state", + state, + "null" + ); + }, + + async disableHeuristics(state) { + await this.setState(state); + + if (!this._heuristicsAreEnabled) { + return; + } + + Services.obs.removeObserver(this, kLinkStatusChangedTopic); + Services.obs.removeObserver(this, kConnectivityTopic); + if (this._debounceTimer) { + lazy.clearTimeout(this._debounceTimer); + delete this._debounceTimer; + } + if (this._throttleTimer) { + lazy.clearTimeout(this._throttleTimer); + delete this._throttleTimer; + } + this._heuristicsAreEnabled = false; + }, + + async rollback() { + await this.disableHeuristics("rollback"); + }, + + async runTRRSelection() { + // If persisting the selection is disabled, clear the existing + // selection. + if (!lazy.DoHConfigController.currentConfig.trrSelection.commitResult) { + lazy.Preferences.reset(ROLLOUT_URI_PREF); + } + + if (!lazy.DoHConfigController.currentConfig.trrSelection.enabled) { + return; + } + + if ( + lazy.Preferences.isSet(ROLLOUT_URI_PREF) && + lazy.Preferences.get(ROLLOUT_URI_PREF) == + lazy.Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF) + ) { + return; + } + + await this.runTRRSelectionDryRun(); + + // If persisting the selection is disabled, don't commit the value. + if (!lazy.DoHConfigController.currentConfig.trrSelection.commitResult) { + return; + } + + lazy.Preferences.set( + ROLLOUT_URI_PREF, + lazy.Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF) + ); + }, + + async runTRRSelectionDryRun() { + if (lazy.Preferences.isSet(TRR_SELECT_DRY_RUN_RESULT_PREF)) { + // Check whether the existing dry-run-result is in the default + // list of TRRs. If it is, all good. Else, run the dry run again. + let dryRunResult = lazy.Preferences.get(TRR_SELECT_DRY_RUN_RESULT_PREF); + let dryRunResultIsValid = + lazy.DoHConfigController.currentConfig.providerList.some( + trr => trr.uri == dryRunResult + ); + if (dryRunResultIsValid) { + return; + } + } + + let setDryRunResultAndRecordTelemetry = trrUri => { + lazy.Preferences.set(TRR_SELECT_DRY_RUN_RESULT_PREF, trrUri); + Services.telemetry.recordEvent( + TRRSELECT_TELEMETRY_CATEGORY, + "trrselect", + "dryrunresult", + trrUri.substring(0, 40) // Telemetry payload max length + ); + }; + + if (lazy.kIsInAutomation) { + // For mochitests, just record telemetry with a dummy result. + // TRRPerformance.sys.mjs is tested in xpcshell. + setDryRunResultAndRecordTelemetry("https://example.com/dns-query"); + return; + } + + // Importing the module here saves us from having to do it at startup, and + // ensures tests have time to set prefs before the module initializes. + let { TRRRacer } = ChromeUtils.importESModule( + "resource:///modules/TRRPerformance.sys.mjs" + ); + await new Promise(resolve => { + let trrList = + lazy.DoHConfigController.currentConfig.trrSelection.providerList.map( + trr => trr.uri + ); + let racer = new TRRRacer(() => { + setDryRunResultAndRecordTelemetry(racer.getFastestTRR(true)); + resolve(); + }, trrList); + racer.run(); + }); + }, + + observe(subject, topic, data) { + switch (topic) { + case kLinkStatusChangedTopic: + this.onConnectionChanged(); + break; + case kConnectivityTopic: + this.onConnectivityAvailable(); + break; + case kPrefChangedTopic: + this.onPrefChanged(data); + break; + case lazy.DoHConfigController.kConfigUpdateTopic: + this.reset(); + break; + } + }, + + setHeuristicResult(skipReason) { + try { + Services.dns.setHeuristicDetectionResult(skipReason); + } catch (e) {} + }, + + async onPrefChanged(pref) { + switch (pref) { + case NETWORK_TRR_URI_PREF: + case NETWORK_TRR_MODE_PREF: + lazy.Preferences.set(DISABLED_PREF, true); + await this.disableHeuristics("manuallyDisabled"); + break; + case NATIVE_FALLBACK_WARNING_PREF: + case NATIVE_FALLBACK_WARNING_HEURISTIC_LIST_PREF: + if (this._heuristicsAreEnabled) { + await this.runHeuristics("native-fallback-warning-pref-changed"); + } else { + this.setHeuristicResult(Ci.nsITRRSkipReason.TRR_UNSET); + } + break; + } + }, + + // Connection change events are debounced to allow the network to settle. + // We wait for the network to be up for a period of kDebounceTimeout before + // handling the change. The timer is canceled when the network goes down and + // restarted the first time we learn that it went back up. + _debounceTimer: null, + _cancelDebounce() { + if (!this._debounceTimer) { + return; + } + + lazy.clearTimeout(this._debounceTimer); + this._debounceTimer = null; + }, + + _lastDebounceTimestamp: 0, + onConnectionChanged() { + if (!lazy.gNetworkLinkService.isLinkUp) { + // Network is down - reset debounce timer. + this._cancelDebounce(); + return; + } + + if (this._debounceTimer) { + // Already debouncing - nothing to do. + return; + } + + if (lazy.kNetworkDebounceTimeout < 0) { + // Skip debouncing in tests that set timeout to a negative value. + this.onConnectionChangedDebounced(); + return; + } + + this._lastDebounceTimestamp = Date.now(); + this._debounceTimer = lazy.setTimeout(() => { + this._cancelDebounce(); + this.onConnectionChangedDebounced(); + }, lazy.kNetworkDebounceTimeout); + }, + + onConnectionChangedDebounced() { + if (!lazy.gNetworkLinkService.isLinkUp) { + return; + } + + if ( + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + return; + } + + // The network is up and we don't know that we're in a locked portal. + // Run heuristics. If we detect a portal later, we'll run heuristics again + // when it's unlocked. In that case, this run will likely have failed. + this.runHeuristicsThrottled("netchange"); + }, + + onConnectivityAvailable() { + if (this._debounceTimer) { + // Already debouncing - nothing to do. + return; + } + + this.runHeuristicsThrottled("connectivity"); + }, +}; diff --git a/browser/components/doh/DoHHeuristics.sys.mjs b/browser/components/doh/DoHHeuristics.sys.mjs new file mode 100644 index 0000000000..b858c8ff30 --- /dev/null +++ b/browser/components/doh/DoHHeuristics.sys.mjs @@ -0,0 +1,405 @@ +/* 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/. */ + +/* + * This module implements the heuristics used to determine whether to enable + * or disable DoH on different networks. DoHController is responsible for running + * these at startup and upon network changes. + */ +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gParentalControlsService", + "@mozilla.org/parental-controls-service;1", + "nsIParentalControlsService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + DoHConfigController: "resource:///modules/DoHConfig.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const GLOBAL_CANARY = "use-application-dns.net."; + +const NXDOMAIN_ERR = "NS_ERROR_UNKNOWN_HOST"; + +export const Heuristics = { + // String constants used to indicate outcome of heuristics. + ENABLE_DOH: "enable_doh", + DISABLE_DOH: "disable_doh", + + async run() { + // Run all the heuristics at the same time. + let [safeSearchChecks, zscaler, canary] = await Promise.all([ + safeSearch(), + zscalerCanary(), + globalCanary(), + ]); + + let platformChecks = await platform(); + let results = { + google: safeSearchChecks.google, + youtube: safeSearchChecks.youtube, + zscalerCanary: zscaler, + canary, + modifiedRoots: await modifiedRoots(), + browserParent: await parentalControls(), + thirdPartyRoots: await thirdPartyRoots(), + policy: await enterprisePolicy(), + vpn: platformChecks.vpn, + proxy: platformChecks.proxy, + nrpt: platformChecks.nrpt, + steeredProvider: "", + }; + + // If any of those were triggered, return the results immediately. + if (Object.values(results).includes("disable_doh")) { + return results; + } + + // Check for provider steering only after the other heuristics have passed. + results.steeredProvider = (await providerSteering()) || ""; + return results; + }, + + async checkEnterprisePolicy() { + return enterprisePolicy(); + }, + + // Test only + async _setMockLinkService(mockLinkService) { + this.mockLinkService = mockLinkService; + }, + + heuristicNameToSkipReason(heuristicName) { + const namesToSkipReason = { + google: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_GOOGLE_SAFESEARCH, + youtube: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_YOUTUBE_SAFESEARCH, + zscalerCanary: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_ZSCALER_CANARY, + canary: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_CANARY, + modifiedRoots: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_MODIFIED_ROOTS, + browserParent: + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_PARENTAL_CONTROLS, + thirdPartyRoots: + Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_THIRD_PARTY_ROOTS, + policy: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_ENTERPRISE_POLICY, + vpn: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_VPN, + proxy: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_PROXY, + nrpt: Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_NRPT, + }; + + let value = namesToSkipReason[heuristicName]; + if (value != undefined) { + return value; + } + return Ci.nsITRRSkipReason.TRR_FAILED; + }, +}; + +async function dnsLookup(hostname, resolveCanonicalName = false) { + let lookupPromise = new Promise((resolve, reject) => { + let request; + let response = { + addresses: [], + }; + let listener = { + onLookupComplete(inRequest, inRecord, inStatus) { + if (inRequest === request) { + if (!Components.isSuccessCode(inStatus)) { + reject({ message: new Components.Exception("", inStatus).name }); + return; + } + inRecord.QueryInterface(Ci.nsIDNSAddrRecord); + if (resolveCanonicalName) { + try { + response.canonicalName = inRecord.canonicalName; + } catch (e) { + // no canonicalName + } + } + while (inRecord.hasMore()) { + let addr = inRecord.getNextAddrAsString(); + // Sometimes there are duplicate records with the same ip. + if (!response.addresses.includes(addr)) { + response.addresses.push(addr); + } + } + resolve(response); + } + }, + }; + let dnsFlags = + Ci.nsIDNSService.RESOLVE_TRR_DISABLED_MODE | + Ci.nsIDNSService.RESOLVE_DISABLE_IPV6 | + Ci.nsIDNSService.RESOLVE_BYPASS_CACHE | + Ci.nsIDNSService.RESOLVE_CANONICAL_NAME; + try { + request = Services.dns.asyncResolve( + hostname, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + dnsFlags, + null, + listener, + null, + {} /* defaultOriginAttributes */ + ); + } catch (e) { + // handle exceptions such as offline mode. + reject({ message: e.name }); + } + }); + + let addresses, canonicalName, err; + + try { + let response = await lookupPromise; + addresses = response.addresses; + canonicalName = response.canonicalName; + } catch (e) { + addresses = [null]; + err = e.message; + } + + return { addresses, canonicalName, err }; +} + +async function dnsListLookup(domainList) { + let results = []; + + let resolutions = await Promise.all( + domainList.map(domain => dnsLookup(domain)) + ); + for (let { addresses } of resolutions) { + results = results.concat(addresses); + } + + return results; +} + +// TODO: Confirm the expected behavior when filtering is on +async function globalCanary() { + let { addresses, err } = await dnsLookup(GLOBAL_CANARY); + + if ( + err === NXDOMAIN_ERR || + !addresses.length || + addresses.every(addr => + Services.io.hostnameIsLocalIPAddress(Services.io.newURI(`http://${addr}`)) + ) + ) { + return "disable_doh"; + } + + return "enable_doh"; +} + +async function modifiedRoots() { + // Check for presence of enterprise_roots cert pref. If enabled, disable DoH + let rootsEnabled = lazy.Preferences.get( + "security.enterprise_roots.enabled", + false + ); + + if (rootsEnabled) { + return "disable_doh"; + } + + return "enable_doh"; +} + +export async function parentalControls() { + if (lazy.gParentalControlsService.parentalControlsEnabled) { + return "disable_doh"; + } + + return "enable_doh"; +} + +async function thirdPartyRoots() { + if (Cu.isInAutomation) { + return "enable_doh"; + } + + let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + + let hasThirdPartyRoots = await new Promise(resolve => { + certdb.asyncHasThirdPartyRoots(resolve); + }); + + if (hasThirdPartyRoots) { + return "disable_doh"; + } + + return "enable_doh"; +} + +async function enterprisePolicy() { + if (Services.policies.status === Services.policies.ACTIVE) { + let policies = Services.policies.getActivePolicies(); + + if (!policies.hasOwnProperty("DNSOverHTTPS")) { + // If DoH isn't in the policy, return that there is a policy (but no DoH specifics) + return "policy_without_doh"; + } + + if (policies.DNSOverHTTPS.Enabled === true) { + // If DoH is enabled in the policy, enable it + return "enable_doh"; + } + + // If DoH is disabled in the policy, disable it + return "disable_doh"; + } + + // Default return, meaning no policy related to DNSOverHTTPS + return "no_policy_set"; +} + +async function safeSearch() { + const providerList = [ + { + name: "google", + unfiltered: ["www.google.com.", "google.com."], + safeSearch: ["forcesafesearch.google.com."], + }, + { + name: "youtube", + unfiltered: [ + "www.youtube.com.", + "m.youtube.com.", + "youtubei.googleapis.com.", + "youtube.googleapis.com.", + "www.youtube-nocookie.com.", + ], + safeSearch: ["restrict.youtube.com.", "restrictmoderate.youtube.com."], + }, + ]; + + async function checkProvider(provider) { + let [unfilteredAnswers, safeSearchAnswers] = await Promise.all([ + dnsListLookup(provider.unfiltered), + dnsListLookup(provider.safeSearch), + ]); + + // Given a provider, check if the answer for any safe search domain + // matches the answer for any default domain + for (let answer of safeSearchAnswers) { + if (answer && unfilteredAnswers.includes(answer)) { + return { name: provider.name, result: "disable_doh" }; + } + } + + return { name: provider.name, result: "enable_doh" }; + } + + // Compare strict domain lookups to non-strict domain lookups. + // Resolutions has a type of [{ name, result }] + let resolutions = await Promise.all( + providerList.map(provider => checkProvider(provider)) + ); + + // Reduce that array entries into a single map + return resolutions.reduce( + (accumulator, check) => { + accumulator[check.name] = check.result; + return accumulator; + }, + {} // accumulator + ); +} + +async function zscalerCanary() { + const ZSCALER_CANARY = "sitereview.zscaler.com."; + + let { addresses } = await dnsLookup(ZSCALER_CANARY); + for (let address of addresses) { + if ( + ["213.152.228.242", "199.168.151.251", "8.25.203.30"].includes(address) + ) { + // if sitereview.zscaler.com resolves to either one of the 3 IPs above, + // Zscaler Shift service is in use, don't enable DoH + return "disable_doh"; + } + } + + return "enable_doh"; +} + +async function platform() { + let platformChecks = {}; + + let indications = Ci.nsINetworkLinkService.NONE_DETECTED; + try { + let linkService = lazy.gNetworkLinkService; + if (Heuristics.mockLinkService) { + linkService = Heuristics.mockLinkService; + } + indications = linkService.platformDNSIndications; + } catch (e) { + if (e.result != Cr.NS_ERROR_NOT_IMPLEMENTED) { + console.error(e); + } + } + + platformChecks.vpn = + indications & Ci.nsINetworkLinkService.VPN_DETECTED + ? "disable_doh" + : "enable_doh"; + platformChecks.proxy = + indications & Ci.nsINetworkLinkService.PROXY_DETECTED + ? "disable_doh" + : "enable_doh"; + platformChecks.nrpt = + indications & Ci.nsINetworkLinkService.NRPT_DETECTED + ? "disable_doh" + : "enable_doh"; + + return platformChecks; +} + +// Check if the network provides a DoH endpoint to use. Returns the name of the +// provider if the check is successful, else null. Currently we only support +// this for Comcast networks. +async function providerSteering() { + if (!lazy.DoHConfigController.currentConfig.providerSteering.enabled) { + return null; + } + const TEST_DOMAIN = "doh.test."; + + // Array of { name, canonicalName, uri } where name is an identifier for + // telemetry, canonicalName is the expected CNAME when looking up doh.test, + // and uri is the provider's DoH endpoint. + let steeredProviders = + lazy.DoHConfigController.currentConfig.providerSteering.providerList; + + if (!steeredProviders || !steeredProviders.length) { + return null; + } + + let { canonicalName, err } = await dnsLookup(TEST_DOMAIN, true); + if (err || !canonicalName) { + return null; + } + + let provider = steeredProviders.find(p => { + return p.canonicalName == canonicalName; + }); + if (!provider || !provider.uri || !provider.id) { + return null; + } + + return provider; +} diff --git a/browser/components/doh/DoHTestUtils.sys.mjs b/browser/components/doh/DoHTestUtils.sys.mjs new file mode 100644 index 0000000000..b89c1fe966 --- /dev/null +++ b/browser/components/doh/DoHTestUtils.sys.mjs @@ -0,0 +1,133 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +const kConfigCollectionKey = "doh-config"; +const kProviderCollectionKey = "doh-providers"; + +const kConfigUpdateTopic = "doh-config-updated"; +const kControllerReloadedTopic = "doh:controller-reloaded"; + +/* + * Some helpers for loading and modifying DoH config in + * Remote Settings. Call resetRemoteSettingsConfig to set up + * basic default config that omits external URLs. Use + * waitForConfigFlush to wait for DoH actors to pick up changes. + * + * Some tests need to load/reset config while DoH actors are + * uninitialized. Pass waitForConfigFlushes = false in these cases. + */ +export const DoHTestUtils = { + providers: [ + { + uri: "https://example.com/1", + UIName: "Example 1", + autoDefault: false, + canonicalName: "", + id: "example-1", + }, + { + uri: "https://example.com/2", + UIName: "Example 2", + autoDefault: false, + canonicalName: "", + id: "example-2", + }, + ], + + async loadRemoteSettingsProviders(providers, waitForConfigFlushes = true) { + let configFlushedPromise = this.waitForConfigFlush(waitForConfigFlushes); + + let providerRS = lazy.RemoteSettings(kProviderCollectionKey); + let db = await providerRS.db; + await db.importChanges({}, Date.now(), providers, { clear: true }); + + // Trigger a sync. + await this.triggerSync(providerRS); + + await configFlushedPromise; + }, + + async loadRemoteSettingsConfig(config, waitForConfigFlushes = true) { + let configFlushedPromise = this.waitForConfigFlush(waitForConfigFlushes); + + let configRS = lazy.RemoteSettings(kConfigCollectionKey); + let db = await configRS.db; + await db.importChanges({}, Date.now(), [config]); + + // Trigger a sync. + await this.triggerSync(configRS); + + await configFlushedPromise; + }, + + // Loads default config for testing without clearing existing entries. + async loadDefaultRemoteSettingsConfig(waitForConfigFlushes = true) { + await this.loadRemoteSettingsProviders( + this.providers, + waitForConfigFlushes + ); + + await this.loadRemoteSettingsConfig( + { + providers: "example-1, example-2", + rolloutEnabled: false, + steeringEnabled: false, + steeringProviders: "", + autoDefaultEnabled: false, + autoDefaultProviders: "", + id: "global", + }, + waitForConfigFlushes + ); + }, + + // Clears existing config AND loads defaults. + async resetRemoteSettingsConfig(waitForConfigFlushes = true) { + let providerRS = lazy.RemoteSettings(kProviderCollectionKey); + let configRS = lazy.RemoteSettings(kConfigCollectionKey); + for (let rs of [providerRS, configRS]) { + let configFlushedPromise = this.waitForConfigFlush(waitForConfigFlushes); + await rs.db.importChanges({}, Date.now(), [], { clear: true }); + // Trigger a sync to clear. + await this.triggerSync(rs); + await configFlushedPromise; + } + + await this.loadDefaultRemoteSettingsConfig(waitForConfigFlushes); + }, + + triggerSync(rs) { + return rs.emit("sync", { + data: { + current: [], + }, + }); + }, + + waitForConfigUpdate() { + return lazy.TestUtils.topicObserved(kConfigUpdateTopic); + }, + + waitForControllerReload() { + return lazy.TestUtils.topicObserved(kControllerReloadedTopic); + }, + + waitForConfigFlush(shouldWait = true) { + if (!shouldWait) { + return Promise.resolve(); + } + + return Promise.all([ + this.waitForConfigUpdate(), + this.waitForControllerReload(), + ]); + }, +}; diff --git a/browser/components/doh/TRRPerformance.sys.mjs b/browser/components/doh/TRRPerformance.sys.mjs new file mode 100644 index 0000000000..e46f280f40 --- /dev/null +++ b/browser/components/doh/TRRPerformance.sys.mjs @@ -0,0 +1,395 @@ +/* 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/. */ + +/* + * This module tests TRR performance by issuing DNS requests to TRRs and + * recording telemetry for the network time for each request. + * + * We test each TRR with 5 random subdomains of a canonical domain and also + * a "popular" domain (which the TRR likely have cached). + * + * To ensure data integrity, we run the requests in an aggregator wrapper + * and collect all the results before sending telemetry. If we detect network + * loss, the results are discarded. A new run is triggered upon detection of + * usable network until a full set of results has been captured. We stop retrying + * after 5 attempts. + */ +Services.telemetry.setEventRecordingEnabled( + "security.doh.trrPerformance", + true +); + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gNetworkLinkService", + "@mozilla.org/network/network-link-service;1", + "nsINetworkLinkService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gCaptivePortalService", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +// The canonical domain whose subdomains we will be resolving. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kCanonicalDomain", + "doh-rollout.trrRace.canonicalDomain", + "firefox-dns-perf-test.net." +); + +// The number of random subdomains to resolve per TRR. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kRepeats", + "doh-rollout.trrRace.randomSubdomainCount", + 5 +); + +// The "popular" domain that we expect the TRRs to have cached. +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "kPopularDomains", + "doh-rollout.trrRace.popularDomains", + null, + null, + val => + val + ? val.split(",").map(t => t.trim()) + : [ + "google.com.", + "youtube.com.", + "amazon.com.", + "facebook.com.", + "yahoo.com.", + ] +); + +function getRandomSubdomain() { + let uuid = Services.uuid.generateUUID().toString().slice(1, -1); // Discard surrounding braces + return `${uuid}.${lazy.kCanonicalDomain}`; +} + +// A wrapper around async DNS lookups. The results are passed on to the supplied +// callback. The wrapper attempts the lookup 3 times before passing on a failure. +// If a false-y `domain` is supplied, a random subdomain will be used. Each retry +// will use a different random subdomain to ensure we bypass chached responses. +export class DNSLookup { + constructor(domain, trrServer, callback) { + this._domain = domain; + this.trrServer = trrServer; + this.callback = callback; + this.retryCount = 0; + } + + doLookup() { + this.retryCount++; + try { + this.usedDomain = this._domain || getRandomSubdomain(); + Services.dns.asyncResolve( + this.usedDomain, + Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT, + Ci.nsIDNSService.RESOLVE_BYPASS_CACHE, + Services.dns.newAdditionalInfo(this.trrServer, -1), + this, + Services.tm.currentThread, + {} + ); + } catch (e) { + console.error(e); + } + } + + onLookupComplete(request, record, status) { + // Try again if we failed... + if (!Components.isSuccessCode(status) && this.retryCount < 3) { + this.doLookup(); + return; + } + + // But after the third try, just pass the status on. + this.callback(request, record, status, this.usedDomain, this.retryCount); + } +} + +DNSLookup.prototype.QueryInterface = ChromeUtils.generateQI(["nsIDNSListener"]); + +// A wrapper around a single set of measurements. The required lookups are +// triggered and the results aggregated before telemetry is sent. If aborted, +// any aggregated results are discarded. +export class LookupAggregator { + constructor(onCompleteCallback, trrList) { + this.onCompleteCallback = onCompleteCallback; + this.trrList = trrList; + this.aborted = false; + this.networkUnstable = false; + this.captivePortal = false; + + this.domains = []; + for (let i = 0; i < lazy.kRepeats; ++i) { + // false-y domain will cause DNSLookup to generate a random one. + this.domains.push(null); + } + this.domains.push(...lazy.kPopularDomains); + this.totalLookups = this.trrList.length * this.domains.length; + this.completedLookups = 0; + this.results = []; + } + + run() { + if (this._ran || this._aborted) { + console.error("Trying to re-run a LookupAggregator."); + return; + } + + this._ran = true; + for (let trr of this.trrList) { + for (let domain of this.domains) { + new DNSLookup( + domain, + trr, + (request, record, status, usedDomain, retryCount) => { + this.results.push({ + domain: usedDomain, + trr, + status, + time: record + ? record.QueryInterface(Ci.nsIDNSAddrRecord) + .trrFetchDurationNetworkOnly + : -1, + retryCount, + }); + + this.completedLookups++; + if (this.completedLookups == this.totalLookups) { + this.recordResults(); + } + } + ).doLookup(); + } + } + } + + abort() { + this.aborted = true; + } + + markUnstableNetwork() { + this.networkUnstable = true; + } + + markCaptivePortal() { + this.captivePortal = true; + } + + recordResults() { + if (this.aborted) { + return; + } + + for (let { domain, trr, status, time, retryCount } of this.results) { + if ( + !( + lazy.kPopularDomains.includes(domain) || + domain.includes(lazy.kCanonicalDomain) + ) + ) { + console.error("Expected known domain for reporting, got ", domain); + return; + } + + Services.telemetry.recordEvent( + "security.doh.trrPerformance", + "resolved", + "record", + "success", + { + domain, + trr, + status: status.toString(), + time: time.toString(), + retryCount: retryCount.toString(), + networkUnstable: this.networkUnstable.toString(), + captivePortal: this.captivePortal.toString(), + } + ); + } + + this.onCompleteCallback(); + } +} + +// This class monitors the network and spawns a new LookupAggregator when ready. +// When the network goes down, an ongoing aggregator is aborted and a new one +// spawned next time we get a link, up to 5 times. On the fifth time, we just +// let the aggegator complete and mark it as tainted. +export class TRRRacer { + constructor(onCompleteCallback, trrList) { + this._aggregator = null; + this._retryCount = 0; + this._complete = false; + this._onCompleteCallback = onCompleteCallback; + this._trrList = trrList; + } + + run() { + if ( + lazy.gNetworkLinkService.isLinkUp && + lazy.gCaptivePortalService.state != + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + this._runNewAggregator(); + if ( + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.UNLOCKED_PORTAL + ) { + this._aggregator.markCaptivePortal(); + } + } + + Services.obs.addObserver(this, "ipc:network:captive-portal-set-state"); + Services.obs.addObserver(this, "network:link-status-changed"); + } + + onComplete() { + Services.obs.removeObserver(this, "ipc:network:captive-portal-set-state"); + Services.obs.removeObserver(this, "network:link-status-changed"); + + this._complete = true; + + if (this._onCompleteCallback) { + this._onCompleteCallback(); + } + } + + getFastestTRR(returnRandomDefault = false) { + if (!this._complete) { + throw new Error("getFastestTRR: Measurement still running."); + } + + return this._getFastestTRRFromResults( + this._aggregator.results, + returnRandomDefault + ); + } + + /* + * Given an array of { trr, time }, returns the trr with smallest mean time. + * Separate from _getFastestTRR for easy unit-testing. + * + * @returns The TRR with the fastest average time. + * If returnRandomDefault is false-y, returns undefined if no valid + * times were present in the results. Otherwise, returns one of the + * present TRRs at random. + */ + _getFastestTRRFromResults(results, returnRandomDefault = false) { + // First, organize the results into a map of TRR -> array of times + let TRRTimingMap = new Map(); + let TRRErrorCount = new Map(); + for (let { trr, time } of results) { + if (!TRRTimingMap.has(trr)) { + TRRTimingMap.set(trr, []); + } + if (time != -1) { + TRRTimingMap.get(trr).push(time); + } else { + TRRErrorCount.set(trr, 1 + (TRRErrorCount.get(trr) || 0)); + } + } + + // Loop through each TRR's array of times, compute the geometric means, + // and remember the fastest TRR. Geometric mean is a bit more forgiving + // in the presence of noise (anomalously high values). + // We don't need the full geometric mean, we simply calculate the arithmetic + // means in log-space and then compare those values. + let fastestTRR; + let fastestAverageTime = -1; + let trrs = [...TRRTimingMap.keys()]; + for (let trr of trrs) { + let times = TRRTimingMap.get(trr); + if (!times.length) { + continue; + } + + // Skip TRRs that had an error rate of more than 30%. + let errorCount = TRRErrorCount.get(trr) || 0; + let totalResults = times.length + errorCount; + if (errorCount / totalResults > 0.3) { + continue; + } + + // Arithmetic mean in log space. Take log of (a + 1) to ensure we never + // take log(0) which would be -Infinity. + let averageTime = + times.map(a => Math.log(a + 1)).reduce((a, b) => a + b) / times.length; + if (fastestAverageTime == -1 || averageTime < fastestAverageTime) { + fastestAverageTime = averageTime; + fastestTRR = trr; + } + } + + if (returnRandomDefault && !fastestTRR) { + fastestTRR = trrs[Math.floor(Math.random() * trrs.length)]; + } + + return fastestTRR; + } + + _runNewAggregator() { + this._aggregator = new LookupAggregator( + () => this.onComplete(), + this._trrList + ); + this._aggregator.run(); + this._retryCount++; + } + + // When the link goes *down*, or when we detect a locked captive portal, we + // abort any ongoing LookupAggregator run. When the link goes *up*, or we + // detect a newly unlocked portal, we start a run if one isn't ongoing. + observe(subject, topic, data) { + switch (topic) { + case "network:link-status-changed": + if (this._aggregator && data == "down") { + if (this._retryCount < 5) { + this._aggregator.abort(); + } else { + this._aggregator.markUnstableNetwork(); + } + } else if ( + data == "up" && + (!this._aggregator || this._aggregator.aborted) + ) { + this._runNewAggregator(); + } + break; + case "ipc:network:captive-portal-set-state": + if ( + this._aggregator && + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.LOCKED_PORTAL + ) { + if (this._retryCount < 5) { + this._aggregator.abort(); + } else { + this._aggregator.markCaptivePortal(); + } + } else if ( + lazy.gCaptivePortalService.state == + lazy.gCaptivePortalService.UNLOCKED_PORTAL && + (!this._aggregator || this._aggregator.aborted) + ) { + this._runNewAggregator(); + } + break; + } + } +} diff --git a/browser/components/doh/moz.build b/browser/components/doh/moz.build new file mode 100644 index 0000000000..21897e5cd1 --- /dev/null +++ b/browser/components/doh/moz.build @@ -0,0 +1,22 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Security") + +EXTRA_JS_MODULES += [ + "DoHConfig.sys.mjs", + "DoHController.sys.mjs", + "DoHHeuristics.sys.mjs", + "TRRPerformance.sys.mjs", +] + +TESTING_JS_MODULES += [ + "DoHTestUtils.sys.mjs", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] diff --git a/browser/components/doh/test/browser/browser.ini b/browser/components/doh/test/browser/browser.ini new file mode 100644 index 0000000000..d440c2f393 --- /dev/null +++ b/browser/components/doh/test/browser/browser.ini @@ -0,0 +1,21 @@ +[DEFAULT] +head = head.js + +[browser_cleanFlow.js] +skip-if = socketprocess_networking +[browser_dirtyEnable.js] +[browser_doorhangerUserReject.js] +[browser_platformDetection.js] +[browser_policyOverride.js] +[browser_providerSteering.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_remoteSettings_newProfile.js] +skip-if = os == 'win' && bits == 32 # Bug 1713464 +[browser_remoteSettings_rollout.js] +skip-if = os == 'win' && bits == 32 # Bug 1713464 +[browser_rollback.js] +[browser_throttle_heuristics.js] +[browser_trrSelect.js] +[browser_trrSelection_disable.js] +[browser_userInterference.js] diff --git a/browser/components/doh/test/browser/browser_cleanFlow.js b/browser/components/doh/test/browser/browser_cleanFlow.js new file mode 100644 index 0000000000..9beb1a5a26 --- /dev/null +++ b/browser/components/doh/test/browser/browser_cleanFlow.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testCleanFlow() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await prefPromise; + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared."); + + BrowserTestUtils.removeTab(tab); + + // Change the environment to failing and simulate a network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Trigger another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Restart the controller for good measure. + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + // The mode technically changes from undefined/empty to 0 here. + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "startup"); + + // Set a passing environment and simulate a network change. + setPassingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Again, repeat and check nothing changed. + simulateNetworkChange(); + await ensureNoTRRModeChange(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Test the clearModeOnShutdown pref. `restartDoHController` does the actual + // test for us between shutdown and startup. + Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, false); + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, true); +}); diff --git a/browser/components/doh/test/browser/browser_dirtyEnable.js b/browser/components/doh/test/browser/browser_dirtyEnable.js new file mode 100644 index 0000000000..c704ca06e6 --- /dev/null +++ b/browser/components/doh/test/browser/browser_dirtyEnable.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testDirtyEnable() { + // Set up a failing environment, pre-set DoH to enabled, and verify that + // when the add-on is enabled, it doesn't do anything - DoH remains turned on. + setFailingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.DISABLED_PREF); + Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 2); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is( + Preferences.get(prefs.DISABLED_PREF, false), + true, + "Disabled state recorded." + ); + is( + Preferences.get(prefs.BREADCRUMB_PREF), + undefined, + "Breadcrumb not saved." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + undefined, + "TRR selection not performed." + ); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + + // Restart the controller for good measure. + await restartDoHController(); + await ensureNoTRRModeChange(undefined); + ensureNoTRRSelectionTelemetry(); + ensureNoHeuristicsTelemetry(); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + is(Preferences.get(prefs.NETWORK_TRR_MODE_PREF), 2, "TRR mode preserved."); + ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/browser_doorhangerUserReject.js b/browser/components/doh/test/browser/browser_doorhangerUserReject.js new file mode 100644 index 0000000000..627ca48db2 --- /dev/null +++ b/browser/components/doh/test/browser/browser_doorhangerUserReject.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testDoorhangerUserReject() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "reject" button. + let button = panel.querySelector(".popup-notification-secondary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await prefPromise; + + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIDisabled", + "Doorhanger decision saved." + ); + + BrowserTestUtils.removeTab(tab); + + await ensureTRRMode(undefined); + ensureNoHeuristicsTelemetry(); + is(Preferences.get(prefs.BREADCRUMB_PREF), undefined, "Breadcrumb cleared."); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Restart the controller for good measure. + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Set failing environment and trigger another network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/browser_platformDetection.js b/browser/components/doh/test/browser/browser_platformDetection.js new file mode 100644 index 0000000000..bf97cab196 --- /dev/null +++ b/browser/components/doh/test/browser/browser_platformDetection.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Heuristics: "resource:///modules/DoHHeuristics.sys.mjs", +}); + +add_task(setup); + +add_task(async function testPlatformIndications() { + // Check if the platform heuristics actually cause a "disable_doh" event + + let { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" + ); + + let mockedLinkService = { + isLinkUp: true, + linkStatusKnown: true, + linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI, + networkID: "abcd", + dnsSuffixList: [], + platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED, + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + }; + + let networkLinkServiceCID = MockRegistrar.register( + "@mozilla.org/network/network-link-service;1", + mockedLinkService + ); + + Heuristics._setMockLinkService(mockedLinkService); + registerCleanupFunction(async () => { + MockRegistrar.unregister(networkLinkServiceCID); + Heuristics._setMockLinkService(undefined); + }); + + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await ensureTRRMode(2); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.VPN_DETECTED; + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.PROXY_DETECTED; + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NRPT_DETECTED; + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + mockedLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NONE_DETECTED; + simulateNetworkChange(); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); +}); diff --git a/browser/components/doh/test/browser/browser_policyOverride.js b/browser/components/doh/test/browser/browser_policyOverride.js new file mode 100644 index 0000000000..25fd2c037e --- /dev/null +++ b/browser/components/doh/test/browser/browser_policyOverride.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +add_task(async function testPolicyOverride() { + // Set up an arbitrary enterprise policy. Its existence should be sufficient + // to disable heuristics. + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + EnableTrackingProtection: { + Value: true, + }, + }, + }); + is( + Services.policies.status, + Ci.nsIEnterprisePolicies.ACTIVE, + "Policy engine is active." + ); + + Preferences.set(prefs.ENABLED_PREF, true); + await waitForStateTelemetry(["shutdown", "policyDisabled"]); + is( + Preferences.get(prefs.BREADCRUMB_PREF), + undefined, + "Breadcrumb not saved." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + undefined, + "TRR selection not performed." + ); + is( + Preferences.get(prefs.SKIP_HEURISTICS_PREF), + true, + "Pref set to suppress CFR." + ); + ensureNoTRRSelectionTelemetry(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Clean up. + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: {}, + }); + EnterprisePolicyTesting.resetRunOnceState(); + + is( + Services.policies.status, + Ci.nsIEnterprisePolicies.INACTIVE, + "Policy engine is inactive at the end of the test" + ); +}); diff --git a/browser/components/doh/test/browser/browser_providerSteering.js b/browser/components/doh/test/browser/browser_providerSteering.js new file mode 100644 index 0000000000..069a823a07 --- /dev/null +++ b/browser/components/doh/test/browser/browser_providerSteering.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const TEST_DOMAIN = "doh.test."; +const AUTO_TRR_URI = "https://example.com/dns-query"; + +add_task(setup); + +add_task(async function testProviderSteering() { + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + let providerTestcases = [ + { + id: "provider1", + canonicalName: "foo.provider1.com", + uri: "https://foo.provider1.com/query", + }, + { + id: "provider2", + canonicalName: "bar.provider2.com", + uri: "https://bar.provider2.com/query", + }, + ]; + let configFlushPromise = DoHTestUtils.waitForConfigFlush(); + Preferences.set( + prefs.PROVIDER_STEERING_LIST_PREF, + JSON.stringify(providerTestcases) + ); + await configFlushPromise; + await checkHeuristicsTelemetry("enable_doh", "startup"); + + let testNetChangeResult = async ( + expectedURI, + heuristicsDecision, + providerName + ) => { + let trrURIChanged = TestUtils.topicObserved( + "network:trr-uri-changed", + () => { + // We need this check because this topic is observed once immediately + // after the network change when the URI is reset, and then when the + // provider steering heuristic runs and sets it to our uri. + return Services.dns.currentTrrURI == expectedURI; + } + ); + simulateNetworkChange(); + await trrURIChanged; + is( + Services.dns.currentTrrURI, + expectedURI, + `TRR URI set to ${expectedURI}` + ); + await checkHeuristicsTelemetry( + heuristicsDecision, + "netchange", + providerName + ); + }; + + for (let { id, canonicalName, uri } of providerTestcases) { + gDNSOverride.addIPOverride(TEST_DOMAIN, "9.9.9.9"); + gDNSOverride.setCnameOverride(TEST_DOMAIN, canonicalName); + await testNetChangeResult(uri, "enable_doh", id); + gDNSOverride.clearHostOverride(TEST_DOMAIN); + } + + await testNetChangeResult(AUTO_TRR_URI, "enable_doh"); + + // Just use the first provider for the remaining checks. + let provider = providerTestcases[0]; + gDNSOverride.addIPOverride(TEST_DOMAIN, "9.9.9.9"); + gDNSOverride.setCnameOverride(TEST_DOMAIN, provider.canonicalName); + await testNetChangeResult(provider.uri, "enable_doh", provider.id); + + // Set enterprise roots enabled and ensure provider steering is disabled. + Preferences.set("security.enterprise_roots.enabled", true); + await testNetChangeResult(AUTO_TRR_URI, "disable_doh"); + Preferences.reset("security.enterprise_roots.enabled"); + + // Check that provider steering is enabled again after we reset above. + await testNetChangeResult(provider.uri, "enable_doh", provider.id); + + // Trigger safesearch heuristics and ensure provider steering is disabled. + let googleDomain = "google.com."; + let googleIP = "1.1.1.1"; + let googleSafeSearchIP = "1.1.1.2"; + gDNSOverride.clearHostOverride(googleDomain); + gDNSOverride.addIPOverride(googleDomain, googleSafeSearchIP); + await testNetChangeResult(AUTO_TRR_URI, "disable_doh"); + gDNSOverride.clearHostOverride(googleDomain); + gDNSOverride.addIPOverride(googleDomain, googleIP); + + // Check that provider steering is enabled again after we reset above. + await testNetChangeResult(provider.uri, "enable_doh", provider.id); + + // Finally, provider steering should be disabled once we clear the override. + gDNSOverride.clearHostOverride(TEST_DOMAIN); + await testNetChangeResult(AUTO_TRR_URI, "enable_doh"); +}); diff --git a/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js b/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js new file mode 100644 index 0000000000..cd4356ed3f --- /dev/null +++ b/browser/components/doh/test/browser/browser_remoteSettings_newProfile.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); +add_task(setupRegion); + +async function setPrefAndWaitForConfigFlush(pref, value) { + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + Preferences.set(pref, value); + await configFlushedPromise; +} + +async function clearPrefAndWaitForConfigFlush(pref, value) { + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + Preferences.reset(pref); + await configFlushedPromise; +} + +add_task(async function testNewProfile() { + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + + let provider1 = { + id: "provider1", + uri: "https://example.org/1", + autoDefault: true, + }; + let provider2 = { + id: "provider2", + uri: "https://example.org/2", + canonicalName: "https://example.org/cname", + }; + let provider3 = { + id: "provider3", + uri: "https://example.org/3", + autoDefault: true, + }; + + await DoHTestUtils.loadRemoteSettingsProviders([ + provider1, + provider2, + provider3, + ]); + + await DoHTestUtils.loadRemoteSettingsConfig({ + id: kTestRegion.toLowerCase(), + rolloutEnabled: true, + providers: "provider1, provider3", + steeringEnabled: true, + steeringProviders: "provider2", + autoDefaultEnabled: true, + autoDefaultProviders: "provider1, provider3", + }); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + Assert.deepEqual( + DoHConfigController.currentConfig.providerList, + [provider1, provider3], + "Provider list should be loaded" + ); + is( + DoHConfigController.currentConfig.providerSteering.enabled, + true, + "Steering should be enabled" + ); + Assert.deepEqual( + DoHConfigController.currentConfig.providerSteering.providerList, + [provider2], + "Steering provider list should be loaded" + ); + is( + DoHConfigController.currentConfig.trrSelection.enabled, + true, + "TRR Selection should be enabled" + ); + Assert.deepEqual( + DoHConfigController.currentConfig.trrSelection.providerList, + [provider1, provider3], + "TRR Selection provider list should be loaded" + ); + is( + DoHConfigController.currentConfig.fallbackProviderURI, + provider1.uri, + "Fallback provider URI should be that of the first one" + ); + + // Test that overriding with prefs works. + await setPrefAndWaitForConfigFlush(prefs.PROVIDER_STEERING_PREF, false); + is( + DoHConfigController.currentConfig.providerSteering.enabled, + false, + "Provider steering should be disabled" + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await setPrefAndWaitForConfigFlush(prefs.TRR_SELECT_ENABLED_PREF, false); + is( + DoHConfigController.currentConfig.trrSelection.enabled, + false, + "TRR selection should be disabled" + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Try a regional pref this time + await setPrefAndWaitForConfigFlush( + `${kRegionalPrefNamespace}.enabled`, + false + ); + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should be disabled" + ); + await ensureTRRMode(undefined); + await ensureNoHeuristicsTelemetry(); + + await clearPrefAndWaitForConfigFlush(`${kRegionalPrefNamespace}.enabled`); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + + await DoHTestUtils.resetRemoteSettingsConfig(); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should be disabled" + ); + await ensureTRRMode(undefined); +}); diff --git a/browser/components/doh/test/browser/browser_remoteSettings_rollout.js b/browser/components/doh/test/browser/browser_remoteSettings_rollout.js new file mode 100644 index 0000000000..e0e31dd238 --- /dev/null +++ b/browser/components/doh/test/browser/browser_remoteSettings_rollout.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); +add_task(setupRegion); + +add_task(async function testPrefFirstRollout() { + let defaults = Services.prefs.getDefaultBranch(""); + + setPassingHeuristics(); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + + let configFlushedPromise = DoHTestUtils.waitForConfigFlush(); + defaults.setBoolPref(`${kRegionalPrefNamespace}.enabled`, true); + await configFlushedPromise; + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should be enabled" + ); + await ensureTRRMode(2); + + await DoHTestUtils.loadRemoteSettingsProviders([ + { + id: "provider1", + uri: "https://example.org/1", + autoDefault: true, + }, + ]); + + await DoHTestUtils.loadRemoteSettingsConfig({ + id: kTestRegion.toLowerCase(), + rolloutEnabled: true, + providers: "provider1", + }); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should still be enabled" + ); + + defaults.deleteBranch(`${kRegionalPrefNamespace}.enabled`); + await restartDoHController(); + + is( + DoHConfigController.currentConfig.enabled, + true, + "Rollout should still be enabled" + ); + await ensureTRRMode(2); + + await DoHTestUtils.resetRemoteSettingsConfig(); + + is( + DoHConfigController.currentConfig.enabled, + false, + "Rollout should not be enabled" + ); + await ensureTRRMode(undefined); +}); diff --git a/browser/components/doh/test/browser/browser_rollback.js b/browser/components/doh/test/browser/browser_rollback.js new file mode 100644 index 0000000000..b414c35ae5 --- /dev/null +++ b/browser/components/doh/test/browser/browser_rollback.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +requestLongerTimeout(2); + +add_task(setup); + +add_task(async function testRollback() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await prefPromise; + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared."); + + BrowserTestUtils.removeTab(tab); + + // Change the environment to failing and simulate a network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Trigger another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Rollback! + setPassingHeuristics(); + Preferences.reset(prefs.ENABLED_PREF); + await waitForStateTelemetry(["shutdown", "rollback"]); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); + + // Re-enable. + Preferences.set(prefs.ENABLED_PREF, true); + + await ensureTRRMode(2); + ensureNoTRRSelectionTelemetry(); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Change the environment to failing and simulate a network change. + setFailingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(0); + await checkHeuristicsTelemetry("disable_doh", "netchange"); + + // Rollback again for good measure! This time with failing heuristics. + Preferences.reset(prefs.ENABLED_PREF); + await waitForStateTelemetry(["shutdown", "rollback"]); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); + + // Re-enable. + Preferences.set(prefs.ENABLED_PREF, true); + + await ensureTRRMode(0); + ensureNoTRRSelectionTelemetry(); + await checkHeuristicsTelemetry("disable_doh", "startup"); + + // Change the environment to passing and simulate a network change. + setPassingHeuristics(); + simulateNetworkChange(); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Rollback again, this time with TRR mode set to 2 prior to doing so. + Preferences.reset(prefs.ENABLED_PREF); + await waitForStateTelemetry(["shutdown", "rollback"]); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); + + // Re-enable. + Preferences.set(prefs.ENABLED_PREF, true); + + await ensureTRRMode(2); + ensureNoTRRSelectionTelemetry(); + await checkHeuristicsTelemetry("enable_doh", "startup"); + simulateNetworkChange(); + await ensureNoTRRModeChange(2); + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + // Rollback again. This time, uninit DoHController first to ensure it reacts + // correctly at startup. + await DoHController._uninit(); + await waitForStateTelemetry(["shutdown"]); + Preferences.reset(prefs.ENABLED_PREF); + await DoHController.init(); + await ensureTRRMode(undefined); + ensureNoTRRSelectionTelemetry(); + await ensureNoHeuristicsTelemetry(); + await waitForStateTelemetry(["rollback"]); + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + await ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/browser_throttle_heuristics.js b/browser/components/doh/test/browser/browser_throttle_heuristics.js new file mode 100644 index 0000000000..7a0b22ed11 --- /dev/null +++ b/browser/components/doh/test/browser/browser_throttle_heuristics.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testHeuristicsThrottling() { + // Use a zero throttle timeout for the test. This both ensures the test has a + // short runtime as well as preventing intermittents because we thought + // something was deterministic when it wasn't. + let throttleTimeout = 0; + let rateLimit = 1; + let throttleDoneTopic = "doh:heuristics-throttle-done"; + let throttleExtendTopic = "doh:heuristics-throttle-extend"; + + Preferences.set(prefs.HEURISTICS_THROTTLE_TIMEOUT_PREF, throttleTimeout); + Preferences.set(prefs.HEURISTICS_THROTTLE_RATE_LIMIT_PREF, rateLimit); + + // Set up a passing environment and enable DoH. + let throttledPromise = TestUtils.topicObserved(throttleDoneTopic); + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + await ensureTRRMode(2); + info("waiting for throttle done"); + await throttledPromise; + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Change the environment to failing and simulate a network change. + throttledPromise = TestUtils.topicObserved(throttleDoneTopic); + simulateNetworkChange(); + info("waiting for throttle done"); + await throttledPromise; + await checkHeuristicsTelemetry("enable_doh", "netchange"); + + /* Simulate two consecutive network changes and check that we throttled the + * second heuristics run. */ + + // We wait for the throttle timer to fire twice - the first time, a fresh + // heuristics run will be performed since it was queued while throttling. + // This triggers another throttle timeout which is the second one we wait for. + throttledPromise = TestUtils.topicObserved(throttleDoneTopic).then(() => + TestUtils.topicObserved(throttleDoneTopic) + ); + simulateNetworkChange(); + simulateNetworkChange(); + info("waiting for throttle done"); + await throttledPromise; + await checkHeuristicsTelemetryMultiple(["netchange", "throttled"]); + + /* Simulate several consecutive network changes at a rate that exceeds the + * rate limit and check that we only record two heuristics runs in total - + * one for the initial netchange and one throttled run at the end. */ + + // We wait for the throttle timer to be extended twice. + let throttleExtendPromise = TestUtils.topicObserved(throttleExtendTopic); + let throttleExtendPromise2 = throttleExtendPromise.then(() => + TestUtils.topicObserved(throttleExtendTopic) + ); + + // Again, we wait for the timer to fire twice - once for the volatile period + // which results in a throttled heuristics run, and once after it with no run. + throttledPromise = throttleExtendPromise2 + .then(() => TestUtils.topicObserved(throttleDoneTopic)) + .then(() => TestUtils.topicObserved(throttleDoneTopic)); + + // Simulate three network changes: + // - The first one starts the throttle timer + // - The second one is within the limit of 1. + // - The third one exceeds the limit and extends the throttle period. + simulateNetworkChange(); + simulateNetworkChange(); + simulateNetworkChange(); + + // First throttle extension should happen now. + info("waiting for throttle extend"); + await throttleExtendPromise; + + // Two more network changes to once again extend the throttle period. + simulateNetworkChange(); + simulateNetworkChange(); + + // Now the second extension should be detected. + info("waiting for throttle done"); + await throttleExtendPromise2; + + // Finally, we wait for the throttle period to finish. + info("waiting for throttle done"); + await throttledPromise; + + await checkHeuristicsTelemetryMultiple(["netchange", "throttled"]); +}); diff --git a/browser/components/doh/test/browser/browser_trrSelect.js b/browser/components/doh/test/browser/browser_trrSelect.js new file mode 100644 index 0000000000..68861be8b8 --- /dev/null +++ b/browser/components/doh/test/browser/browser_trrSelect.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +async function waitForStartup() { + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); +} + +async function setPrefAndWaitForConfigFlush(pref, value) { + let configFlushed = DoHTestUtils.waitForConfigFlush(); + if (value) { + Preferences.set(pref, value); + } else { + Preferences.reset(pref); + } + await configFlushed; + await waitForStartup(); +} + +add_task(setup); + +add_task(async function testTRRSelect() { + // Clean start: doh-rollout.uri should be set after init. + setPassingHeuristics(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + + // Wait for heuristics to complete. + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Reset and restart the controller for good measure. + Preferences.reset(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF); + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + await restartDoHController(); + await waitForStartup(); + + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + + // Disable committing. The committed URI should be reset to the + // default provider and the dry-run-result should persist. + prefPromise = TestUtils.waitForPrefChange( + prefs.TRR_SELECT_URI_PREF, + newVal => newVal == "https://example.com/1" + ); + await setPrefAndWaitForConfigFlush(prefs.TRR_SELECT_COMMIT_PREF, false); + await prefPromise; + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "Default TRR selected." + ); + try { + await BrowserTestUtils.waitForCondition(() => { + return !Preferences.isSet(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF); + }); + ok(false, "Dry run result was cleared, fail!"); + } catch (e) { + ok(true, "Dry run result was not cleared."); + } + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + "https://example.com/dns-query", + "dry-run result has the correct value." + ); + + // Reset again, dry-run-result should be recorded but not + // be committed. Committing is still disabled from above. + Preferences.reset(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF); + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + await restartDoHController(); + await waitForStartup(); + + try { + await BrowserTestUtils.waitForCondition(() => { + return ( + Preferences.get(prefs.TRR_SELECT_URI_PREF) == + "https://example.com/dns-query" + ); + }); + ok(false, "Dry run result got committed, fail!"); + } catch (e) { + ok(true, "Dry run result did not get committed"); + } + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "Default TRR selected." + ); + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + "https://example.com/dns-query", + "TRR selection complete, dry-run result recorded." + ); + + // Reset doh-rollout.uri, and change the dry-run-result to another one on the + // default list. After init, the existing dry-run-result should be committed. + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + Preferences.set( + prefs.TRR_SELECT_DRY_RUN_RESULT_PREF, + "https://example.com/2" + ); + prefPromise = TestUtils.waitForPrefChange( + prefs.TRR_SELECT_URI_PREF, + newVal => newVal == "https://example.com/2" + ); + await setPrefAndWaitForConfigFlush(prefs.TRR_SELECT_COMMIT_PREF, true); + await prefPromise; + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/2", + "TRR selection complete, existing dry-run-result committed." + ); + + // Reset doh-rollout.uri, and change the dry-run-result to another one NOT on + // default list. After init, a new TRR should be selected and committed. + prefPromise = TestUtils.waitForPrefChange( + prefs.TRR_SELECT_URI_PREF, + newVal => newVal == "https://example.com/dns-query" + ); + Preferences.reset(prefs.TRR_SELECT_URI_PREF); + Preferences.set( + prefs.TRR_SELECT_DRY_RUN_RESULT_PREF, + "https://example.com/4" + ); + await restartDoHController(); + await prefPromise; + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete, existing dry-run-result discarded and refreshed." + ); +}); diff --git a/browser/components/doh/test/browser/browser_trrSelection_disable.js b/browser/components/doh/test/browser/browser_trrSelection_disable.js new file mode 100644 index 0000000000..dc7bd68262 --- /dev/null +++ b/browser/components/doh/test/browser/browser_trrSelection_disable.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testTrrSelectionDisable() { + // Turn off TRR Selection. + let configFlushed = DoHTestUtils.waitForConfigFlush(); + Preferences.set(prefs.TRR_SELECT_ENABLED_PREF, false); + await configFlushed; + + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + Preferences.set(prefs.ENABLED_PREF, true); + await BrowserTestUtils.waitForCondition(() => { + return Preferences.get(prefs.BREADCRUMB_PREF); + }); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + undefined, + "TRR selection dry run not performed." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "doh-rollout.uri set to first provider in the list." + ); + ensureNoTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + await BrowserTestUtils.waitForCondition(() => { + return Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF); + }); + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb not cleared."); + + BrowserTestUtils.removeTab(tab); + + // Restart the controller for good measure. + await restartDoHController(); + ensureNoTRRSelectionTelemetry(); + is( + Preferences.get(prefs.TRR_SELECT_DRY_RUN_RESULT_PREF), + undefined, + "TRR selection dry run not performed." + ); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/1", + "doh-rollout.uri set to first provider in the list." + ); + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); +}); diff --git a/browser/components/doh/test/browser/browser_userInterference.js b/browser/components/doh/test/browser/browser_userInterference.js new file mode 100644 index 0000000000..96d1e3c94c --- /dev/null +++ b/browser/components/doh/test/browser/browser_userInterference.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function testUserInterference() { + // Set up a passing environment and enable DoH. + setPassingHeuristics(); + let promise = waitForDoorhanger(); + let prefPromise = TestUtils.waitForPrefChange(prefs.BREADCRUMB_PREF); + Preferences.set(prefs.ENABLED_PREF, true); + + await prefPromise; + is(Preferences.get(prefs.BREADCRUMB_PREF), true, "Breadcrumb saved."); + is( + Preferences.get(prefs.TRR_SELECT_URI_PREF), + "https://example.com/dns-query", + "TRR selection complete." + ); + await checkTRRSelectionTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + let panel = await promise; + + prefPromise = TestUtils.waitForPrefChange( + prefs.DOORHANGER_USER_DECISION_PREF + ); + + // Click the doorhanger's "accept" button. + let button = panel.querySelector(".popup-notification-primary-button"); + promise = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + EventUtils.synthesizeMouseAtCenter(button, {}); + await promise; + await prefPromise; + + is( + Preferences.get(prefs.DOORHANGER_USER_DECISION_PREF), + "UIOk", + "Doorhanger decision saved." + ); + + BrowserTestUtils.removeTab(tab); + + await ensureTRRMode(2); + await checkHeuristicsTelemetry("enable_doh", "startup"); + + // Set the TRR mode pref manually and ensure we respect this. + Preferences.set(prefs.NETWORK_TRR_MODE_PREF, 3); + await ensureTRRMode(undefined); + + // Simulate a network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + is( + Preferences.get(prefs.DISABLED_PREF, false), + true, + "Manual disable recorded." + ); + is(Preferences.get(prefs.BREADCRUMB_PREF), undefined, "Breadcrumb cleared."); + + // Simulate another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); + + // Restart the controller for good measure. + await restartDoHController(); + await ensureNoTRRModeChange(undefined); + ensureNoTRRSelectionTelemetry(); + ensureNoHeuristicsTelemetry(); + + // Simulate another network change. + simulateNetworkChange(); + await ensureNoTRRModeChange(undefined); + ensureNoHeuristicsTelemetry(); +}); diff --git a/browser/components/doh/test/browser/head.js b/browser/components/doh/test/browser/head.js new file mode 100644 index 0000000000..20d8156cc0 --- /dev/null +++ b/browser/components/doh/test/browser/head.js @@ -0,0 +1,365 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + DoHConfigController: "resource:///modules/DoHConfig.sys.mjs", + DoHController: "resource:///modules/DoHController.sys.mjs", + DoHTestUtils: "resource://testing-common/DoHTestUtils.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RegionTestUtils: "resource://testing-common/RegionTestUtils.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ASRouter: "resource://activity-stream/lib/ASRouter.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gDNSOverride", + "@mozilla.org/network/native-dns-override;1", + "nsINativeDNSResolverOverride" +); + +const { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); + +const EXAMPLE_URL = "https://example.com/"; + +const prefs = { + TESTING_PREF: "doh-rollout._testing", + ENABLED_PREF: "doh-rollout.enabled", + ROLLOUT_TRR_MODE_PREF: "doh-rollout.mode", + NETWORK_TRR_MODE_PREF: "network.trr.mode", + CONFIRMATION_NS_PREF: "network.trr.confirmationNS", + BREADCRUMB_PREF: "doh-rollout.self-enabled", + DOORHANGER_USER_DECISION_PREF: "doh-rollout.doorhanger-decision", + DISABLED_PREF: "doh-rollout.disable-heuristics", + SKIP_HEURISTICS_PREF: "doh-rollout.skipHeuristicsCheck", + CLEAR_ON_SHUTDOWN_PREF: "doh-rollout.clearModeOnShutdown", + FIRST_RUN_PREF: "doh-rollout.doneFirstRun", + PROVIDER_LIST_PREF: "doh-rollout.provider-list", + TRR_SELECT_ENABLED_PREF: "doh-rollout.trr-selection.enabled", + TRR_SELECT_URI_PREF: "doh-rollout.uri", + TRR_SELECT_COMMIT_PREF: "doh-rollout.trr-selection.commit-result", + TRR_SELECT_DRY_RUN_RESULT_PREF: "doh-rollout.trr-selection.dry-run-result", + PROVIDER_STEERING_PREF: "doh-rollout.provider-steering.enabled", + PROVIDER_STEERING_LIST_PREF: "doh-rollout.provider-steering.provider-list", + NETWORK_DEBOUNCE_TIMEOUT_PREF: "doh-rollout.network-debounce-timeout", + HEURISTICS_THROTTLE_TIMEOUT_PREF: "doh-rollout.heuristics-throttle-timeout", + HEURISTICS_THROTTLE_RATE_LIMIT_PREF: + "doh-rollout.heuristics-throttle-rate-limit", +}; + +const CFR_PREF = "browser.newtabpage.activity-stream.asrouter.providers.cfr"; +const CFR_JSON = { + id: "cfr", + enabled: true, + type: "local", + localProvider: "CFRMessageProvider", + categories: ["cfrAddons", "cfrFeatures"], +}; + +async function setup() { + await DoHController._uninit(); + await DoHConfigController._uninit(); + SpecialPowers.pushPrefEnv({ + set: [["security.notification_enable_delay", 0]], + }); + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.telemetry.clearEvents(); + + // Enable the CFR. + Preferences.set(CFR_PREF, JSON.stringify(CFR_JSON)); + + // Tell DoHController that this isn't real life. + Preferences.set(prefs.TESTING_PREF, true); + + // Avoid non-local connections to the TRR endpoint. + Preferences.set(prefs.CONFIRMATION_NS_PREF, "skip"); + + // Enable trr selection and provider steeringfor tests. This is off + // by default so it can be controlled via Normandy. + Preferences.set(prefs.TRR_SELECT_ENABLED_PREF, true); + Preferences.set(prefs.PROVIDER_STEERING_PREF, true); + + // Enable committing the TRR selection. This pref ships false by default so + // it can be controlled e.g. via Normandy, but for testing let's set enable. + Preferences.set(prefs.TRR_SELECT_COMMIT_PREF, true); + + // Clear mode on shutdown by default. + Preferences.set(prefs.CLEAR_ON_SHUTDOWN_PREF, true); + + // Generally don't bother with debouncing or throttling. + // The throttling test will set this explicitly. + Preferences.set(prefs.NETWORK_DEBOUNCE_TIMEOUT_PREF, -1); + Preferences.set(prefs.HEURISTICS_THROTTLE_TIMEOUT_PREF, -1); + + // Set up heuristics, all passing by default. + + // Google safesearch overrides + gDNSOverride.addIPOverride("www.google.com.", "1.1.1.1"); + gDNSOverride.addIPOverride("google.com.", "1.1.1.1"); + gDNSOverride.addIPOverride("forcesafesearch.google.com.", "1.1.1.2"); + + // YouTube safesearch overrides + gDNSOverride.addIPOverride("www.youtube.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("m.youtube.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("youtubei.googleapis.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("youtube.googleapis.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("www.youtube-nocookie.com.", "2.1.1.1"); + gDNSOverride.addIPOverride("restrict.youtube.com.", "2.1.1.2"); + gDNSOverride.addIPOverride("restrictmoderate.youtube.com.", "2.1.1.2"); + + // Zscaler override + gDNSOverride.addIPOverride("sitereview.zscaler.com.", "3.1.1.1"); + + // Global canary + gDNSOverride.addIPOverride("use-application-dns.net.", "4.1.1.1"); + + await DoHTestUtils.resetRemoteSettingsConfig(false); + + await DoHConfigController.init(); + await DoHController.init(); + + await waitForStateTelemetry(["rollback"]); + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.clearEvents(); + gDNSOverride.clearOverrides(); + if (ASRouter.state.messageBlockList.includes("DOH_ROLLOUT_CONFIRMATION")) { + await ASRouter.unblockMessageById("DOH_ROLLOUT_CONFIRMATION"); + } + // The CFR pref is set to an empty array in user.js for testing profiles, + // so "reset" it back to that value. + Preferences.set(CFR_PREF, "[]"); + await DoHController._uninit(); + Services.telemetry.clearEvents(); + Preferences.reset(Object.values(prefs)); + await DoHTestUtils.resetRemoteSettingsConfig(false); + await DoHController.init(); + }); +} + +const kTestRegion = "DE"; +const kRegionalPrefNamespace = `doh-rollout.${kTestRegion.toLowerCase()}`; + +async function setupRegion() { + Region._home = null; + RegionTestUtils.setNetworkRegion(kTestRegion); + await Region._fetchRegion(); + is(Region.home, kTestRegion, "Should have correct region"); + Preferences.reset("doh-rollout.home-region"); + await DoHConfigController.loadRegion(); +} + +async function checkTRRSelectionTelemetry() { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + return events && events.length; + }); + events = events.filter( + e => + e[1] == "security.doh.trrPerformance" && + e[2] == "trrselect" && + e[3] == "dryrunresult" + ); + is(events.length, 1, "Found the expected trrselect event."); + is( + events[0][4], + "https://example.com/dns-query", + "The event records the expected decision" + ); +} + +function ensureNoTRRSelectionTelemetry() { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + if (!events) { + ok(true, "Found no trrselect events."); + return; + } + events = events.filter( + e => + e[1] == "security.doh.trrPerformance" && + e[2] == "trrselect" && + e[3] == "dryrunresult" + ); + is(events.length, 0, "Found no trrselect events."); +} + +async function checkHeuristicsTelemetry( + decision, + evaluateReason, + steeredProvider = "" +) { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + events = events?.filter( + e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics" + ); + return events?.length; + }); + is(events.length, 1, "Found the expected heuristics event."); + is(events[0][4], decision, "The event records the expected decision"); + if (evaluateReason) { + is(events[0][5].evaluateReason, evaluateReason, "Got the expected reason."); + } + is(events[0][5].steeredProvider, steeredProvider, "Got expected provider."); + + // After checking the event, clear all telemetry. Since we check for a single + // event above, this ensures all heuristics events are intentional and tested. + // TODO: Test events other than heuristics. Those tests would also work the + // same way, so as to test one event at a time, and this clearEvents() call + // will continue to exist as-is. + Services.telemetry.clearEvents(); +} + +async function checkHeuristicsTelemetryMultiple(expectedEvaluateReasons) { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + if (events && events.length) { + events = events.filter( + e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics" + ); + if (events.length == expectedEvaluateReasons.length) { + return true; + } + } + return false; + }); + is( + events.length, + expectedEvaluateReasons.length, + "Found the expected heuristics events." + ); + for (let reason of expectedEvaluateReasons) { + let event = events.find(e => e[5].evaluateReason == reason); + is(event[5].evaluateReason, reason, `${reason} event found`); + } + Services.telemetry.clearEvents(); +} + +function ensureNoHeuristicsTelemetry() { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + if (!events) { + ok(true, "Found no heuristics events."); + return; + } + events = events.filter( + e => e[1] == "doh" && e[2] == "evaluate_v2" && e[3] == "heuristics" + ); + is(events.length, 0, "Found no heuristics events."); +} + +async function waitForStateTelemetry(expectedStates) { + let events; + await TestUtils.waitForCondition(() => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS + ).parent; + return events; + }); + events = events.filter(e => e[1] == "doh" && e[2] == "state"); + info(events); + is(events.length, expectedStates.length, "Found the expected state events."); + for (let state of expectedStates) { + let event = events.find(e => e[3] == state); + is(event[3], state, `${state} state found`); + } + Services.telemetry.clearEvents(); +} + +async function restartDoHController() { + let oldMode = Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF); + await DoHController._uninit(); + let newMode = Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF); + let expectClear = Preferences.get(prefs.CLEAR_ON_SHUTDOWN_PREF); + is( + newMode, + expectClear ? undefined : oldMode, + `Mode was ${expectClear ? "cleared" : "persisted"} on shutdown.` + ); + await DoHController.init(); +} + +// setPassing/FailingHeuristics are used generically to test that DoH is enabled +// or disabled correctly. We use the zscaler canary arbitrarily here, individual +// heuristics are tested separately. +function setPassingHeuristics() { + gDNSOverride.clearHostOverride("sitereview.zscaler.com."); + gDNSOverride.addIPOverride("sitereview.zscaler.com.", "3.1.1.1"); +} + +function setFailingHeuristics() { + gDNSOverride.clearHostOverride("sitereview.zscaler.com."); + gDNSOverride.addIPOverride("sitereview.zscaler.com.", "213.152.228.242"); +} + +async function waitForDoorhanger() { + const popupID = "contextual-feature-recommendation"; + const bucketID = "DOH_ROLLOUT_CONFIRMATION"; + let panel; + await BrowserTestUtils.waitForEvent(document, "popupshown", true, event => { + panel = event.originalTarget; + let popupNotification = event.originalTarget.firstChild; + return ( + popupNotification && + popupNotification.notification && + popupNotification.notification.id == popupID && + popupNotification.getAttribute("data-notification-bucket") == bucketID + ); + }); + return panel; +} + +function simulateNetworkChange() { + // The networkStatus API does not actually propagate the link status we supply + // here, but rather sends the link status from the NetworkLinkService. + // This means there's no point sending a down and then an up - the extension + // will just receive "up" twice. + // TODO: Implement a mock NetworkLinkService and use it to also simulate + // network down events. + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); +} + +async function ensureTRRMode(mode) { + await TestUtils.waitForCondition(() => { + return Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF) === mode; + }); + is(Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF), mode, `TRR mode is ${mode}`); +} + +async function ensureNoTRRModeChange(mode) { + try { + // Try and wait for the TRR pref to change... waitForCondition should throw + // after trying for a while. + await TestUtils.waitForCondition(() => { + return Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF) !== mode; + }); + // If we reach this, the waitForCondition didn't throw. Fail! + ok(false, "TRR mode changed when it shouldn't have!"); + } catch (e) { + // Assert for clarity. + is( + Preferences.get(prefs.ROLLOUT_TRR_MODE_PREF), + mode, + "No change in TRR mode" + ); + } +} diff --git a/browser/components/doh/test/unit/head.js b/browser/components/doh/test/unit/head.js new file mode 100644 index 0000000000..eb19dac8a1 --- /dev/null +++ b/browser/components/doh/test/unit/head.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +let h2Port, trrServer1, trrServer2, trrList; +let DNSLookup, LookupAggregator, TRRRacer; + +function readFile(file) { + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + fstream.init(file, -1, 0, 0); + let data = NetUtil.readInputStreamToString(fstream, fstream.available()); + fstream.close(); + return data; +} + +function addCertFromFile(certdb, filename, trustString) { + let certFile = do_get_file(filename, false); + let pem = readFile(certFile) + .replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/[\r\n]/g, ""); + certdb.addCertFromBase64(pem, trustString); +} + +function ensureNoTelemetry() { + let events = + Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent || []; + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.ok(!events.length); +} + +function setup() { + h2Port = Services.env.get("MOZHTTP2_PORT"); + Assert.notEqual(h2Port, null); + Assert.notEqual(h2Port, ""); + + // Set to allow the cert presented by our H2 server + do_get_profile(); + + Services.prefs.setBoolPref("network.http.http2.enabled", true); + + // use the h2 server as DOH provider + trrServer1 = `https://foo.example.com:${h2Port}/doh?responseIP=1.1.1.1`; + trrServer2 = `https://foo.example.com:${h2Port}/doh?responseIP=2.2.2.2`; + trrList = [trrServer1, trrServer2]; + // make all native resolve calls "secretly" resolve localhost instead + Services.prefs.setBoolPref("network.dns.native-is-localhost", true); + + // The moz-http2 cert is for foo.example.com and is signed by http2-ca.pem + // so add that cert to the trust list as a signing cert. // the foo.example.com domain name. + let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + addCertFromFile(certdb, "http2-ca.pem", "CTu,u,u"); + + Services.prefs.setIntPref("doh-rollout.trrRace.randomSubdomainCount", 2); + + Services.prefs.setCharPref( + "doh-rollout.trrRace.popularDomains", + "foo.example.com., bar.example.com." + ); + + Services.prefs.setCharPref( + "doh-rollout.trrRace.canonicalDomain", + "firefox-dns-perf-test.net." + ); + + let TRRPerformance = ChromeUtils.importESModule( + "resource:///modules/TRRPerformance.sys.mjs" + ); + + DNSLookup = TRRPerformance.DNSLookup; + LookupAggregator = TRRPerformance.LookupAggregator; + TRRRacer = TRRPerformance.TRRRacer; + + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("network.http.http2.enabled"); + Services.prefs.clearUserPref("network.dns.native-is-localhost"); + + Services.telemetry.canRecordExtended = oldCanRecord; + }); +} diff --git a/browser/components/doh/test/unit/test_DNSLookup.js b/browser/components/doh/test/unit/test_DNSLookup.js new file mode 100644 index 0000000000..5951445f13 --- /dev/null +++ b/browser/components/doh/test/unit/test_DNSLookup.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function test_SuccessfulRandomDNSLookup() { + let deferred = PromiseUtils.defer(); + let lookup = new DNSLookup( + null, + trrServer1, + (request, record, status, usedDomain, retryCount) => { + deferred.resolve({ request, record, status, usedDomain, retryCount }); + } + ); + lookup.doLookup(); + let result = await deferred.promise; + Assert.ok(result.usedDomain.endsWith(".firefox-dns-perf-test.net.")); + Assert.equal(result.status, Cr.NS_OK); + Assert.ok(result.record.QueryInterface(Ci.nsIDNSAddrRecord)); + Assert.ok(result.record.IsTRR()); + Assert.greater(result.record.trrFetchDuration, 0); + Assert.equal(result.retryCount, 1); +}); + +add_task(async function test_SuccessfulSpecifiedDNSLookup() { + let deferred = PromiseUtils.defer(); + let lookup = new DNSLookup( + "foo.example.com", + trrServer1, + (request, record, status, usedDomain, retryCount) => { + deferred.resolve({ request, record, status, usedDomain, retryCount }); + } + ); + lookup.doLookup(); + let result = await deferred.promise; + Assert.equal(result.usedDomain, "foo.example.com"); + Assert.equal(result.status, Cr.NS_OK); + Assert.ok(result.record.QueryInterface(Ci.nsIDNSAddrRecord)); + Assert.ok(result.record.IsTRR()); + Assert.greater(result.record.trrFetchDuration, 0); + Assert.equal(result.retryCount, 1); +}); + +add_task(async function test_FailedDNSLookup() { + let deferred = PromiseUtils.defer(); + let lookup = new DNSLookup( + null, + `https://foo.example.com:${h2Port}/doh?responseIP=none`, + (request, record, status, usedDomain, retryCount) => { + deferred.resolve({ request, record, status, usedDomain, retryCount }); + } + ); + lookup.doLookup(); + let result = await deferred.promise; + Assert.ok(result.usedDomain.endsWith(".firefox-dns-perf-test.net.")); + Assert.notEqual(result.status, Cr.NS_OK); + Assert.equal(result.record, null); + Assert.equal(result.retryCount, 3); +}); diff --git a/browser/components/doh/test/unit/test_LookupAggregator.js b/browser/components/doh/test/unit/test_LookupAggregator.js new file mode 100644 index 0000000000..c2050a8ca8 --- /dev/null +++ b/browser/components/doh/test/unit/test_LookupAggregator.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +add_task(setup); + +async function helper_SuccessfulLookupAggregator( + networkUnstable = false, + captivePortal = false +) { + let deferred = PromiseUtils.defer(); + let aggregator = new LookupAggregator(() => deferred.resolve(), trrList); + // The aggregator's domain list should correctly reflect our set + // prefs for number of random subdomains (2) and the list of + // popular domains. + Assert.equal(aggregator.domains[0], null); + Assert.equal(aggregator.domains[1], null); + Assert.equal(aggregator.domains[2], "foo.example.com."); + Assert.equal(aggregator.domains[3], "bar.example.com."); + Assert.equal(aggregator.totalLookups, 8); // 2 TRRs * 4 domains. + + if (networkUnstable) { + aggregator.markUnstableNetwork(); + } + if (captivePortal) { + aggregator.markCaptivePortal(); + } + aggregator.run(); + await deferred.promise; + Assert.ok(!aggregator.aborted); + Assert.equal(aggregator.networkUnstable, networkUnstable); + Assert.equal(aggregator.captivePortal, captivePortal); + Assert.equal(aggregator.results.length, aggregator.totalLookups); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, aggregator.totalLookups); + + for (let event of events) { + info(JSON.stringify(event)); + Assert.equal(event[1], "security.doh.trrPerformance"); + Assert.equal(event[2], "resolved"); + Assert.equal(event[3], "record"); + Assert.equal(event[4], "success"); + } + + // We only need to check the payload of each event from here on. + events = events.map(e => e[5]); + + for (let trr of [trrServer1, trrServer2]) { + // There should be two results for random subdomains. + let results = aggregator.results.filter(r => { + return r.trr == trr && r.domain.endsWith(".firefox-dns-perf-test.net."); + }); + Assert.equal(results.length, 2); + + for (let result of results) { + Assert.ok(result.domain.endsWith(".firefox-dns-perf-test.net.")); + Assert.equal(result.trr, trr); + Assert.ok(Components.isSuccessCode(result.status)); + Assert.greater(result.time, 0); + Assert.equal(result.retryCount, 1); + + let matchingEvents = events.filter( + e => e.domain == result.domain && e.trr == result.trr + ); + Assert.equal(matchingEvents.length, 1); + let e = matchingEvents.pop(); + for (let key of Object.keys(result)) { + Assert.equal(e[key], result[key].toString()); + } + Assert.equal(e.networkUnstable, networkUnstable.toString()); + Assert.equal(e.captivePortal, captivePortal.toString()); + } + + // There should be two results for the popular domains. + results = aggregator.results.filter(r => { + return r.trr == trr && !r.domain.endsWith(".firefox-dns-perf-test.net."); + }); + Assert.equal(results.length, 2); + + Assert.ok( + [results[0].domain, results[1].domain].includes("foo.example.com.") + ); + Assert.ok( + [results[0].domain, results[1].domain].includes("bar.example.com.") + ); + for (let result of results) { + Assert.equal(result.trr, trr); + Assert.equal(result.status, Cr.NS_OK); + Assert.greater(result.time, 0); + Assert.equal(result.retryCount, 1); + + let matchingEvents = events.filter( + e => e.domain == result.domain && e.trr == result.trr + ); + Assert.equal(matchingEvents.length, 1); + let e = matchingEvents.pop(); + for (let key of Object.keys(result)) { + Assert.equal(e[key], result[key].toString()); + } + Assert.equal(e.networkUnstable, networkUnstable.toString()); + Assert.equal(e.captivePortal, captivePortal.toString()); + } + } + + Services.telemetry.clearEvents(); +} + +add_task(async function test_SuccessfulLookupAggregator() { + await helper_SuccessfulLookupAggregator(false, false); + await helper_SuccessfulLookupAggregator(false, true); + await helper_SuccessfulLookupAggregator(true, false); + await helper_SuccessfulLookupAggregator(true, true); +}); + +add_task(async function test_AbortedLookupAggregator() { + let deferred = PromiseUtils.defer(); + let aggregator = new LookupAggregator(() => deferred.resolve(), trrList); + // The aggregator's domain list should correctly reflect our set + // prefs for number of random subdomains (2) and the list of + // popular domains. + Assert.equal(aggregator.domains[0], null); + Assert.equal(aggregator.domains[1], null); + Assert.equal(aggregator.domains[2], "foo.example.com."); + Assert.equal(aggregator.domains[3], "bar.example.com."); + Assert.equal(aggregator.totalLookups, 8); // 2 TRRs * 4 domains. + + // The aggregator should never call the onComplete callback. To test + // this, race the deferred promise with a 3 second timeout. The timeout + // should win, since the deferred promise should never resolve. + let timeoutPromise = new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => resolve("timeout"), 3000); + }); + aggregator.run(); + aggregator.abort(); + let winner = await Promise.race([deferred.promise, timeoutPromise]); + Assert.equal(winner, "timeout"); + Assert.ok(aggregator.aborted); + Assert.ok(!aggregator.networkUnstable); + Assert.ok(!aggregator.captivePortal); + + // Ensure we send no telemetry for an aborted run! + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok( + !events || !events.filter(e => e[1] == "security.doh.trrPerformance").length + ); +}); diff --git a/browser/components/doh/test/unit/test_TRRRacer.js b/browser/components/doh/test/unit/test_TRRRacer.js new file mode 100644 index 0000000000..d9a0455ba0 --- /dev/null +++ b/browser/components/doh/test/unit/test_TRRRacer.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +add_task(setup); + +add_task(async function test_TRRRacer_cleanRun() { + let deferred = PromiseUtils.defer(); + let racer = new TRRRacer(() => { + deferred.resolve(); + deferred.resolved = true; + }, trrList); + racer.run(); + + await deferred.promise; + Assert.equal(racer._retryCount, 1); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, racer._aggregator.totalLookups); + + Services.telemetry.clearEvents(); + + // Simulate network changes and ensure no re-runs since it's already complete. + async function testNetworkChange(captivePortal = false) { + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + + Assert.ok(!racer._aggregator.aborted); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-success"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); + } + + Assert.equal(racer._retryCount, 1); + ensureNoTelemetry(); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-abort"); + } + } + + testNetworkChange(false); + testNetworkChange(true); +}); + +async function test_TRRRacer_networkFlux_helper(captivePortal = false) { + let deferred = PromiseUtils.defer(); + let racer = new TRRRacer(() => { + deferred.resolve(); + deferred.resolved = true; + }, trrList); + racer.run(); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + + Assert.ok(racer._aggregator.aborted); + ensureNoTelemetry(); + Assert.equal(racer._retryCount, 1); + Assert.ok(!deferred.resolved); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-success"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); + } + + Assert.ok(!racer._aggregator.aborted); + await deferred.promise; + + Assert.equal(racer._retryCount, 2); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, racer._aggregator.totalLookups); + + Services.telemetry.clearEvents(); + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-abort"); + } +} + +add_task(async function test_TRRRacer_networkFlux() { + await test_TRRRacer_networkFlux_helper(false); + await test_TRRRacer_networkFlux_helper(true); +}); + +async function test_TRRRacer_maxRetries_helper(captivePortal = false) { + let deferred = PromiseUtils.defer(); + let racer = new TRRRacer(() => { + deferred.resolve(); + deferred.resolved = true; + }, trrList); + racer.run(); + info("ran new racer"); + // Start at i = 1 since we're already at retry #1. + for (let i = 1; i < 5; ++i) { + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + + info("notified observers"); + + Assert.ok(racer._aggregator.aborted); + ensureNoTelemetry(); + Assert.equal(racer._retryCount, i); + Assert.ok(!deferred.resolved); + + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-success"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "up"); + } + } + + // Simulate a "down" network event and ensure we still send telemetry + // since we've maxed out our retry count. + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login"); + } else { + Services.obs.notifyObservers(null, "network:link-status-changed", "down"); + } + Assert.ok(!racer._aggregator.aborted); + await deferred.promise; + Assert.equal(racer._retryCount, 5); + + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).parent; + Assert.ok(events); + events = events.filter(e => e[1] == "security.doh.trrPerformance"); + Assert.equal(events.length, racer._aggregator.totalLookups); + + Services.telemetry.clearEvents(); + if (captivePortal) { + Services.obs.notifyObservers(null, "captive-portal-login-abort"); + } +} + +add_task(async function test_TRRRacer_maxRetries() { + await test_TRRRacer_maxRetries_helper(false); + await test_TRRRacer_maxRetries_helper(true); +}); + +add_task(async function test_TRRRacer_getFastestTRRFromResults() { + let results = [ + { trr: "trr1", time: 10 }, + { trr: "trr2", time: 100 }, + { trr: "trr1", time: 1000 }, + { trr: "trr2", time: 110 }, + { trr: "trr3", time: -1 }, + { trr: "trr4", time: -1 }, + { trr: "trr4", time: -1 }, + { trr: "trr4", time: 1 }, + { trr: "trr4", time: 1 }, + { trr: "trr5", time: 10 }, + { trr: "trr5", time: 20 }, + { trr: "trr5", time: 1000 }, + ]; + let racer = new TRRRacer(undefined, trrList); + let fastest = racer._getFastestTRRFromResults(results); + // trr1's geometric mean is 100 + // trr2's geometric mean is 110 + // trr3 has no valid times, excluded + // trr4 has 50% invalid times, excluded + // trr5's geometric mean is ~58.5, it's the winner. + Assert.equal(fastest, "trr5"); + + // When no valid entries are available, undefined is the default output. + results = [ + { trr: "trr1", time: -1 }, + { trr: "trr2", time: -1 }, + ]; + + fastest = racer._getFastestTRRFromResults(results); + Assert.equal(fastest, undefined); + + // When passing `returnRandomDefault = true`, verify that both TRRs are + // possible outputs. The probability that the randomization is working + // correctly and we consistently get the same output after 50 iterations is + // 0.5^50 ~= 8.9*10^-16. + let firstResult = racer._getFastestTRRFromResults(results, true); + while (racer._getFastestTRRFromResults(results, true) == firstResult) { + continue; + } + Assert.ok(true, "Both TRRs were possible outputs when all results invalid."); +}); diff --git a/browser/components/doh/test/unit/test_heuristics.js b/browser/components/doh/test/unit/test_heuristics.js new file mode 100644 index 0000000000..a6a6c9b6c9 --- /dev/null +++ b/browser/components/doh/test/unit/test_heuristics.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +let cid; + +async function SetMockParentalControlEnabled(aEnabled) { + if (cid) { + MockRegistrar.unregister(cid); + } + + let parentalControlsService = { + parentalControlsEnabled: aEnabled, + QueryInterface: ChromeUtils.generateQI(["nsIParentalControlsService"]), + }; + cid = MockRegistrar.register( + "@mozilla.org/parental-controls-service;1", + parentalControlsService + ); +} + +registerCleanupFunction(() => { + if (cid) { + MockRegistrar.unregister(cid); + } +}); + +add_task(setup); + +add_task(async function test_parentalControls() { + let DoHHeuristics = ChromeUtils.importESModule( + "resource:///modules/DoHHeuristics.sys.mjs" + ); + + let parentalControls = DoHHeuristics.parentalControls; + + Assert.equal( + await parentalControls(), + "enable_doh", + "By default, parental controls should be disabled and doh should be enabled" + ); + + SetMockParentalControlEnabled(false); + + Assert.equal( + await parentalControls(), + "enable_doh", + "Mocked parental controls service is disabled; doh is enabled" + ); + + SetMockParentalControlEnabled(true); + + Assert.equal( + await parentalControls(), + "enable_doh", + "Default value of mocked parental controls service is disabled; doh is enabled" + ); + + SetMockParentalControlEnabled(false); + + Assert.equal( + await parentalControls(), + "enable_doh", + "Mocked parental controls service is disabled; doh is enabled" + ); + + MockRegistrar.unregister(cid); + + Assert.equal( + await parentalControls(), + "enable_doh", + "By default, parental controls should be disabled and doh should be enabled" + ); +}); diff --git a/browser/components/doh/test/unit/xpcshell.ini b/browser/components/doh/test/unit/xpcshell.ini new file mode 100644 index 0000000000..7b3d7e31cf --- /dev/null +++ b/browser/components/doh/test/unit/xpcshell.ini @@ -0,0 +1,12 @@ +[DEFAULT] +skip-if = toolkit == 'android' # bug 1730213 +head = head.js +firefox-appdir = browser +support-files = + ../../../../../netwerk/test/unit/http2-ca.pem + +[test_heuristics.js] +[test_DNSLookup.js] +skip-if = debug # Bug 1617845 +[test_LookupAggregator.js] +[test_TRRRacer.js] |