summaryrefslogtreecommitdiffstats
path: root/browser/components/doh/DoHHeuristics.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/doh/DoHHeuristics.sys.mjs')
-rw-r--r--browser/components/doh/DoHHeuristics.sys.mjs405
1 files changed, 405 insertions, 0 deletions
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;
+}