summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/ExtensionDNR.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/extensions/ExtensionDNR.sys.mjs1926
1 files changed, 1926 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..c3a78eefa1
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionDNR.sys.mjs
@@ -0,0 +1,1926 @@
+/* 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
+ */
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "WebRequest",
+ "resource://gre/modules/WebRequest.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
+});
+
+/**
+ * The minimum number of static rules guaranteed to an extension across its
+ * enabled static rulesets. Any rules above this limit will count towards the
+ * global static rule limit.
+ */
+const GUARANTEED_MINIMUM_STATIC_RULES = 30000;
+
+/**
+ * The maximum number of static Rulesets an extension can specify as part of
+ * the "rule_resources" manifest key.
+ *
+ * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318
+ */
+const MAX_NUMBER_OF_STATIC_RULESETS = 50;
+
+/**
+ * The maximum number of static Rulesets an extension can enable at any one time.
+ *
+ * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318
+ */
+const MAX_NUMBER_OF_ENABLED_STATIC_RULESETS = 10;
+
+/**
+ * The maximum number of dynamic and session rules an extension can add.
+ * NOTE: in the Firefox we are enforcing this limit to the session and dynamic rules count separately,
+ * instead of enforcing it to the rules count for both combined as the Chrome implementation does.
+ *
+ * NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/319
+ */
+const MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES = 5000;
+
+// TODO(Bug 1803370): allow extension to exceed the GUARANTEED_MINIMUM_STATIC_RULES limit.
+//
+// The maximum number of static rules exceeding the per-extension
+// GUARANTEED_MINIMUM_STATIC_RULES across every extensions.
+//
+// const MAX_GLOBAL_NUMBER_OF_STATIC_RULES = 300000;
+
+// 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;
+
+ 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);
+ }
+
+ getCompiledUrlFilter() {
+ return this.#compiledUrlFilter;
+ }
+ setCompiledUrlFilter(compiledUrlFilter) {
+ this.#compiledUrlFilter = compiledUrlFilter;
+ }
+}
+
+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();
+}
+
+/**
+ * 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 {nsIURI} requestURI - The URL to match against.
+ * @returns {object} An object to p
+ */
+ constructor(requestURI) {
+ // "^" is appended, see CompiledUrlFilter's #initializeUrlFilter.
+ this.urlAnyCase = requestURI.spec + "^";
+ 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;
+ }
+}
+
+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;
+ }
+
+ 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);
+ }
+ }
+
+ 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;
+ }
+
+ 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);
+
+ this.rulesMap.set(rule.id, newRule);
+ }
+ }
+
+ // 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;
+ }
+ // TODO bug 1745764 / bug 1745763: after adding support for dynamic/static
+ // rules, validate that we only have a session ruleset here.
+ return true;
+ }
+
+ static #regexNonASCII = /[^\x00-\x7F]/; // eslint-disable-line no-control-regex
+
+ // Checks: urlFilter & regexFilter
+ #checkCondUrlFilterAndRegexFilter(rule) {
+ const { urlFilter, regexFilter } = rule.condition;
+ 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;
+ }
+ // TODO bug 1745760: accept when regexFilter is a valid regexp.
+ this.#collectInvalidRule(rule, "regexFilter is not supported yet");
+ 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 { extensionPath, url, transform } = rule.action.redirect ?? {};
+ if (!url && extensionPath == null && !transform) {
+ this.#collectInvalidRule(
+ rule,
+ "A redirect rule must have a non-empty action.redirect object"
+ );
+ return false;
+ }
+ if (url && extensionPath != null) {
+ this.#collectInvalidRule(
+ rule,
+ "redirect.extensionPath and redirect.url are mutually exclusive"
+ );
+ return false;
+ }
+ if (extensionPath != null && !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;
+ }
+ }
+
+ // TODO bug 1745760: With regexFilter support, implement regexSubstitution.
+ 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;
+ }
+}
+
+/**
+ * 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 {
+ 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 (non-null).
+ * @param {string} options.type - ResourceType (MozContentPolicyType).
+ * @param {string} [options.method] - HTTP method
+ * @param {integer} [options.tabId]
+ */
+ constructor({ requestURI, initiatorURI, type, method, tabId }) {
+ this.requestURI = requestURI;
+ this.initiatorURI = initiatorURI;
+ this.type = type;
+ this.method = method;
+ this.tabId = tabId;
+
+ this.requestDomain = this.#domainFromURI(requestURI);
+ this.initiatorDomain = initiatorURI
+ ? this.#domainFromURI(initiatorURI)
+ : null;
+
+ this.requestDataForUrlFilter = new RequestDataForUrlFilter(requestURI);
+ }
+
+ 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,
+ });
+ }
+
+ canExtensionModify(extension) {
+ const policy = extension.policy;
+ return (
+ (!this.initiatorURI || policy.canAccessURI(this.initiatorURI)) &&
+ policy.canAccessURI(this.requestURI)
+ );
+ }
+
+ #domainFromURI(uri) {
+ 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;
+ }
+}
+
+/**
+ * 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.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;
+ let finalAllowAllRequestsMatches = [];
+ 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) {
+ if (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 (!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:
+ 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.
+ }
+ }
+
+ /**
+ * Retrieves the list of matched modifyHeaders rules that should apply.
+ *
+ * @returns {MatchedRule[]}
+ */
+ getMatchingModifyHeadersRules() {
+ // 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;
+ }
+
+ #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) {
+ // TODO bug 1745760: check cond.regexFilter + isUrlFilterCaseSensitive
+ }
+ 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}`);
+ }
+ }
+}
+
+const NetworkIntegration = {
+ 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;
+ if (!channel.canModify) {
+ ruleManagers = [];
+ }
+ if (channel.loadInfo.originAttributes.privateBrowsingId > 0) {
+ ruleManagers = ruleManagers.filter(
+ rm => rm.extension.privateBrowsingAllowed
+ );
+ }
+ let matchedRules;
+ if (ruleManagers.length) {
+ const request = RequestDetails.fromChannelWrapper(channel);
+ matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers);
+ }
+ // 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.
+
+ // TODO bug 1797403: Apply allowAllRequests actions.
+
+ 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;
+ 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(channel.finalURI, redirect.transform);
+ } else if (redirect.regexSubstitution) {
+ // TODO bug 1745760: Implement along with regexFilter support.
+ throw new Error("regexSubstitution not implemented");
+ } else {
+ // #checkActionRedirect ensures that the redirect action is non-empty.
+ }
+
+ 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
+ );
+ // TODO bug 1745764: support registration of (persistent) dynamic rules.
+ this.dynamicRules = this.makeRuleset(
+ "_dynamic",
+ PRECEDENCE_DYNAMIC_RULESET
+ );
+ this.enabledStaticRules = [];
+
+ this.hasBlockPermission = extension.hasPermission("declarativeNetRequest");
+ this.hasRulesWithTabIds = false;
+ }
+
+ get availableStaticRuleCount() {
+ return Math.max(
+ 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) {
+ this.sessionRules.rules = validatedSessionRules;
+ this.hasRulesWithTabIds = !!this.sessionRules.rules.find(rule => {
+ return rule.condition.tabIds || rule.condition.excludedTabIds;
+ });
+ NetworkIntegration.maybeUpdateTabIdChecker();
+ }
+
+ setDynamicRules(validatedDynamicRules) {
+ this.dynamicRules.rules = validatedDynamicRules;
+ }
+
+ /**
+ * 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)
+ );
+ }
+ this.enabledStaticRules = rulesets;
+ }
+
+ getSessionRules() {
+ return this.sessionRules.rules;
+ }
+
+ getDynamicRules() {
+ return this.dynamicRules.rules;
+ }
+}
+
+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.
+ *
+ * @param {object|RequestDetails} request
+ * @param {Extension} [extension]
+ * @returns {MatchedRule[]}
+ */
+function getMatchedRulesForRequest(request, extension) {
+ let requestDetails = new RequestDetails(request);
+ let ruleManagers = gRuleManagers;
+ if (extension) {
+ ruleManagers = ruleManagers.filter(rm => rm.extension === extension);
+ }
+ 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`
+ );
+ }
+ 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}`;
+
+ 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 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,
+ clearRuleManager,
+ ensureInitialized,
+ getMatchedRulesForRequest,
+ getRuleManager,
+ updateDynamicRules,
+ updateEnabledStaticRulesets,
+ validateManifestEntry,
+ // TODO(Bug 1803370): consider allowing changing DNR limits through about:config prefs).
+ limits: {
+ GUARANTEED_MINIMUM_STATIC_RULES,
+ MAX_NUMBER_OF_STATIC_RULESETS,
+ MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
+ MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES,
+ },
+
+ beforeWebRequestEvent,
+ handleRequest,
+};