gecko-dev/browser/components/doh/DoHHeuristics.sys.mjs

438 lines
12 KiB
JavaScript

/* 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
export async function globalCanary() {
// Check that a commonly used domain resolves before and after
// checking the global canary.
// If this check fails, the globalCanary isn't to be trusted, as it's
// an indication it might have failed due to DNS being unavailable.
async function preconditionCheck(domain) {
let { addresses, err } = await dnsLookup(domain);
if (err === NXDOMAIN_ERR || !addresses.length) {
return false;
}
return true;
}
let preCheckSuccess = await preconditionCheck("example.com.");
if (!preCheckSuccess) {
return "enable_doh";
}
// Actual global canary check
let { addresses, err } = await dnsLookup(GLOBAL_CANARY);
let postCheckSuccess = await preconditionCheck("example.org.");
if (!postCheckSuccess) {
return "enable_doh";
}
function isLocal(addr) {
// hostnameIsLocalIPAddress does not return true for loopback addresses
// so we specifically handle these.
if (addr == "127.0.0.1" || addr == "::1" || addr == "0.0.0.0") {
return true;
}
return Services.io.hostnameIsLocalIPAddress(
Services.io.newURI(`http://${addr}`)
);
}
if (
err === NXDOMAIN_ERR ||
!addresses.length ||
addresses.every(addr => isLocal(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;
}