diff options
Diffstat (limited to 'toolkit/components/extensions/ExtensionDNR.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/ExtensionDNR.sys.mjs | 2436 |
1 files changed, 2436 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionDNR.sys.mjs b/toolkit/components/extensions/ExtensionDNR.sys.mjs new file mode 100644 index 0000000000..cd01d52b72 --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNR.sys.mjs @@ -0,0 +1,2436 @@ +/* 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/. */ + +// Each extension that uses DNR has one RuleManager. All registered RuleManagers +// are checked whenever a network request occurs. Individual extensions may +// occasionally modify their rules (e.g. via the updateSessionRules API). +const gRuleManagers = []; + +/** + * Whenever a request occurs, the rules of each RuleManager are matched against + * the request to determine the final action to take. The RequestEvaluator class + * is responsible for evaluating rules, and its behavior is described below. + * + * Short version: + * Find the highest-priority rule that matches the given request. If the + * request is not canceled, all matching allowAllRequests and modifyHeaders + * actions are returned. + * + * Longer version: + * Unless stated otherwise, the explanation below describes the behavior within + * an extension. + * An extension can specify rules, optionally in multiple rulesets. The ability + * to have multiple ruleset exists to support bulk updates of rules. Rulesets + * are NOT independent - rules from different rulesets can affect each other. + * + * When multiple rules match, the order between rules are defined as follows: + * - Ruleset precedence: session > dynamic > static (order from manifest.json). + * - Rules in ruleset precedence: ordered by rule.id, lowest (numeric) ID first. + * - Across all rules+rulesets: highest rule.priority (default 1) first, + * action precedence if rule priority are the same. + * + * The primary documented way for extensions to describe precedence is by + * specifying rule.priority. Between same-priority rules, their precedence is + * dependent on the rule action. The ruleset/rule ID precedence is only used to + * have a defined ordering if multiple rules have the same priority+action. + * + * Rule actions have the following order of precedence and meaning: + * - "allow" can be used to ignore other same-or-lower-priority rules. + * - "allowAllRequests" (for main_frame / sub_frame resourceTypes only) has the + * same effect as allow, but also applies to (future) subresource loads in + * the document (including descendant frames) generated from the request. + * - "block" cancels the matched request. + * - "upgradeScheme" upgrades the scheme of the request. + * - "redirect" redirects the request. + * - "modifyHeaders" rewrites request/response headers. + * + * The matched rules are evaluated in two passes: + * 1. findMatchingRules(): + * Find the highest-priority rule(s), and choose the action with the highest + * precedence (across all rulesets, any action except modifyHeaders). + * This also accounts for any allowAllRequests from an ancestor frame. + * + * 2. getMatchingModifyHeadersRules(): + * Find matching rules with the "modifyHeaders" action, minus ignored rules. + * Reaching this step implies that the request was not canceled, so either + * the first step did not yield a rule, or the rule action is "allow" or + * "allowAllRequests" (i.e. ignore same-or-lower-priority rules). + * + * If an extension does not have sufficient permissions for the action, the + * resulting action is ignored. + * + * The above describes the evaluation within one extension. When a sequence of + * (multiple) extensions is given, they may return conflicting actions in the + * first pass. This is resolved by choosing the action with the following order + * of precedence, in RequestEvaluator.evaluateRequest(): + * - block + * - redirect / upgradeScheme + * - allow / allowAllRequests + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + WebRequest: "resource://gre/modules/WebRequest.sys.mjs", +}); + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gMatchRequestsFromOtherExtensions", + "extensions.dnr.match_requests_from_other_extensions", + false +); + +// As documented above: +// Ruleset precedence: session > dynamic > static (order from manifest.json). +const PRECEDENCE_SESSION_RULESET = 1; +const PRECEDENCE_DYNAMIC_RULESET = 2; +const PRECEDENCE_STATIC_RULESETS_BASE = 3; + +// The RuleCondition class represents a rule's "condition" type as described in +// schemas/declarative_net_request.json. This class exists to allow the JS +// engine to use one Shape for all Rule instances. +class RuleCondition { + #compiledUrlFilter; + #compiledRegexFilter; + + constructor(cond) { + this.urlFilter = cond.urlFilter; + this.regexFilter = cond.regexFilter; + this.isUrlFilterCaseSensitive = cond.isUrlFilterCaseSensitive; + this.initiatorDomains = cond.initiatorDomains; + this.excludedInitiatorDomains = cond.excludedInitiatorDomains; + this.requestDomains = cond.requestDomains; + this.excludedRequestDomains = cond.excludedRequestDomains; + this.resourceTypes = cond.resourceTypes; + this.excludedResourceTypes = cond.excludedResourceTypes; + this.requestMethods = cond.requestMethods; + this.excludedRequestMethods = cond.excludedRequestMethods; + this.domainType = cond.domainType; + this.tabIds = cond.tabIds; + this.excludedTabIds = cond.excludedTabIds; + } + + // See CompiledUrlFilter for documentation. + urlFilterMatches(requestDataForUrlFilter) { + if (!this.#compiledUrlFilter) { + // eslint-disable-next-line no-use-before-define + this.#compiledUrlFilter = new CompiledUrlFilter( + this.urlFilter, + this.isUrlFilterCaseSensitive + ); + } + return this.#compiledUrlFilter.matchesRequest(requestDataForUrlFilter); + } + + // Used for testing regexFilter matches in RuleEvaluator.#matchRuleCondition + // and to get redirect URL from regexSubstitution in applyRegexSubstitution. + getCompiledRegexFilter() { + return this.#compiledRegexFilter; + } + + // RuleValidator compiles regexFilter before this Rule class is instantiated. + // To avoid unnecessarily compiling it again, the result is assigned here. + setCompiledRegexFilter(compiledRegexFilter) { + this.#compiledRegexFilter = compiledRegexFilter; + } +} + +export class Rule { + constructor(rule) { + this.id = rule.id; + this.priority = rule.priority; + this.condition = new RuleCondition(rule.condition); + this.action = rule.action; + } + + // The precedence of rules within an extension. This method is frequently + // used during the first pass of the RequestEvaluator. + actionPrecedence() { + switch (this.action.type) { + case "allow": + return 1; // Highest precedence. + case "allowAllRequests": + return 2; + case "block": + return 3; + case "upgradeScheme": + return 4; + case "redirect": + return 5; + case "modifyHeaders": + return 6; + default: + throw new Error(`Unexpected action type: ${this.action.type}`); + } + } + + isAllowOrAllowAllRequestsAction() { + const type = this.action.type; + return type === "allow" || type === "allowAllRequests"; + } +} + +class Ruleset { + /** + * @param {string} rulesetId - extension-defined ruleset ID. + * @param {integer} rulesetPrecedence + * @param {Rule[]} rules - extension-defined rules + * @param {RuleManager} ruleManager - owner of this ruleset. + */ + constructor(rulesetId, rulesetPrecedence, rules, ruleManager) { + this.id = rulesetId; + this.rulesetPrecedence = rulesetPrecedence; + this.rules = rules; + // For use by MatchedRule. + this.ruleManager = ruleManager; + } +} + +/** + * @param {string} uriQuery - The query of a nsIURI to transform. + * @param {object} queryTransform - The value of the + * Rule.action.redirect.transform.queryTransform property as defined in + * declarative_net_request.json. + * @returns {string} The uriQuery with the queryTransform applied to it. + */ +function applyQueryTransform(uriQuery, queryTransform) { + // URLSearchParams cannot be applied to the full query string, because that + // API formats the full query string using form-urlencoding. But the input + // may be in a different format. So we try to only modify matched params. + + function urlencode(s) { + // Encode in application/x-www-form-urlencoded format. + // The only JS API to do that is URLSearchParams. encodeURIComponent is not + // the same, it differs in how it handles " " ("%20") and "!'()~" (raw). + // But urlencoded space should be "+" and the latter be "%21%27%28%29%7E". + return new URLSearchParams({ s }).toString().slice(2); + } + if (!uriQuery.length && !queryTransform.addOrReplaceParams) { + // Nothing to do. + return ""; + } + const removeParamsSet = new Set(queryTransform.removeParams?.map(urlencode)); + const addParams = (queryTransform.addOrReplaceParams || []).map(orig => ({ + normalizedKey: urlencode(orig.key), + orig, + })); + const finalParams = []; + if (uriQuery.length) { + for (let part of uriQuery.split("&")) { + let key = part.split("=", 1)[0]; + if (removeParamsSet.has(key)) { + continue; + } + let i = addParams.findIndex(p => p.normalizedKey === key); + if (i !== -1) { + // Replace found param with the key-value from addOrReplaceParams. + finalParams.push(`${key}=${urlencode(addParams[i].orig.value)}`); + // Omit param so that a future search for the same key can find the next + // specified key-value pair, if any. And to prevent the already-used + // key-value pairs from being appended after the loop. + addParams.splice(i, 1); + } else { + finalParams.push(part); + } + } + } + // Append remaining, unused key-value pairs. + for (let { normalizedKey, orig } of addParams) { + if (!orig.replaceOnly) { + finalParams.push(`${normalizedKey}=${urlencode(orig.value)}`); + } + } + return finalParams.length ? `?${finalParams.join("&")}` : ""; +} + +/** + * @param {nsIURI} uri - Usually a http(s) URL. + * @param {object} transform - The value of the Rule.action.redirect.transform + * property as defined in declarative_net_request.json. + * @returns {nsIURI} uri - The new URL. + * @throws if the transformation is invalid. + */ +function applyURLTransform(uri, transform) { + let mut = uri.mutate(); + if (transform.scheme) { + // Note: declarative_net_request.json only allows http(s)/moz-extension:. + mut.setScheme(transform.scheme); + if (uri.port !== -1 || transform.port) { + // If the URI contains a port or transform.port was specified, the default + // port is significant. So we must set it in that case. + if (transform.scheme === "https") { + mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(443); + } else if (transform.scheme === "http") { + mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(80); + } + } + } + if (transform.username != null) { + mut.setUsername(transform.username); + } + if (transform.password != null) { + mut.setPassword(transform.password); + } + if (transform.host != null) { + mut.setHost(transform.host); + } + if (transform.port != null) { + // The caller ensures that transform.port is a string consisting of digits + // only. When it is an empty string, it should be cleared (-1). + mut.setPort(transform.port || -1); + } + if (transform.path != null) { + mut.setFilePath(transform.path); + } + if (transform.query != null) { + mut.setQuery(transform.query); + } else if (transform.queryTransform) { + mut.setQuery(applyQueryTransform(uri.query, transform.queryTransform)); + } + if (transform.fragment != null) { + mut.setRef(transform.fragment); + } + return mut.finalize(); +} + +/** + * @param {nsIURI} uri - Usually a http(s) URL. + * @param {MatchedRule} matchedRule - The matched rule with a regexFilter + * condition and regexSubstitution action. + * @returns {nsIURI} The new URL derived from the regexSubstitution combined + * with capturing group from regexFilter applied to the input uri. + * @throws if the resulting URL is an invalid redirect target. + */ +function applyRegexSubstitution(uri, matchedRule) { + const rule = matchedRule.rule; + const extension = matchedRule.ruleManager.extension; + const regexSubstitution = rule.action.redirect.regexSubstitution; + const compiledRegexFilter = rule.condition.getCompiledRegexFilter(); + // This method being called implies that regexFilter matched, so |matches| is + // always non-null, i.e. an array of string/undefined values. + const matches = compiledRegexFilter.exec(uri.spec); + + let redirectUrl = regexSubstitution.replace(/\\(.)/g, (_, char) => { + // #checkActionRedirect ensures that every \ is followed by a \ or digit. + return char === "\\" ? char : matches[char] ?? ""; + }); + + // Throws if the URL is invalid: + let redirectUri; + try { + redirectUri = Services.io.newURI(redirectUrl); + } catch (e) { + throw new Error( + `Extension ${extension.id} tried to redirect to an invalid URL: ${redirectUrl}` + ); + } + if (!extension.checkLoadURI(redirectUri, { dontReportErrors: true })) { + throw new Error( + `Extension ${extension.id} may not redirect to: ${redirectUrl}` + ); + } + return redirectUri; +} + +/** + * An urlFilter is a string pattern to match a canonical http(s) URL. + * urlFilter matches anywhere in the string, unless an anchor is present: + * - ||... ("Domain name anchor") - domain or subdomain starts with ... + * - |... ("Left anchor") - URL starts with ... + * - ...| ("Right anchor") - URL ends with ... + * + * Other than the anchors, the following special characters exist: + * - ^ = end of URL, or any char except: alphanum _ - . % ("Separator") + * - * = any number of characters ("Wildcard") + * + * Ambiguous cases (undocumented but actual Chrome behavior): + * - Plain "||" is a domain name anchor, not left + empty + right anchor. + * - "^" repeated at end of pattern: "^" matches end of URL only once. + * - "^|" at end of pattern: "^" is allowed to match end of URL. + * + * Implementation details: + * - CompiledUrlFilter's constructor (+#initializeUrlFilter) extracts the + * actual urlFilter and anchors, for matching against URLs later. + * - RequestDataForUrlFilter class precomputes the URL / domain anchors to + * support matching more efficiently. + * - CompiledUrlFilter's matchesRequest(request) checks whether the request is + * actually matched, using the precomputed information. + * + * The class was designed to minimize the number of string allocations during + * request evaluation, because the matchesRequest method may be called very + * often for every network request. + */ +class CompiledUrlFilter { + #isUrlFilterCaseSensitive; + #urlFilterParts; // = parts of urlFilter, minus anchors, split at "*". + // isAnchorLeft and isAnchorDomain are mutually exclusive. + #isAnchorLeft = false; + #isAnchorDomain = false; + #isAnchorRight = false; + #isTrailingSeparator = false; // Whether urlFilter ends with "^". + + /** + * @param {string} urlFilter - non-empty urlFilter + * @param {boolean} [isUrlFilterCaseSensitive] + */ + constructor(urlFilter, isUrlFilterCaseSensitive) { + this.#isUrlFilterCaseSensitive = isUrlFilterCaseSensitive; + this.#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive); + } + + #initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive) { + let start = 0; + let end = urlFilter.length; + + // First, trim the anchors off urlFilter. + if (urlFilter[0] === "|") { + if (urlFilter[1] === "|") { + start = 2; + this.#isAnchorDomain = true; + // ^ will not revert to false below, because "||*" is already rejected + // by RuleValidator's #checkCondUrlFilterAndRegexFilter method. + } else { + start = 1; + this.#isAnchorLeft = true; // may revert to false below. + } + } + if (end > start && urlFilter[end - 1] === "|") { + --end; + this.#isAnchorRight = true; // may revert to false below. + } + + // Skip unnecessary wildcards, and adjust meaningless anchors accordingly: + // "|*" and "*|" are not effective anchors, they could have been omitted. + while (start < end && urlFilter[start] === "*") { + ++start; + this.#isAnchorLeft = false; + } + while (end > start && urlFilter[end - 1] === "*") { + --end; + this.#isAnchorRight = false; + } + + // Special-case the last "^", so that the matching algorithm can rely on + // the simple assumption that a "^" in the filter matches exactly one char: + // The "^" at the end of the pattern is specified to match either one char + // as usual, or as an anchor for the end of the URL (i.e. zero characters). + this.#isTrailingSeparator = urlFilter[end - 1] === "^"; + + let urlFilterWithoutAnchors = urlFilter.slice(start, end); + if (!isUrlFilterCaseSensitive) { + urlFilterWithoutAnchors = urlFilterWithoutAnchors.toLowerCase(); + } + this.#urlFilterParts = urlFilterWithoutAnchors.split("*"); + } + + /** + * Tests whether |request| matches the urlFilter. + * + * @param {RequestDataForUrlFilter} requestDataForUrlFilter + * @returns {boolean} Whether the condition matches the URL. + */ + matchesRequest(requestDataForUrlFilter) { + const url = requestDataForUrlFilter.getUrl(this.#isUrlFilterCaseSensitive); + const domainAnchors = requestDataForUrlFilter.domainAnchors; + + const urlFilterParts = this.#urlFilterParts; + + const REAL_END_OF_URL = url.length - 1; // minus trailing "^" + + // atUrlIndex is the position after the most recently matched part. + // If a match is not found, it is -1 and we should return false. + let atUrlIndex = 0; + + // The head always exists, potentially even an empty string. + const head = urlFilterParts[0]; + if (this.#isAnchorLeft) { + if (!this.#startsWithPart(head, url, 0)) { + return false; + } + atUrlIndex = head.length; + } else if (this.#isAnchorDomain) { + atUrlIndex = this.#indexAfterDomainPart(head, url, domainAnchors); + } else { + atUrlIndex = this.#indexAfterPart(head, url, 0); + } + + let previouslyAtUrlIndex = 0; + for (let i = 1; i < urlFilterParts.length && atUrlIndex !== -1; ++i) { + previouslyAtUrlIndex = atUrlIndex; + atUrlIndex = this.#indexAfterPart(urlFilterParts[i], url, atUrlIndex); + } + if (atUrlIndex === -1) { + return false; + } + if (atUrlIndex === url.length) { + // We always append a "^" to the URL, so if the match is at the end of the + // URL (REAL_END_OF_URL), only accept if the pattern ended with a "^". + return this.#isTrailingSeparator; + } + if (!this.#isAnchorRight || atUrlIndex === REAL_END_OF_URL) { + // Either not interested in the end, or already at the end of the URL. + return true; + } + + // #isAnchorRight is true but we are not at the end of the URL. + // Backtrack once, to retry the last pattern (tail) with the end of the URL. + + const tail = urlFilterParts[urlFilterParts.length - 1]; + // The expected offset where the tail should be located. + const expectedTailIndex = REAL_END_OF_URL - tail.length; + // If #isTrailingSeparator is true, then accept the URL's trailing "^". + const expectedTailIndexPlus1 = expectedTailIndex + 1; + if (urlFilterParts.length === 1) { + if (this.#isAnchorLeft) { + // If matched, we would have returned at the REAL_END_OF_URL checks. + return false; + } + if (this.#isAnchorDomain) { + // The tail must be exactly at one of the domain anchors. + return ( + (domainAnchors.includes(expectedTailIndex) && + this.#startsWithPart(tail, url, expectedTailIndex)) || + (this.#isTrailingSeparator && + domainAnchors.includes(expectedTailIndexPlus1) && + this.#startsWithPart(tail, url, expectedTailIndexPlus1)) + ); + } + // head has no left/domain anchor, fall through. + } + // The tail is not left/domain anchored, accept it as long as it did not + // overlap with an already-matched part of the URL. + return ( + (expectedTailIndex > previouslyAtUrlIndex && + this.#startsWithPart(tail, url, expectedTailIndex)) || + (this.#isTrailingSeparator && + expectedTailIndexPlus1 > previouslyAtUrlIndex && + this.#startsWithPart(tail, url, expectedTailIndexPlus1)) + ); + } + + // Whether a character should match "^" in an urlFilter. + // The "match end of URL" meaning of "^" is covered by #isTrailingSeparator. + static #regexIsSep = /[^A-Za-z0-9_\-.%]/; + + #matchPartAt(part, url, urlIndex, sepStart) { + if (sepStart === -1) { + // Fast path. + return url.startsWith(part, urlIndex); + } + if (urlIndex + part.length > url.length) { + return false; + } + for (let i = 0; i < part.length; ++i) { + let partChar = part[i]; + let urlChar = url[urlIndex + i]; + if ( + partChar !== urlChar && + (partChar !== "^" || !CompiledUrlFilter.#regexIsSep.test(urlChar)) + ) { + return false; + } + } + return true; + } + + #startsWithPart(part, url, urlIndex) { + const sepStart = part.indexOf("^"); + return this.#matchPartAt(part, url, urlIndex, sepStart); + } + + #indexAfterPart(part, url, urlIndex) { + let sepStart = part.indexOf("^"); + if (sepStart === -1) { + // Fast path. + let i = url.indexOf(part, urlIndex); + return i === -1 ? i : i + part.length; + } + let maxUrlIndex = url.length - part.length; + for (let i = urlIndex; i <= maxUrlIndex; ++i) { + if (this.#matchPartAt(part, url, i, sepStart)) { + return i + part.length; + } + } + return -1; + } + + #indexAfterDomainPart(part, url, domainAnchors) { + const sepStart = part.indexOf("^"); + for (let offset of domainAnchors) { + if (this.#matchPartAt(part, url, offset, sepStart)) { + return offset + part.length; + } + } + return -1; + } +} + +// See CompiledUrlFilter for documentation of RequestDataForUrlFilter. +class RequestDataForUrlFilter { + /** + * @param {string} requestURIspec - The URL to match against. + */ + constructor(requestURIspec) { + // "^" is appended, see CompiledUrlFilter's #initializeUrlFilter. + this.urlAnyCase = requestURIspec + "^"; + this.urlLowerCase = this.urlAnyCase.toLowerCase(); + // For "||..." (Domain name anchor): where (sub)domains start in the URL. + this.domainAnchors = this.#getDomainAnchors(this.urlAnyCase); + } + + getUrl(isUrlFilterCaseSensitive) { + return isUrlFilterCaseSensitive ? this.urlAnyCase : this.urlLowerCase; + } + + #getDomainAnchors(url) { + let hostStart = url.indexOf("://") + 3; + let hostEnd = url.indexOf("/", hostStart); + let userpassEnd = url.lastIndexOf("@", hostEnd) + 1; + if (userpassEnd) { + hostStart = userpassEnd; + } + let host = url.slice(hostStart, hostEnd); + let domainAnchors = [hostStart]; + let offset = 0; + // Find all offsets after ".". If not found, -1 + 1 = 0, and the loop ends. + while ((offset = host.indexOf(".", offset) + 1)) { + domainAnchors.push(hostStart + offset); + } + return domainAnchors; + } +} + +function compileRegexFilter(regexFilter, isUrlFilterCaseSensitive) { + // TODO bug 1821033: Restrict supported regex to avoid perf issues. For + // discussion on the desired syntax, see + // https://github.com/w3c/webextensions/issues/344 + return new RegExp(regexFilter, isUrlFilterCaseSensitive ? "" : "i"); +} + +class ModifyHeadersBase { + // Map<string,MatchedRule> - The first MatchedRule that modified the header. + // After modifying a header, it cannot be modified further, with the exception + // of the "append" operation, provided that they are from the same extension. + #alreadyModifiedMap = new Map(); + // Set<string> - The list of headers allowed to be modified with "append", + // despite having been modified. Allowed for "set"/"append", not for "remove". + #appendStillAllowed = new Set(); + + /** + * @param {ChannelWrapper} channel + */ + constructor(channel) { + this.channel = channel; + } + + /** + * @param {MatchedRule} matchedRule + * @returns {object[]} + */ + headerActionsFor(matchedRule) { + throw new Error("Not implemented."); + } + + /** + * @param {MatchedRule} matchedrule + * @param {string} name + * @param {string} value + * @param {boolean} merge + */ + setHeaderImpl(matchedrule, name, value, merge) { + throw new Error("Not implemented."); + } + + /** @param {MatchedRule[]} matchedRules */ + applyModifyHeaders(matchedRules) { + for (const matchedRule of matchedRules) { + for (const headerAction of this.headerActionsFor(matchedRule)) { + const { header: name, operation, value } = headerAction; + if (!this.#isOperationAllowed(name, operation, matchedRule)) { + continue; + } + let ok; + switch (operation) { + case "set": + ok = this.setHeader(matchedRule, name, value, /* merge */ false); + if (ok) { + this.#appendStillAllowed.add(name); + } + break; + case "append": + ok = this.setHeader(matchedRule, name, value, /* merge */ true); + if (ok) { + this.#appendStillAllowed.add(name); + } + break; + case "remove": + ok = this.setHeader(matchedRule, name, "", /* merge */ false); + // Note: removal is final, so we don't add to #appendStillAllowed. + break; + } + if (ok) { + this.#alreadyModifiedMap.set(name, matchedRule); + } + } + } + } + + #isOperationAllowed(name, operation, matchedRule) { + const modifiedBy = this.#alreadyModifiedMap.get(name); + if (!modifiedBy) { + return true; + } + if ( + operation === "append" && + this.#appendStillAllowed.has(name) && + matchedRule.ruleManager === modifiedBy.ruleManager + ) { + return true; + } + // TODO bug 1803369: dev experience improvement: consider logging when + // a header modification was rejected. + return false; + } + + setHeader(matchedRule, name, value, merge) { + try { + this.setHeaderImpl(matchedRule, name, value, merge); + return true; + } catch (e) { + const extension = matchedRule.ruleManager.extension; + extension.logger.error( + `Failed to apply modifyHeaders action to header "${name}" (DNR rule id ${matchedRule.rule.id} from ruleset "${matchedRule.ruleset.id}"): ${e}` + ); + } + return false; + } + + // kName should already be in lower case. + isHeaderNameEqual(name, kName) { + return name.length === kName.length && name.toLowerCase() === kName; + } +} + +class ModifyRequestHeaders extends ModifyHeadersBase { + static maybeApplyModifyHeaders(channel, matchedRules) { + matchedRules = matchedRules.filter(mr => { + const action = mr.rule.action; + return action.type === "modifyHeaders" && action.requestHeaders?.length; + }); + if (matchedRules.length) { + new ModifyRequestHeaders(channel).applyModifyHeaders(matchedRules); + } + } + + /** @param {MatchedRule} matchedRule */ + headerActionsFor(matchedRule) { + return matchedRule.rule.action.requestHeaders; + } + + setHeaderImpl(matchedRule, name, value, merge) { + if (this.isHeaderNameEqual(name, "host")) { + this.#checkHostHeader(matchedRule, value); + } + if (merge && value && this.isHeaderNameEqual(name, "cookie")) { + // By default, headers are merged with ",". But Cookie should use "; ". + // HTTP/1.1 allowed only one Cookie header, but HTTP/2.0 allows multiple, + // but recommends concatenation on one line. Relevant RFCs: + // - https://www.rfc-editor.org/rfc/rfc6265#section-5.4 + // - https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5 + // Consistent with Firefox internals, we ensure that there is at most one + // Cookie header, by overwriting the previous one, if any. + let existingCookie = this.channel.getRequestHeader("cookie"); + if (existingCookie) { + value = existingCookie + "; " + value; + merge = false; + } + } + this.channel.setRequestHeader(name, value, merge); + } + + #checkHostHeader(matchedRule, value) { + let uri = Services.io.newURI(`https://${value}/`); + let { policy } = matchedRule.ruleManager.extension; + + if (!policy.allowedOrigins.matches(uri)) { + throw new Error( + `Unable to set host header, url missing from permissions.` + ); + } + + if (WebExtensionPolicy.isRestrictedURI(uri)) { + throw new Error(`Unable to set host header to restricted url.`); + } + } +} + +class ModifyResponseHeaders extends ModifyHeadersBase { + static maybeApplyModifyHeaders(channel, matchedRules) { + matchedRules = matchedRules.filter(mr => { + const action = mr.rule.action; + return action.type === "modifyHeaders" && action.responseHeaders?.length; + }); + if (matchedRules.length) { + new ModifyResponseHeaders(channel).applyModifyHeaders(matchedRules); + } + } + + headerActionsFor(matchedRule) { + return matchedRule.rule.action.responseHeaders; + } + + setHeaderImpl(matchedRule, name, value, merge) { + this.channel.setResponseHeader(name, value, merge); + } +} + +class RuleValidator { + constructor(alreadyValidatedRules, { isSessionRuleset = false } = {}) { + this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r])); + this.failures = []; + this.isSessionRuleset = isSessionRuleset; + } + + /** + * Static method used to deserialize Rule class instances from a plain + * js object rule as serialized implicitly by aomStartup.encodeBlob + * when we store the rules into the startup cache file. + * + * @param {object} rule + * @returns {Rule} + */ + static deserializeRule(rule) { + const newRule = new Rule(rule); + if (newRule.condition.regexFilter) { + newRule.condition.setCompiledRegexFilter( + compileRegexFilter( + newRule.condition.regexFilter, + newRule.condition.isUrlFilterCaseSensitive + ) + ); + } + return newRule; + } + + removeRuleIds(ruleIds) { + for (const ruleId of ruleIds) { + this.rulesMap.delete(ruleId); + } + } + + /** + * @param {object[]} rules - A list of objects that adhere to the Rule type + * from declarative_net_request.json. + */ + addRules(rules) { + for (const rule of rules) { + if (this.rulesMap.has(rule.id)) { + this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`); + continue; + } + // declarative_net_request.json defines basic types, such as the expected + // object properties and (primitive) type. Trivial constraints such as + // minimum array lengths are also expressed in the schema. + // Anything more complex is validated here. In particular, constraints + // involving multiple properties (e.g. mutual exclusiveness). + // + // The following conditions have already been validated by the schema: + // - isUrlFilterCaseSensitive (boolean) + // - domainType (enum string) + // - initiatorDomains & excludedInitiatorDomains & requestDomains & + // excludedRequestDomains (array of string in canonicalDomain format) + if ( + !this.#checkCondResourceTypes(rule) || + !this.#checkCondRequestMethods(rule) || + !this.#checkCondTabIds(rule) || + !this.#checkCondUrlFilterAndRegexFilter(rule) || + !this.#checkAction(rule) + ) { + continue; + } + + const newRule = new Rule(rule); + // #lastCompiledRegexFilter is set if regexFilter is set, and null + // otherwise by the above call to #checkCondUrlFilterAndRegexFilter(). + if (this.#lastCompiledRegexFilter) { + newRule.condition.setCompiledRegexFilter(this.#lastCompiledRegexFilter); + } + + this.rulesMap.set(rule.id, newRule); + } + } + + // #checkCondUrlFilterAndRegexFilter() compiles the regexFilter to check its + // validity. To avoid having to compile it again when the Rule (RuleCondition) + // is constructed, we temporarily cache the result. + #lastCompiledRegexFilter; + + // Checks: resourceTypes & excludedResourceTypes + #checkCondResourceTypes(rule) { + const { resourceTypes, excludedResourceTypes } = rule.condition; + if (this.#hasOverlap(resourceTypes, excludedResourceTypes)) { + this.#collectInvalidRule( + rule, + "resourceTypes and excludedResourceTypes should not overlap" + ); + return false; + } + if (rule.action.type === "allowAllRequests") { + if (!resourceTypes) { + this.#collectInvalidRule( + rule, + "An allowAllRequests rule must have a non-empty resourceTypes array" + ); + return false; + } + if (resourceTypes.some(r => r !== "main_frame" && r !== "sub_frame")) { + this.#collectInvalidRule( + rule, + "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes" + ); + return false; + } + } + return true; + } + + // Checks: requestMethods & excludedRequestMethods + #checkCondRequestMethods(rule) { + const { requestMethods, excludedRequestMethods } = rule.condition; + if (this.#hasOverlap(requestMethods, excludedRequestMethods)) { + this.#collectInvalidRule( + rule, + "requestMethods and excludedRequestMethods should not overlap" + ); + return false; + } + const isInvalidRequestMethod = method => method.toLowerCase() !== method; + if ( + requestMethods?.some(isInvalidRequestMethod) || + excludedRequestMethods?.some(isInvalidRequestMethod) + ) { + this.#collectInvalidRule(rule, "request methods must be in lower case"); + return false; + } + return true; + } + + // Checks: tabIds & excludedTabIds + #checkCondTabIds(rule) { + const { tabIds, excludedTabIds } = rule.condition; + + if ((tabIds || excludedTabIds) && !this.isSessionRuleset) { + this.#collectInvalidRule( + rule, + "tabIds and excludedTabIds can only be specified in session rules" + ); + return false; + } + + if (this.#hasOverlap(tabIds, excludedTabIds)) { + this.#collectInvalidRule( + rule, + "tabIds and excludedTabIds should not overlap" + ); + return false; + } + return true; + } + + static #regexNonASCII = /[^\x00-\x7F]/; // eslint-disable-line no-control-regex + static #regexDigitOrBackslash = /^[0-9\\]$/; + + // Checks: urlFilter & regexFilter + #checkCondUrlFilterAndRegexFilter(rule) { + const { urlFilter, regexFilter } = rule.condition; + + this.#lastCompiledRegexFilter = null; + + const checkEmptyOrNonASCII = (str, prop) => { + if (!str) { + this.#collectInvalidRule(rule, `${prop} should not be an empty string`); + return false; + } + // Non-ASCII in URLs are always encoded in % (or punycode in domains). + if (RuleValidator.#regexNonASCII.test(str)) { + this.#collectInvalidRule( + rule, + `${prop} should not contain non-ASCII characters` + ); + return false; + } + return true; + }; + if (urlFilter != null) { + if (regexFilter != null) { + this.#collectInvalidRule( + rule, + "urlFilter and regexFilter are mutually exclusive" + ); + return false; + } + if (!checkEmptyOrNonASCII(urlFilter, "urlFilter")) { + // #collectInvalidRule already called by checkEmptyOrNonASCII. + return false; + } + if (urlFilter.startsWith("||*")) { + // Rejected because Chrome does too. '||*' is equivalent to '*'. + this.#collectInvalidRule(rule, "urlFilter should not start with '||*'"); + return false; + } + } else if (regexFilter != null) { + if (!checkEmptyOrNonASCII(regexFilter, "regexFilter")) { + // #collectInvalidRule already called by checkEmptyOrNonASCII. + return false; + } + try { + this.#lastCompiledRegexFilter = compileRegexFilter( + regexFilter, + rule.condition.isUrlFilterCaseSensitive + ); + } catch (e) { + this.#collectInvalidRule( + rule, + "regexFilter is not a valid regular expression" + ); + return false; + } + } + return true; + } + + #checkAction(rule) { + switch (rule.action.type) { + case "allow": + case "allowAllRequests": + case "block": + case "upgradeScheme": + // These actions have no extra properties. + break; + case "redirect": + return this.#checkActionRedirect(rule); + case "modifyHeaders": + return this.#checkActionModifyHeaders(rule); + default: + // Other values are not possible because declarative_net_request.json + // only accepts the above action types. + throw new Error(`Unexpected action type: ${rule.action.type}`); + } + return true; + } + + #checkActionRedirect(rule) { + const { url, extensionPath, transform, regexSubstitution } = + rule.action.redirect ?? {}; + const hasExtensionPath = extensionPath != null; + const hasRegexSubstitution = regexSubstitution != null; + const redirectKeyCount = // @ts-ignore trivial/noisy + !!url + !!hasExtensionPath + !!transform + !!hasRegexSubstitution; + if (redirectKeyCount !== 1) { + if (redirectKeyCount === 0) { + this.#collectInvalidRule( + rule, + "A redirect rule must have a non-empty action.redirect object" + ); + return false; + } + // Side note: Chrome silently ignores excess keys, and skips validation + // for ignored keys, in this order: + // - url > extensionPath > transform > regexSubstitution + this.#collectInvalidRule( + rule, + "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive" + ); + return false; + } + + if (hasExtensionPath && !extensionPath.startsWith("/")) { + this.#collectInvalidRule( + rule, + "redirect.extensionPath should start with a '/'" + ); + return false; + } + + // If specified, the "url" property is described as "format": "url" in the + // JSON schema, which ensures that the URL is a canonical form, and that + // the extension is allowed to trigger a navigation to the URL. + // E.g. javascript: and privileged about:-URLs cannot be navigated to, but + // http(s) URLs can (regardless of extension permissions). + // data:-URLs are currently blocked due to bug 1622986. + + if (transform) { + if (transform.query != null && transform.queryTransform) { + this.#collectInvalidRule( + rule, + "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive" + ); + return false; + } + // Most of the validation is done by nsIURIMutator via applyURLTransform. + // nsIURIMutator is not very strict, so we perform some extra checks here + // to reject values that are not technically valid URLs. + + if (transform.port && /\D/.test(transform.port)) { + // nsIURIMutator's setPort takes an int, so any string will implicitly + // be converted to a number. This part verifies that the input only + // consists of digits. setPort will ensure that it is at most 65535. + this.#collectInvalidRule( + rule, + "redirect.transform.port should be empty or an integer" + ); + return false; + } + + // Note: we don't verify whether transform.query starts with '/', because + // Chrome does not require it, and nsIURIMutator prepends it if missing. + + if (transform.query && !transform.query.startsWith("?")) { + this.#collectInvalidRule( + rule, + "redirect.transform.query should be empty or start with a '?'" + ); + return false; + } + if (transform.fragment && !transform.fragment.startsWith("#")) { + this.#collectInvalidRule( + rule, + "redirect.transform.fragment should be empty or start with a '#'" + ); + return false; + } + try { + const dummyURI = Services.io.newURI("http://dummy"); + // applyURLTransform uses nsIURIMutator to transform a URI, and throws + // if |transform| is invalid, e.g. invalid host, port, etc. + applyURLTransform(dummyURI, transform); + } catch (e) { + this.#collectInvalidRule( + rule, + "redirect.transform does not describe a valid URL transformation" + ); + return false; + } + } + + if (hasRegexSubstitution) { + if (!rule.condition.regexFilter) { + this.#collectInvalidRule( + rule, + "redirect.regexSubstitution requires the regexFilter condition to be specified" + ); + return false; + } + let i = 0; + // i will be index after \. Loop breaks if not found (-1+1=0 = false). + while ((i = regexSubstitution.indexOf("\\", i) + 1)) { + let c = regexSubstitution[i++]; // may be undefined if \ is at end. + if (c === undefined || !RuleValidator.#regexDigitOrBackslash.test(c)) { + this.#collectInvalidRule( + rule, + "redirect.regexSubstitution only allows digit or \\ after \\." + ); + return false; + } + } + } + + return true; + } + + #checkActionModifyHeaders(rule) { + const { requestHeaders, responseHeaders } = rule.action; + if (!requestHeaders && !responseHeaders) { + this.#collectInvalidRule( + rule, + "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list" + ); + return false; + } + + const isValidModifyHeadersOp = ({ header, operation, value }) => { + if (!header) { + this.#collectInvalidRule(rule, "header must be non-empty"); + return false; + } + if (!value && (operation === "append" || operation === "set")) { + this.#collectInvalidRule( + rule, + "value is required for operations append/set" + ); + return false; + } + if (value && operation === "remove") { + this.#collectInvalidRule( + rule, + "value must not be provided for operation remove" + ); + return false; + } + return true; + }; + if ( + (requestHeaders && !requestHeaders.every(isValidModifyHeadersOp)) || + (responseHeaders && !responseHeaders.every(isValidModifyHeadersOp)) + ) { + // #collectInvalidRule already called by isValidModifyHeadersOp. + return false; + } + return true; + } + + // Conditions with a filter and an exclude-filter should reject overlapping + // lists, because they can never simultaneously be true. + #hasOverlap(arrayA, arrayB) { + return arrayA && arrayB && arrayA.some(v => arrayB.includes(v)); + } + + #collectInvalidRule(rule, message) { + this.failures.push({ rule, message }); + } + + getValidatedRules() { + return Array.from(this.rulesMap.values()); + } + + getFailures() { + return this.failures; + } +} + +export class RuleQuotaCounter { + constructor(isStaticRulesets) { + this.isStaticRulesets = isStaticRulesets; + this.ruleLimitName = isStaticRulesets + ? "GUARANTEED_MINIMUM_STATIC_RULES" + : "MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES"; + this.ruleLimitRemaining = lazy.ExtensionDNRLimits[this.ruleLimitName]; + this.regexRemaining = lazy.ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES; + } + + tryAddRules(rulesetId, rules) { + if (rules.length > this.ruleLimitRemaining) { + this.#throwQuotaError(rulesetId, "rules", this.ruleLimitName); + } + let regexCount = 0; + for (let rule of rules) { + if (rule.condition.regexFilter && ++regexCount > this.regexRemaining) { + this.#throwQuotaError( + rulesetId, + "regexFilter rules", + "MAX_NUMBER_OF_REGEX_RULES" + ); + } + } + + // Update counters only when there are no quota errors. + this.ruleLimitRemaining -= rules.length; + this.regexRemaining -= regexCount; + } + + #throwQuotaError(rulesetId, what, limitName) { + if (this.isStaticRulesets) { + throw new ExtensionError( + `Number of ${what} across all enabled static rulesets exceeds ${limitName} if ruleset "${rulesetId}" were to be enabled.` + ); + } + throw new ExtensionError( + `Number of ${what} in ruleset "${rulesetId}" exceeds ${limitName}.` + ); + } +} + +/** + * Compares two rules to determine the relative order of precedence. + * Rules are only comparable if they are from the same extension! + * + * @param {Rule} ruleA + * @param {Rule} ruleB + * @param {Ruleset} rulesetA - the ruleset ruleA is part of. + * @param {Ruleset} rulesetB - the ruleset ruleB is part of. + * @returns {integer} + * 0 if equal. + * <0 if ruleA comes before ruleB. + * >0 if ruleA comes after ruleB. + */ +function compareRule(ruleA, ruleB, rulesetA, rulesetB) { + // Comparators: 0 if equal, >0 if a after b, <0 if a before b. + function cmpHighestNumber(a, b) { + return a === b ? 0 : b - a; + } + function cmpLowestNumber(a, b) { + return a === b ? 0 : a - b; + } + return ( + // All compared operands are non-negative integers. + cmpHighestNumber(ruleA.priority, ruleB.priority) || + cmpLowestNumber(ruleA.actionPrecedence(), ruleB.actionPrecedence()) || + // As noted in the big comment at the top of the file, the following two + // comparisons only exist in order to have a stable ordering of rules. The + // specific comparison is somewhat arbitrary and matches Chrome's behavior. + // For context, see https://github.com/w3c/webextensions/issues/280 + cmpLowestNumber(rulesetA.rulesetPrecedence, rulesetB.rulesetPrecedence) || + cmpLowestNumber(ruleA.id, ruleB.id) + ); +} + +class MatchedRule { + /** + * @param {Rule} rule + * @param {Ruleset} ruleset + */ + constructor(rule, ruleset) { + this.rule = rule; + this.ruleset = ruleset; + } + + // The RuleManager that generated this MatchedRule. + get ruleManager() { + return this.ruleset.ruleManager; + } +} + +// tabId computation is currently not free, and depends on the initialization of +// ExtensionParent.apiManager.global (see WebRequest.getTabIdForChannelWrapper). +// Fortunately, DNR only supports tabIds in session rules, so by keeping track +// of session rules with tabIds/excludedTabIds conditions, we can find tabId +// exactly and only when necessary. +let gHasAnyTabIdConditions = false; + +class RequestDetails { + /** + * @param {object} options + * @param {nsIURI} options.requestURI - URL of the requested resource. + * @param {nsIURI} [options.initiatorURI] - URL of triggering principal, + * provided that it is a content principal. Otherwise null. + * @param {string} options.type - ResourceType (MozContentPolicyType). + * @param {string} [options.method] - HTTP method + * @param {integer} [options.tabId] + * @param {BrowsingContext} [options.browsingContext] - The BrowsingContext + * associated with the request. Typically the bc for which the subresource + * request is initiated, if any. For document requests, this is the parent + * (i.e. the parent frame for sub_frame, null for main_frame). + */ + constructor({ + requestURI, + initiatorURI, + type, + method, + tabId, + browsingContext, + }) { + this.requestURI = requestURI; + this.initiatorURI = initiatorURI; + this.type = type; + this.method = method; + this.tabId = tabId; + this.browsingContext = browsingContext; + + this.requestDomain = this.#domainFromURI(requestURI); + this.initiatorDomain = initiatorURI + ? this.#domainFromURI(initiatorURI) + : null; + + this.requestURIspec = requestURI.spec; + this.requestDataForUrlFilter = new RequestDataForUrlFilter( + this.requestURIspec + ); + } + + static fromChannelWrapper(channel) { + let tabId = -1; + if (gHasAnyTabIdConditions) { + tabId = lazy.WebRequest.getTabIdForChannelWrapper(channel); + } + return new RequestDetails({ + requestURI: channel.finalURI, + // Note: originURI may be null, if missing or null principal, as desired. + initiatorURI: channel.originURI, + type: channel.type, + method: channel.method.toLowerCase(), + tabId, + browsingContext: channel.loadInfo.browsingContext, + }); + } + + #ancestorRequestDetails; + get ancestorRequestDetails() { + if (this.#ancestorRequestDetails) { + return this.#ancestorRequestDetails; + } + this.#ancestorRequestDetails = []; + if (!this.browsingContext?.ancestorsAreCurrent) { + // this.browsingContext is set for real requests (via fromChannelWrapper). + // It may be void for testMatchOutcome and for the ancestor requests + // simulated below. + // + // ancestorsAreCurrent being false is unexpected, but could theoretically + // happen if the request is triggered from an unloaded (sub)frame. In that + // case we don't want to use potentially incorrect ancestor information. + // + // In any case, nothing left to do. + return this.#ancestorRequestDetails; + } + // Reconstruct the frame hierarchy of the request's document, in order to + // retroactively recompute the relevant matches of allowAllRequests rules. + // + // The allowAllRequests rule is supposedly applying to all subresource + // requests. For non-document requests, this is usually the document if any. + // In case of document requests, there is some ambiguity: + // - Usually, the initiator is the parent document that created the frame. + // - Sometimes, the initiator is a different frame or even another window. + // + // In RequestDetails.fromChannelWrapper, the actual initiator is used and + // reflected in initiatorURI, but here we use the document's parent. This + // is done because the chain of initiators is unstable (e.g. an opener can + // navigate/unload), whereas frame ancestor chain is constant as long as + // the leaf BrowsingContext is current. Moreover, allowAllRequests was + // originally designed to operate on frame hierarchies (crbug.com/1038831). + // + // This implementation of "initiator" for "allowAllRequests" is consistent + // with Chrome and Safari. + for (let bc = this.browsingContext; bc; bc = bc.parent) { + // Note: requestURI may differ from the document's initial requestURI, + // e.g. due to same-document navigations. + const requestURI = bc.currentURI; + if (!requestURI.schemeIs("https") && !requestURI.schemeIs("http")) { + // DNR is currently only hooked up to http(s) requests. Ignore other + // URLs, e.g. about:, blob:, moz-extension:, data:, etc. + continue; + } + const isTop = !bc.parent; + const parentPrin = bc.parentWindowContext?.documentPrincipal; + const requestDetails = new RequestDetails({ + requestURI, + // Note: initiatorURI differs from RequestDetails.fromChannelWrapper; + // See the above comment for more info. + initiatorURI: parentPrin?.isContentPrincipal ? parentPrin.URI : null, + type: isTop ? "main_frame" : "sub_frame", + method: bc.activeSessionHistoryEntry?.hasPostData ? "post" : "get", + tabId: this.tabId, + // In this loop we are already explicitly accounting for ancestors, so + // we intentionally omit browsingContext even though we have |bc|. If + // we were to set `browsingContext: bc`, the output would be the same, + // but be derived from unnecessarily repeated request evaluations. + browsingContext: null, + }); + this.#ancestorRequestDetails.unshift(requestDetails); + } + return this.#ancestorRequestDetails; + } + + canExtensionModify(extension) { + const policy = extension.policy; + if (!policy.canAccessURI(this.requestURI)) { + return false; + } + if ( + this.initiatorURI && + this.type !== "main_frame" && + this.type !== "sub_frame" && + !policy.canAccessURI(this.initiatorURI) + ) { + // Host permissions for the initiator is required except for navigation + // requests: https://bugzilla.mozilla.org/show_bug.cgi?id=1825824#c2 + return false; + } + return true; + } + + #domainFromURI(uri) { + try { + let hostname = uri.host; + // nsIURI omits brackets from IPv6 addresses. But the canonical form of an + // IPv6 address is with brackets, so add them. + return hostname.includes(":") ? `[${hostname}]` : hostname; + } catch (e) { + // uri.host throws for some schemes (e.g. about:). In practice we won't + // encounter this for network (via NetworkIntegration.startDNREvaluation) + // because isRestrictedPrincipalURI filters the initiatorURI. Furthermore, + // because only http(s) requests are observed, requestURI is http(s). + // + // declarativeNetRequest.testMatchOutcome can pass arbitrary URIs and thus + // trigger the error in nsIURI::GetHost. + Cu.reportError(e); + return null; + } + } +} + +/** + * This RequestEvaluator class's logic is documented at the top of this file. + */ +class RequestEvaluator { + // private constructor, only used by RequestEvaluator.evaluateRequest. + constructor(request, ruleManager) { + this.req = request; + this.ruleManager = ruleManager; + this.canModify = request.canExtensionModify(ruleManager.extension); + + // These values are initialized by findMatchingRules(): + this.matchedRule = null; + this.matchedModifyHeadersRules = []; + this.didCheckAncestors = false; + this.findMatchingRules(); + } + + /** + * Finds the matched rules for the given request and extensions, + * according to the logic documented at the top of this file. + * + * @param {RequestDetails} request + * @param {RuleManager[]} ruleManagers + * The list of RuleManagers, ordered by importance of its extension. + * @returns {MatchedRule[]} + */ + static evaluateRequest(request, ruleManagers) { + // Helper to determine precedence of rules from different extensions. + function precedence(matchedRule) { + switch (matchedRule.rule.action.type) { + case "block": + return 1; + case "redirect": + case "upgradeScheme": + return 2; + case "allow": + case "allowAllRequests": + return 3; + // case "modifyHeaders": not comparable after the first pass. + default: + throw new Error(`Unexpected action: ${matchedRule.rule.action.type}`); + } + } + + let requestEvaluators = []; + let finalMatch; + for (let ruleManager of ruleManagers) { + // Evaluate request with findMatchingRules(): + const requestEvaluator = new RequestEvaluator(request, ruleManager); + // RequestEvaluator may be used after the loop when the request is + // accepted, to collect modifyHeaders/allow/allowAllRequests actions. + requestEvaluators.push(requestEvaluator); + let matchedRule = requestEvaluator.matchedRule; + if ( + matchedRule && + (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) + ) { + // Before choosing the matched rule as finalMatch, check whether there + // is an allowAllRequests rule override among the ancestors. + requestEvaluator.findAncestorRuleOverride(); + matchedRule = requestEvaluator.matchedRule; + if (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) { + finalMatch = matchedRule; + if (finalMatch.rule.action.type === "block") { + break; + } + } + } + } + if (finalMatch && !finalMatch.rule.isAllowOrAllowAllRequestsAction()) { + // Found block/redirect/upgradeScheme, request will be replaced. + return [finalMatch]; + } + // Request not canceled, collect all modifyHeaders actions: + let matchedRules = requestEvaluators + .map(re => re.getMatchingModifyHeadersRules()) + .flat(1); + + // ... and collect the allowAllRequests actions: + // Note: Only needed for testMatchOutcome, getMatchedRules (bug 1745765) and + // onRuleMatchedDebug (bug 1745773). Not for regular requests, since regular + // requests do not distinguish between no rule vs allow vs allowAllRequests. + let finalAllowAllRequestsMatches = []; + for (let requestEvaluator of requestEvaluators) { + // TODO bug 1745765 / bug 1745773: Uncomment findAncestorRuleOverride() + // when getMatchedRules() or onRuleMatchedDebug are implemented. + // requestEvaluator.findAncestorRuleOverride(); + let matchedRule = requestEvaluator.matchedRule; + if (matchedRule && matchedRule.rule.action.type === "allowAllRequests") { + // Even if a different extension wins the final match, an extension + // may want to record the "allowAllRequests" action for the future. + finalAllowAllRequestsMatches.push(matchedRule); + } + } + if (finalAllowAllRequestsMatches.length) { + matchedRules = finalAllowAllRequestsMatches.concat(matchedRules); + } + + // ... and collect the "allow" action. At this point, finalMatch could also + // be a modifyHeaders or allowAllRequests action, but these would already + // have been added to the matchedRules result before. + if (finalMatch && finalMatch.rule.action.type === "allow") { + matchedRules.unshift(finalMatch); + } + return matchedRules; + } + + /** + * Finds the matching rules, as documented in the comment before the class. + */ + findMatchingRules() { + if (!this.canModify && !this.ruleManager.hasBlockPermission) { + // If the extension cannot apply any action, don't bother. + return; + } + + this.#collectMatchInRuleset(this.ruleManager.sessionRules); + this.#collectMatchInRuleset(this.ruleManager.dynamicRules); + for (let ruleset of this.ruleManager.enabledStaticRules) { + this.#collectMatchInRuleset(ruleset); + } + + if (this.matchedRule && !this.#isRuleActionAllowed(this.matchedRule.rule)) { + this.matchedRule = null; + // Note: this.matchedModifyHeadersRules is [] because canModify access is + // checked before populating the list. + } + } + + /** + * Find an "allowAllRequests" rule among the ancestors that may override the + * current matchedRule and/or matchedModifyHeadersRules rules. + */ + findAncestorRuleOverride() { + if (this.didCheckAncestors) { + return; + } + this.didCheckAncestors = true; + + if (!this.ruleManager.hasRulesWithAllowAllRequests) { + // Optimization: Skip ancestorRequestDetails lookup and/or request + // evaluation if there are no allowAllRequests rules. + return; + } + + // Now we need to check whether any of the ancestor frames had a matching + // allowAllRequests rule. matchedRule and/or matchedModifyHeadersRules + // results may be ignored if their priority is lower or equal to the + // highest-priority allowAllRequests rule among the frame ancestors. + // + // In theory, every ancestor may potentially yield an allowAllRequests rule, + // and should therefore be checked unconditionally. But logically, if there + // are no existing matches, then any matching allowAllRequests rules will + // not have any effect on the request outcome. As an optimization, we + // therefore skip ancestor checks in this case. + if ( + (!this.matchedRule || + this.matchedRule.rule.isAllowOrAllowAllRequestsAction()) && + !this.matchedModifyHeadersRules.length + ) { + // Optimization: Do not look up ancestors if no rules were matched. + // + // TODO bug 1745773: onRuleMatchedDebug is supposed to report when a rule + // has been matched. To be pedantic, when there is an onRuleMatchedDebug + // listener, the parents need to be checked unconditionally, in order to + // report potential allowAllRequests matches among ancestors. + // TODO bug 1745765: the above may also apply to getMatchedRules(). + return; + } + + for (let request of this.req.ancestorRequestDetails) { + // TODO: Optimize by only evaluating allow/allowAllRequests rules, because + // the request being seen here implies that the request was not canceled, + // i.e. that there were no block/redirect/upgradeScheme rules in any of + // the ancestors (across all extensions!). + let requestEvaluator = new RequestEvaluator(request, this.ruleManager); + let ancestorMatchedRule = requestEvaluator.matchedRule; + if ( + ancestorMatchedRule && + ancestorMatchedRule.rule.action.type === "allowAllRequests" && + (!this.matchedRule || + compareRule( + this.matchedRule.rule, + ancestorMatchedRule.rule, + this.matchedRule.ruleset, + ancestorMatchedRule.ruleset + ) > 0) + ) { + // Found an allowAllRequests rule that takes precedence over whatever + // the current rule was. + this.matchedRule = ancestorMatchedRule; + } + } + } + + /** + * Retrieves the list of matched modifyHeaders rules that should apply. + * + * @returns {MatchedRule[]} + */ + getMatchingModifyHeadersRules() { + if (this.matchedModifyHeadersRules.length) { + // Find parent allowAllRequests rules, if any, to make sure that we can + // appropriately ignore same-or-lower-priority modifyHeaders rules. + this.findAncestorRuleOverride(); + } + // The minimum priority is 1. Defaulting to 0 = include all. + let priorityThreshold = 0; + if (this.matchedRule?.rule.isAllowOrAllowAllRequestsAction()) { + priorityThreshold = this.matchedRule.rule.priority; + } + // Note: the result cannot be non-empty if this.matchedRule is a non-allow + // action, because if that were to be the case, then the request would have + // been canceled, and therefore there would not be any header to modify. + // Even if another extension were to override the action, it could only be + // any other non-allow action, which would still cancel the request. + let matchedRules = this.matchedModifyHeadersRules.filter(matchedRule => { + return matchedRule.rule.priority > priorityThreshold; + }); + // Sort output for a deterministic order. + // NOTE: Sorting rules at registration (in RuleManagers) would avoid the + // need to sort here. Since the number of matched modifyHeaders rules are + // expected to be small, we don't bother optimizing. + matchedRules.sort((a, b) => { + return compareRule(a.rule, b.rule, a.ruleset, b.ruleset); + }); + return matchedRules; + } + + /** @param {Ruleset} ruleset */ + #collectMatchInRuleset(ruleset) { + for (let rule of ruleset.rules) { + if (!this.#matchesRuleCondition(rule.condition)) { + continue; + } + if (rule.action.type === "modifyHeaders") { + if (this.canModify) { + this.matchedModifyHeadersRules.push(new MatchedRule(rule, ruleset)); + } + continue; + } + if ( + this.matchedRule && + compareRule( + this.matchedRule.rule, + rule, + this.matchedRule.ruleset, + ruleset + ) <= 0 + ) { + continue; + } + this.matchedRule = new MatchedRule(rule, ruleset); + } + } + + /** + * @param {RuleCondition} cond + * @returns {boolean} Whether the condition matched. + */ + #matchesRuleCondition(cond) { + if (cond.resourceTypes) { + if (!cond.resourceTypes.includes(this.req.type)) { + return false; + } + } else if (cond.excludedResourceTypes) { + if (cond.excludedResourceTypes.includes(this.req.type)) { + return false; + } + } else if (this.req.type === "main_frame") { + // When resourceTypes/excludedResourceTypes are not specified, the + // documented behavior is to ignore main_frame requests. + return false; + } + + // Check this.req.requestURI: + if (cond.urlFilter) { + if (!cond.urlFilterMatches(this.req.requestDataForUrlFilter)) { + return false; + } + } else if (cond.regexFilter) { + if (!cond.getCompiledRegexFilter().test(this.req.requestURIspec)) { + return false; + } + } + if ( + cond.excludedRequestDomains && + this.#matchesDomains(cond.excludedRequestDomains, this.req.requestDomain) + ) { + return false; + } + if ( + cond.requestDomains && + !this.#matchesDomains(cond.requestDomains, this.req.requestDomain) + ) { + return false; + } + if ( + cond.excludedInitiatorDomains && + // Note: unable to only match null principals (bug 1798225). + this.req.initiatorDomain && + this.#matchesDomains( + cond.excludedInitiatorDomains, + this.req.initiatorDomain + ) + ) { + return false; + } + if ( + cond.initiatorDomains && + // Note: unable to only match null principals (bug 1798225). + (!this.req.initiatorDomain || + !this.#matchesDomains(cond.initiatorDomains, this.req.initiatorDomain)) + ) { + return false; + } + + // TODO bug 1797408: domainType + + if (cond.requestMethods) { + if (!cond.requestMethods.includes(this.req.method)) { + return false; + } + } else if (cond.excludedRequestMethods?.includes(this.req.method)) { + return false; + } + + if (cond.tabIds) { + if (!cond.tabIds.includes(this.req.tabId)) { + return false; + } + } else if (cond.excludedTabIds?.includes(this.req.tabId)) { + return false; + } + + return true; + } + + /** + * @param {string[]} domains - A list of canonicalized domain patterns. + * Canonical means punycode, no ports, and IPv6 without brackets, and not + * starting with a dot. May end with a dot if it is a FQDN. + * @param {string} host - The canonical representation of the host of a URL. + * @returns {boolean} Whether the given host is a (sub)domain of any of the + * given domains. + */ + #matchesDomains(domains, host) { + return domains.some(domain => { + return ( + host.endsWith(domain) && + // either host === domain + (host.length === domain.length || + // or host = "something." + domain (WITH a domain separator). + host.charAt(host.length - domain.length - 1) === ".") + ); + }); + } + + /** + * @param {Rule} rule - The final rule from the first pass. + * @returns {boolean} Whether the extension is allowed to execute the rule. + */ + #isRuleActionAllowed(rule) { + if (this.canModify) { + return true; + } + switch (rule.action.type) { + case "allow": + case "allowAllRequests": + case "block": + case "upgradeScheme": + return this.ruleManager.hasBlockPermission; + case "redirect": + return false; + // case "modifyHeaders" is never an action for this.matchedRule. + default: + throw new Error(`Unexpected action type: ${rule.action.type}`); + } + } +} + +/** + * Checks whether a request from a document with the given URI is allowed to + * be modified by an unprivileged extension (e.g. an extension without host + * permissions but the "declarativeNetRequest" permission). + * The output is comparable to WebExtensionPolicy::CanAccessURI for an extension + * with the `<all_urls>` permission, for consistency with the webRequest API. + * + * @param {nsIURI} [uri] The URI of a request's loadingPrincipal. May be void + * if missing (e.g. top-level requests) or not a content principal. + * @returns {boolean} Whether there is any extension that is allowed to see + * requests from a document with the given URI. Callers are expected to: + * - check system requests (and treat as true). + * - check WebExtensionPolicy.isRestrictedURI (and treat as true). + */ +function isRestrictedPrincipalURI(uri) { + if (!uri) { + // No URI, could be: + // - System principal (caller should have checked and disallowed access). + // - Expanded principal, typically content script in documents. If an + // extension content script managed to run there, that implies that an + // extension was able to access it. + // - Null principal (e.g. sandboxed document, about:blank, data:). + return false; + } + + // An unprivileged extension with maximal host permissions has allowedOrigins + // set to [`<all_urls>`, `moz-extension://extensions-own-uuid-here`]. + // `<all_urls>` matches PermittedSchemes from MatchPattern.cpp: + // https://searchfox.org/mozilla-central/rev/55d5c4b9dffe5e59eb6b019c1a930ec9ada47e10/toolkit/components/extensions/MatchPattern.cpp#209-211 + // i.e. "http", "https", "ws", "wss", "file", "ftp", "data". + // - It is not possible to have a loadingPrincipal for: ws, wss, ftp. + // - data:-URIs always have an opaque origin, i.e. the principal is not a + // content principal, thus void here. + // - The remaining schemes from `<all_urls>` are: http, https, file, data, + // and checked below. + // + // Privileged addons can also access resource: and about:, but we do not need + // to support these now. + + // http(s) are common, and allowed, except for some restricted domains. The + // caller is expected to check WebExtensionPolicy.isRestrictedURI. + if (uri.schemeIs("http") || uri.schemeIs("https")) { + return false; // Very common. + } + + // moz-extension: is not restricted because an extension always has permission + // to its own moz-extension:-origin. The caller is expected to verify that an + // extension can only access its own URI. + if (uri.schemeIs("moz-extension")) { + return false; + } + + // Requests from local files are intentionally allowed (bug 1621935). + if (uri.schemeIs("file")) { + return false; + } + + // Anything else (e.g. resource:, about:newtab, etc.) is not allowed. + return true; +} + +const NetworkIntegration = { + maxEvaluatedRulesCount: 0, + + register() { + // We register via WebRequest.jsm to ensure predictable ordering of DNR and + // WebRequest behavior. + lazy.WebRequest.setDNRHandlingEnabled(true); + }, + unregister() { + lazy.WebRequest.setDNRHandlingEnabled(false); + }, + maybeUpdateTabIdChecker() { + gHasAnyTabIdConditions = gRuleManagers.some(rm => rm.hasRulesWithTabIds); + }, + + startDNREvaluation(channel) { + let ruleManagers = gRuleManagers; + // TODO bug 1827422: Merge isRestrictedPrincipalURI with canModify. + if (!channel.canModify || isRestrictedPrincipalURI(channel.documentURI)) { + // Ignore system requests or requests to restricted domains. + ruleManagers = []; + } + if (channel.loadInfo.originAttributes.privateBrowsingId > 0) { + ruleManagers = ruleManagers.filter( + rm => rm.extension.privateBrowsingAllowed + ); + } + if (ruleManagers.length && !lazy.gMatchRequestsFromOtherExtensions) { + const policy = channel.loadInfo.loadingPrincipal?.addonPolicy; + if (policy) { + ruleManagers = ruleManagers.filter( + rm => rm.extension.policy === policy + ); + } + } + let matchedRules; + if (ruleManagers.length) { + const evaluateRulesTimerId = + Glean.extensionsApisDnr.evaluateRulesTime.start(); + try { + const request = RequestDetails.fromChannelWrapper(channel); + matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers); + } finally { + if (evaluateRulesTimerId !== undefined) { + Glean.extensionsApisDnr.evaluateRulesTime.stopAndAccumulate( + evaluateRulesTimerId + ); + } + } + const evaluateRulesCount = ruleManagers.reduce( + (sum, ruleManager) => sum + ruleManager.getRulesCount(), + 0 + ); + if (evaluateRulesCount > this.maxEvaluatedRulesCount) { + Glean.extensionsApisDnr.evaluateRulesCountMax.set(evaluateRulesCount); + this.maxEvaluatedRulesCount = evaluateRulesCount; + } + } + // Cache for later. In case of redirects, _dnrMatchedRules may exist for + // the pre-redirect HTTP channel, and is overwritten here again. + channel._dnrMatchedRules = matchedRules; + }, + + /** + * Applies the actions of the DNR rules. + * + * @param {ChannelWrapper} channel + * @returns {boolean} Whether to ignore any responses from the webRequest API. + */ + onBeforeRequest(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return false; + } + // If a matched rule closes the channel, it is the sole match. + const finalMatch = matchedRules[0]; + switch (finalMatch.rule.action.type) { + case "block": + this.applyBlock(channel, finalMatch); + return true; + case "redirect": + this.applyRedirect(channel, finalMatch); + return true; + case "upgradeScheme": + this.applyUpgradeScheme(channel, finalMatch); + return true; + } + // If there are multiple rules, then it may be a combination of allow, + // allowAllRequests and/or modifyHeaders. + + // "modifyHeaders" is handled by onBeforeSendHeaders/onHeadersReceived. + // "allow" and "allowAllRequests" require no further action now. + // "allowAllRequests" is applied to new requests in the future (if any) + // through RequestEvaluator's findAncestorRuleOverride(). + + return false; + }, + + onBeforeSendHeaders(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return; + } + ModifyRequestHeaders.maybeApplyModifyHeaders(channel, matchedRules); + }, + + onHeadersReceived(channel) { + let matchedRules = channel._dnrMatchedRules; + if (!matchedRules?.length) { + return; + } + ModifyResponseHeaders.maybeApplyModifyHeaders(channel, matchedRules); + }, + + applyBlock(channel, matchedRule) { + // TODO bug 1802259: Consider a DNR-specific reason. + channel.cancel( + Cr.NS_ERROR_ABORT, + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST + ); + const addonId = matchedRule.ruleManager.extension.id; + let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag); + properties.setProperty("cancelledByExtension", addonId); + }, + + applyUpgradeScheme(channel, matchedRule) { + // Request upgrade. No-op if already secure (i.e. https). + channel.upgradeToSecure(); + }, + + applyRedirect(channel, matchedRule) { + // Ambiguity resolution order of redirect dict keys, consistent with Chrome: + // - url > extensionPath > transform > regexSubstitution + const redirect = matchedRule.rule.action.redirect; + const extension = matchedRule.ruleManager.extension; + const preRedirectUri = channel.finalURI; + let redirectUri; + if (redirect.url) { + // redirect.url already validated by checkActionRedirect. + redirectUri = Services.io.newURI(redirect.url); + } else if (redirect.extensionPath) { + redirectUri = extension.baseURI + .mutate() + .setPathQueryRef(redirect.extensionPath) + .finalize(); + } else if (redirect.transform) { + redirectUri = applyURLTransform(preRedirectUri, redirect.transform); + } else if (redirect.regexSubstitution) { + // Note: may throw if regexSubstitution results in an invalid redirect. + // The error propagates up to handleRequest, which will just allow the + // request to continue. + redirectUri = applyRegexSubstitution(preRedirectUri, matchedRule); + } else { + // #checkActionRedirect ensures that the redirect action is non-empty. + } + + if (preRedirectUri.equals(redirectUri)) { + // URL did not change. Sometimes it is a bug in the extension, but there + // are also cases where the result is unavoidable. E.g. redirect.transform + // with queryTransform.removeParams that does not remove anything. + // TODO: consider logging to help with debugging. + return; + } + + channel.redirectTo(redirectUri); + + let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag); + properties.setProperty("redirectedByExtension", extension.id); + + let origin = channel.getRequestHeader("Origin"); + if (origin) { + channel.setResponseHeader("Access-Control-Allow-Origin", origin); + channel.setResponseHeader("Access-Control-Allow-Credentials", "true"); + channel.setResponseHeader("Access-Control-Max-Age", "0"); + } + }, +}; + +class RuleManager { + constructor(extension) { + this.extension = extension; + this.sessionRules = this.makeRuleset( + "_session", + PRECEDENCE_SESSION_RULESET + ); + this.dynamicRules = this.makeRuleset( + "_dynamic", + PRECEDENCE_DYNAMIC_RULESET + ); + this.enabledStaticRules = []; + + this.hasBlockPermission = extension.hasPermission("declarativeNetRequest"); + this.hasRulesWithTabIds = false; + this.hasRulesWithAllowAllRequests = false; + this.totalRulesCount = 0; + } + + get availableStaticRuleCount() { + return Math.max( + lazy.ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES - + this.enabledStaticRules.reduce( + (acc, ruleset) => acc + ruleset.rules.length, + 0 + ), + 0 + ); + } + + get enabledStaticRulesetIds() { + return this.enabledStaticRules.map(ruleset => ruleset.id); + } + + makeRuleset(rulesetId, rulesetPrecedence, rules = []) { + return new Ruleset(rulesetId, rulesetPrecedence, rules, this); + } + + setSessionRules(validatedSessionRules) { + let oldRulesCount = this.sessionRules.rules.length; + let newRulesCount = validatedSessionRules.length; + this.sessionRules.rules = validatedSessionRules; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.hasRulesWithTabIds = !!this.sessionRules.rules.find(rule => { + return rule.condition.tabIds || rule.condition.excludedTabIds; + }); + this.#updateAllowAllRequestRules(); + NetworkIntegration.maybeUpdateTabIdChecker(); + } + + setDynamicRules(validatedDynamicRules) { + let oldRulesCount = this.dynamicRules.rules.length; + let newRulesCount = validatedDynamicRules.length; + this.dynamicRules.rules = validatedDynamicRules; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.#updateAllowAllRequestRules(); + } + + /** + * Set the enabled static rulesets. + * + * @param {Array<{ id, rules }>} enabledStaticRulesets + * Array of objects including the ruleset id and rules. + * The order of the rulesets in the Array is expected to + * match the order of the rulesets in the extension manifest. + */ + setEnabledStaticRulesets(enabledStaticRulesets) { + const rulesets = []; + for (const [idx, { id, rules }] of enabledStaticRulesets.entries()) { + rulesets.push( + this.makeRuleset(id, idx + PRECEDENCE_STATIC_RULESETS_BASE, rules) + ); + } + const countRules = rulesets => + rulesets.reduce((sum, ruleset) => sum + ruleset.rules.length, 0); + const oldRulesCount = countRules(this.enabledStaticRules); + const newRulesCount = countRules(rulesets); + this.enabledStaticRules = rulesets; + this.totalRulesCount += newRulesCount - oldRulesCount; + this.#updateAllowAllRequestRules(); + } + + getSessionRules() { + return this.sessionRules.rules; + } + + getDynamicRules() { + return this.dynamicRules.rules; + } + + getRulesCount() { + return this.totalRulesCount; + } + + #updateAllowAllRequestRules() { + const filterAAR = rule => rule.action.type === "allowAllRequests"; + this.hasRulesWithAllowAllRequests = + this.sessionRules.rules.some(filterAAR) || + this.dynamicRules.rules.some(filterAAR) || + this.enabledStaticRules.some(ruleset => ruleset.rules.some(filterAAR)); + } +} + +function getRuleManager(extension, createIfMissing = true) { + let ruleManager = gRuleManagers.find(rm => rm.extension === extension); + if (!ruleManager && createIfMissing) { + if (extension.hasShutdown) { + throw new Error( + `Error on creating new DNR RuleManager after extension shutdown: ${extension.id}` + ); + } + ruleManager = new RuleManager(extension); + // The most recently installed extension gets priority, i.e. appears at the + // start of the gRuleManagers list. It is not yet possible to determine the + // installation time of a given Extension, so currently the last to + // instantiate a RuleManager claims the highest priority. + // TODO bug 1786059: order extensions by "installation time". + gRuleManagers.unshift(ruleManager); + if (gRuleManagers.length === 1) { + // The first DNR registration. + NetworkIntegration.register(); + } + } + return ruleManager; +} + +function clearRuleManager(extension) { + let i = gRuleManagers.findIndex(rm => rm.extension === extension); + if (i !== -1) { + gRuleManagers.splice(i, 1); + NetworkIntegration.maybeUpdateTabIdChecker(); + if (gRuleManagers.length === 0) { + // The last DNR registration. + NetworkIntegration.unregister(); + } + } +} + +/** + * Finds all matching rules for a request, optionally restricted to one + * extension. Used by declarativeNetRequest.testMatchOutcome. + * + * @param {object|RequestDetails} request + * @param {Extension} [extension] + * @returns {MatchedRule[]} + */ +function getMatchedRulesForRequest(request, extension) { + let requestDetails = new RequestDetails(request); + const { requestURI, initiatorURI } = requestDetails; + let ruleManagers = gRuleManagers; + if (extension) { + ruleManagers = ruleManagers.filter(rm => rm.extension === extension); + } + if ( + // NetworkIntegration.startDNREvaluation does not check requestURI, but we + // do that here to filter URIs that are obviously disallowed. In practice, + // anything other than http(s) is bogus and unsupported in DNR. + isRestrictedPrincipalURI(requestURI) || + // Equivalent to NetworkIntegration.startDNREvaluation's channel.canModify + // check, which excludes system requests and restricted domains. + WebExtensionPolicy.isRestrictedURI(requestURI) || + (initiatorURI && WebExtensionPolicy.isRestrictedURI(initiatorURI)) || + isRestrictedPrincipalURI(initiatorURI) + ) { + ruleManagers = []; + } + // While this simulated request is not really from another extension, apply + // the same access control checks from NetworkIntegration.startDNREvaluation + // for consistency. + if ( + !lazy.gMatchRequestsFromOtherExtensions && + initiatorURI?.schemeIs("moz-extension") + ) { + const extUuid = initiatorURI.host; + ruleManagers = ruleManagers.filter(rm => rm.extension.uuid === extUuid); + } + return RequestEvaluator.evaluateRequest(requestDetails, ruleManagers); +} + +/** + * Runs before any webRequest event is notified. Headers may be modified, but + * the request should not be canceled (see handleRequest instead). + * + * @param {ChannelWrapper} channel + * @param {string} kind - The name of the webRequest event. + */ +function beforeWebRequestEvent(channel, kind) { + try { + switch (kind) { + case "onBeforeRequest": + NetworkIntegration.startDNREvaluation(channel); + break; + case "onBeforeSendHeaders": + NetworkIntegration.onBeforeSendHeaders(channel); + break; + case "onHeadersReceived": + NetworkIntegration.onHeadersReceived(channel); + break; + } + } catch (e) { + Cu.reportError(e); + } +} + +/** + * Applies matching DNR rules, some of which may potentially cancel the request. + * + * @param {ChannelWrapper} channel + * @param {string} kind - The name of the webRequest event. + * @returns {boolean} Whether to ignore any responses from the webRequest API. + */ +function handleRequest(channel, kind) { + try { + if (kind === "onBeforeRequest") { + return NetworkIntegration.onBeforeRequest(channel); + } + } catch (e) { + Cu.reportError(e); + } + return false; +} + +async function initExtension(extension) { + // These permissions are NOT an OptionalPermission, so their status can be + // assumed to be constant for the lifetime of the extension. + if ( + extension.hasPermission("declarativeNetRequest") || + extension.hasPermission("declarativeNetRequestWithHostAccess") + ) { + if (extension.hasShutdown) { + throw new Error( + `Aborted ExtensionDNR.initExtension call, extension "${extension.id}" is not active anymore` + ); + } + extension.once("shutdown", () => clearRuleManager(extension)); + await lazy.ExtensionDNRStore.initExtension(extension); + } +} + +function ensureInitialized(extension) { + return (extension._dnrReady ??= initExtension(extension)); +} + +function validateManifestEntry(extension) { + const ruleResourcesArray = + extension.manifest.declarative_net_request.rule_resources; + + const getWarningMessage = msg => + `Warning processing declarative_net_request: ${msg}`; + + const { MAX_NUMBER_OF_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + if (ruleResourcesArray.length > MAX_NUMBER_OF_STATIC_RULESETS) { + extension.manifestWarning( + getWarningMessage( + `Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit (${MAX_NUMBER_OF_STATIC_RULESETS}).` + ) + ); + } + + const seenRulesetIds = new Set(); + const seenRulesetPaths = new Set(); + const duplicatedRulesetIds = []; + const duplicatedRulesetPaths = []; + for (const [idx, { id, path }] of ruleResourcesArray.entries()) { + if (seenRulesetIds.has(id)) { + duplicatedRulesetIds.push({ idx, id }); + } + if (seenRulesetPaths.has(path)) { + duplicatedRulesetPaths.push({ idx, path }); + } + seenRulesetIds.add(id); + seenRulesetPaths.add(path); + } + + if (duplicatedRulesetIds.length) { + const errorDetails = duplicatedRulesetIds + .map(({ idx, id }) => `"${id}" at index ${idx}`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Static ruleset ids should be unique, duplicated ruleset ids: ${errorDetails}.` + ) + ); + } + + if (duplicatedRulesetPaths.length) { + // NOTE: technically Chrome allows duplicated paths without any manifest + // validation warnings or errors, but if this happens it not unlikely to be + // actually a mistake in the manifest that may have been missed. + // + // In Firefox we decided to allow the same behavior to avoid introducing a chrome + // incompatibility, but we still warn about it to avoid extension developers + // to investigate more easily issue that may be due to duplicated rulesets + // paths. + const errorDetails = duplicatedRulesetPaths + .map(({ idx, path }) => `"${path}" at index ${idx}`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Static rulesets paths are not unique, duplicated ruleset paths: ${errorDetails}.` + ) + ); + } + + const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits; + + const enabledRulesets = ruleResourcesArray.filter(rs => rs.enabled); + if (enabledRulesets.length > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) { + const exceedingRulesetIds = enabledRulesets + .slice(MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) + .map(ruleset => `"${ruleset.id}"`) + .join(", "); + extension.manifestWarning( + getWarningMessage( + `Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ${exceedingRulesetIds}.` + ) + ); + } +} + +async function updateEnabledStaticRulesets(extension, updateRulesetOptions) { + await ensureInitialized(extension); + await lazy.ExtensionDNRStore.updateEnabledStaticRulesets( + extension, + updateRulesetOptions + ); +} + +async function updateDynamicRules(extension, updateRuleOptions) { + await ensureInitialized(extension); + await lazy.ExtensionDNRStore.updateDynamicRules(extension, updateRuleOptions); +} + +// exports used by the DNR API implementation. +export const ExtensionDNR = { + RuleValidator, + RuleQuotaCounter, + clearRuleManager, + ensureInitialized, + getMatchedRulesForRequest, + getRuleManager, + updateDynamicRules, + updateEnabledStaticRulesets, + validateManifestEntry, + beforeWebRequestEvent, + handleRequest, +}; |