Bug 1870926 [wpt PR 43734] - Remove experimental ::details-summary pseudo-element...
[gecko.git] / toolkit / components / extensions / ExtensionDNR.sys.mjs
blob2757333b3b419b395817cb248446afd0e4a4485b
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 // Each extension that uses DNR has one RuleManager. All registered RuleManagers
6 // are checked whenever a network request occurs. Individual extensions may
7 // occasionally modify their rules (e.g. via the updateSessionRules API).
8 const gRuleManagers = [];
10 /**
11  * Whenever a request occurs, the rules of each RuleManager are matched against
12  * the request to determine the final action to take. The RequestEvaluator class
13  * is responsible for evaluating rules, and its behavior is described below.
14  *
15  * Short version:
16  * Find the highest-priority rule that matches the given request. If the
17  * request is not canceled, all matching allowAllRequests and modifyHeaders
18  * actions are returned.
19  *
20  * Longer version:
21  * Unless stated otherwise, the explanation below describes the behavior within
22  * an extension.
23  * An extension can specify rules, optionally in multiple rulesets. The ability
24  * to have multiple ruleset exists to support bulk updates of rules. Rulesets
25  * are NOT independent - rules from different rulesets can affect each other.
26  *
27  * When multiple rules match, the order between rules are defined as follows:
28  * - Ruleset precedence: session > dynamic > static (order from manifest.json).
29  * - Rules in ruleset precedence: ordered by rule.id, lowest (numeric) ID first.
30  * - Across all rules+rulesets: highest rule.priority (default 1) first,
31  *                              action precedence if rule priority are the same.
32  *
33  * The primary documented way for extensions to describe precedence is by
34  * specifying rule.priority. Between same-priority rules, their precedence is
35  * dependent on the rule action. The ruleset/rule ID precedence is only used to
36  * have a defined ordering if multiple rules have the same priority+action.
37  *
38  * Rule actions have the following order of precedence and meaning:
39  * - "allow" can be used to ignore other same-or-lower-priority rules.
40  * - "allowAllRequests" (for main_frame / sub_frame resourceTypes only) has the
41  *      same effect as allow, but also applies to (future) subresource loads in
42  *      the document (including descendant frames) generated from the request.
43  * - "block" cancels the matched request.
44  * - "upgradeScheme" upgrades the scheme of the request.
45  * - "redirect" redirects the request.
46  * - "modifyHeaders" rewrites request/response headers.
47  *
48  * The matched rules are evaluated in two passes:
49  * 1. findMatchingRules():
50  *    Find the highest-priority rule(s), and choose the action with the highest
51  *    precedence (across all rulesets, any action except modifyHeaders).
52  *    This also accounts for any allowAllRequests from an ancestor frame.
53  *
54  * 2. getMatchingModifyHeadersRules():
55  *    Find matching rules with the "modifyHeaders" action, minus ignored rules.
56  *    Reaching this step implies that the request was not canceled, so either
57  *    the first step did not yield a rule, or the rule action is "allow" or
58  *    "allowAllRequests" (i.e. ignore same-or-lower-priority rules).
59  *
60  * If an extension does not have sufficient permissions for the action, the
61  * resulting action is ignored.
62  *
63  * The above describes the evaluation within one extension. When a sequence of
64  * (multiple) extensions is given, they may return conflicting actions in the
65  * first pass. This is resolved by choosing the action with the following order
66  * of precedence, in RequestEvaluator.evaluateRequest():
67  *  - block
68  *  - redirect / upgradeScheme
69  *  - allow / allowAllRequests
70  */
72 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
74 const lazy = {};
76 ChromeUtils.defineESModuleGetters(lazy, {
77   ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
78   ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
79   WebRequest: "resource://gre/modules/WebRequest.sys.mjs",
80 });
82 import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
84 const { ExtensionError } = ExtensionUtils;
86 XPCOMUtils.defineLazyPreferenceGetter(
87   lazy,
88   "gMatchRequestsFromOtherExtensions",
89   "extensions.dnr.match_requests_from_other_extensions",
90   false
93 // As documented above:
94 // Ruleset precedence: session > dynamic > static (order from manifest.json).
95 const PRECEDENCE_SESSION_RULESET = 1;
96 const PRECEDENCE_DYNAMIC_RULESET = 2;
97 const PRECEDENCE_STATIC_RULESETS_BASE = 3;
99 // The RuleCondition class represents a rule's "condition" type as described in
100 // schemas/declarative_net_request.json. This class exists to allow the JS
101 // engine to use one Shape for all Rule instances.
102 class RuleCondition {
103   #compiledUrlFilter;
104   #compiledRegexFilter;
106   constructor(cond) {
107     this.urlFilter = cond.urlFilter;
108     this.regexFilter = cond.regexFilter;
109     this.isUrlFilterCaseSensitive = cond.isUrlFilterCaseSensitive;
110     this.initiatorDomains = cond.initiatorDomains;
111     this.excludedInitiatorDomains = cond.excludedInitiatorDomains;
112     this.requestDomains = cond.requestDomains;
113     this.excludedRequestDomains = cond.excludedRequestDomains;
114     this.resourceTypes = cond.resourceTypes;
115     this.excludedResourceTypes = cond.excludedResourceTypes;
116     this.requestMethods = cond.requestMethods;
117     this.excludedRequestMethods = cond.excludedRequestMethods;
118     this.domainType = cond.domainType;
119     this.tabIds = cond.tabIds;
120     this.excludedTabIds = cond.excludedTabIds;
121   }
123   // See CompiledUrlFilter for documentation.
124   urlFilterMatches(requestDataForUrlFilter) {
125     if (!this.#compiledUrlFilter) {
126       // eslint-disable-next-line no-use-before-define
127       this.#compiledUrlFilter = new CompiledUrlFilter(
128         this.urlFilter,
129         this.isUrlFilterCaseSensitive
130       );
131     }
132     return this.#compiledUrlFilter.matchesRequest(requestDataForUrlFilter);
133   }
135   // Used for testing regexFilter matches in RuleEvaluator.#matchRuleCondition
136   // and to get redirect URL from regexSubstitution in applyRegexSubstitution.
137   getCompiledRegexFilter() {
138     return this.#compiledRegexFilter;
139   }
141   // RuleValidator compiles regexFilter before this Rule class is instantiated.
142   // To avoid unnecessarily compiling it again, the result is assigned here.
143   setCompiledRegexFilter(compiledRegexFilter) {
144     this.#compiledRegexFilter = compiledRegexFilter;
145   }
148 export class Rule {
149   constructor(rule) {
150     this.id = rule.id;
151     this.priority = rule.priority;
152     this.condition = new RuleCondition(rule.condition);
153     this.action = rule.action;
154   }
156   // The precedence of rules within an extension. This method is frequently
157   // used during the first pass of the RequestEvaluator.
158   actionPrecedence() {
159     switch (this.action.type) {
160       case "allow":
161         return 1; // Highest precedence.
162       case "allowAllRequests":
163         return 2;
164       case "block":
165         return 3;
166       case "upgradeScheme":
167         return 4;
168       case "redirect":
169         return 5;
170       case "modifyHeaders":
171         return 6;
172       default:
173         throw new Error(`Unexpected action type: ${this.action.type}`);
174     }
175   }
177   isAllowOrAllowAllRequestsAction() {
178     const type = this.action.type;
179     return type === "allow" || type === "allowAllRequests";
180   }
183 class Ruleset {
184   /**
185    * @param {string} rulesetId - extension-defined ruleset ID.
186    * @param {integer} rulesetPrecedence
187    * @param {Rule[]} rules - extension-defined rules
188    * @param {RuleManager} ruleManager - owner of this ruleset.
189    */
190   constructor(rulesetId, rulesetPrecedence, rules, ruleManager) {
191     this.id = rulesetId;
192     this.rulesetPrecedence = rulesetPrecedence;
193     this.rules = rules;
194     // For use by MatchedRule.
195     this.ruleManager = ruleManager;
196   }
200  * @param {string} uriQuery - The query of a nsIURI to transform.
201  * @param {object} queryTransform - The value of the
202  *   Rule.action.redirect.transform.queryTransform property as defined in
203  *   declarative_net_request.json.
204  * @returns {string} The uriQuery with the queryTransform applied to it.
205  */
206 function applyQueryTransform(uriQuery, queryTransform) {
207   // URLSearchParams cannot be applied to the full query string, because that
208   // API formats the full query string using form-urlencoding. But the input
209   // may be in a different format. So we try to only modify matched params.
211   function urlencode(s) {
212     // Encode in application/x-www-form-urlencoded format.
213     // The only JS API to do that is URLSearchParams. encodeURIComponent is not
214     // the same, it differs in how it handles " " ("%20") and "!'()~" (raw).
215     // But urlencoded space should be "+" and the latter be "%21%27%28%29%7E".
216     return new URLSearchParams({ s }).toString().slice(2);
217   }
218   if (!uriQuery.length && !queryTransform.addOrReplaceParams) {
219     // Nothing to do.
220     return "";
221   }
222   const removeParamsSet = new Set(queryTransform.removeParams?.map(urlencode));
223   const addParams = (queryTransform.addOrReplaceParams || []).map(orig => ({
224     normalizedKey: urlencode(orig.key),
225     orig,
226   }));
227   const finalParams = [];
228   if (uriQuery.length) {
229     for (let part of uriQuery.split("&")) {
230       let key = part.split("=", 1)[0];
231       if (removeParamsSet.has(key)) {
232         continue;
233       }
234       let i = addParams.findIndex(p => p.normalizedKey === key);
235       if (i !== -1) {
236         // Replace found param with the key-value from addOrReplaceParams.
237         finalParams.push(`${key}=${urlencode(addParams[i].orig.value)}`);
238         // Omit param so that a future search for the same key can find the next
239         // specified key-value pair, if any. And to prevent the already-used
240         // key-value pairs from being appended after the loop.
241         addParams.splice(i, 1);
242       } else {
243         finalParams.push(part);
244       }
245     }
246   }
247   // Append remaining, unused key-value pairs.
248   for (let { normalizedKey, orig } of addParams) {
249     if (!orig.replaceOnly) {
250       finalParams.push(`${normalizedKey}=${urlencode(orig.value)}`);
251     }
252   }
253   return finalParams.length ? `?${finalParams.join("&")}` : "";
257  * @param {nsIURI} uri - Usually a http(s) URL.
258  * @param {object} transform - The value of the Rule.action.redirect.transform
259  *   property as defined in declarative_net_request.json.
260  * @returns {nsIURI} uri - The new URL.
261  * @throws if the transformation is invalid.
262  */
263 function applyURLTransform(uri, transform) {
264   let mut = uri.mutate();
265   if (transform.scheme) {
266     // Note: declarative_net_request.json only allows http(s)/moz-extension:.
267     mut.setScheme(transform.scheme);
268     if (uri.port !== -1 || transform.port) {
269       // If the URI contains a port or transform.port was specified, the default
270       // port is significant. So we must set it in that case.
271       if (transform.scheme === "https") {
272         mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(443);
273       } else if (transform.scheme === "http") {
274         mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(80);
275       }
276     }
277   }
278   if (transform.username != null) {
279     mut.setUsername(transform.username);
280   }
281   if (transform.password != null) {
282     mut.setPassword(transform.password);
283   }
284   if (transform.host != null) {
285     mut.setHost(transform.host);
286   }
287   if (transform.port != null) {
288     // The caller ensures that transform.port is a string consisting of digits
289     // only. When it is an empty string, it should be cleared (-1).
290     mut.setPort(transform.port || -1);
291   }
292   if (transform.path != null) {
293     mut.setFilePath(transform.path);
294   }
295   if (transform.query != null) {
296     mut.setQuery(transform.query);
297   } else if (transform.queryTransform) {
298     mut.setQuery(applyQueryTransform(uri.query, transform.queryTransform));
299   }
300   if (transform.fragment != null) {
301     mut.setRef(transform.fragment);
302   }
303   return mut.finalize();
307  * @param {nsIURI} uri - Usually a http(s) URL.
308  * @param {MatchedRule} matchedRule - The matched rule with a regexFilter
309  *   condition and regexSubstitution action.
310  * @returns {nsIURI} The new URL derived from the regexSubstitution combined
311  *   with capturing group from regexFilter applied to the input uri.
312  * @throws if the resulting URL is an invalid redirect target.
313  */
314 function applyRegexSubstitution(uri, matchedRule) {
315   const rule = matchedRule.rule;
316   const extension = matchedRule.ruleManager.extension;
317   const regexSubstitution = rule.action.redirect.regexSubstitution;
318   const compiledRegexFilter = rule.condition.getCompiledRegexFilter();
319   // This method being called implies that regexFilter matched, so |matches| is
320   // always non-null, i.e. an array of string/undefined values.
321   const matches = compiledRegexFilter.exec(uri.spec);
323   let redirectUrl = regexSubstitution.replace(/\\(.)/g, (_, char) => {
324     // #checkActionRedirect ensures that every \ is followed by a \ or digit.
325     return char === "\\" ? char : matches[char] ?? "";
326   });
328   // Throws if the URL is invalid:
329   let redirectUri;
330   try {
331     redirectUri = Services.io.newURI(redirectUrl);
332   } catch (e) {
333     throw new Error(
334       `Extension ${extension.id} tried to redirect to an invalid URL: ${redirectUrl}`
335     );
336   }
337   if (!extension.checkLoadURI(redirectUri, { dontReportErrors: true })) {
338     throw new Error(
339       `Extension ${extension.id} may not redirect to: ${redirectUrl}`
340     );
341   }
342   return redirectUri;
346  * An urlFilter is a string pattern to match a canonical http(s) URL.
347  * urlFilter matches anywhere in the string, unless an anchor is present:
348  * - ||... ("Domain name anchor") - domain or subdomain starts with ...
349  * - |... ("Left anchor") - URL starts with ...
350  * - ...| ("Right anchor") - URL ends with ...
352  * Other than the anchors, the following special characters exist:
353  * - ^ = end of URL, or any char except: alphanum _ - . % ("Separator")
354  * - * = any number of characters ("Wildcard")
356  * Ambiguous cases (undocumented but actual Chrome behavior):
357  * - Plain "||" is a domain name anchor, not left + empty + right anchor.
358  * - "^" repeated at end of pattern: "^" matches end of URL only once.
359  * - "^|" at end of pattern: "^" is allowed to match end of URL.
361  * Implementation details:
362  * - CompiledUrlFilter's constructor (+#initializeUrlFilter) extracts the
363  *   actual urlFilter and anchors, for matching against URLs later.
364  * - RequestDataForUrlFilter class precomputes the URL / domain anchors to
365  *   support matching more efficiently.
366  * - CompiledUrlFilter's matchesRequest(request) checks whether the request is
367  *   actually matched, using the precomputed information.
369  * The class was designed to minimize the number of string allocations during
370  * request evaluation, because the matchesRequest method may be called very
371  * often for every network request.
372  */
373 class CompiledUrlFilter {
374   #isUrlFilterCaseSensitive;
375   #urlFilterParts; // = parts of urlFilter, minus anchors, split at "*".
376   // isAnchorLeft and isAnchorDomain are mutually exclusive.
377   #isAnchorLeft = false;
378   #isAnchorDomain = false;
379   #isAnchorRight = false;
380   #isTrailingSeparator = false; // Whether urlFilter ends with "^".
382   /**
383    * @param {string} urlFilter - non-empty urlFilter
384    * @param {boolean} [isUrlFilterCaseSensitive]
385    */
386   constructor(urlFilter, isUrlFilterCaseSensitive) {
387     this.#isUrlFilterCaseSensitive = isUrlFilterCaseSensitive;
388     this.#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive);
389   }
391   #initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive) {
392     let start = 0;
393     let end = urlFilter.length;
395     // First, trim the anchors off urlFilter.
396     if (urlFilter[0] === "|") {
397       if (urlFilter[1] === "|") {
398         start = 2;
399         this.#isAnchorDomain = true;
400         // ^ will not revert to false below, because "||*" is already rejected
401         // by RuleValidator's #checkCondUrlFilterAndRegexFilter method.
402       } else {
403         start = 1;
404         this.#isAnchorLeft = true; // may revert to false below.
405       }
406     }
407     if (end > start && urlFilter[end - 1] === "|") {
408       --end;
409       this.#isAnchorRight = true; // may revert to false below.
410     }
412     // Skip unnecessary wildcards, and adjust meaningless anchors accordingly:
413     // "|*" and "*|" are not effective anchors, they could have been omitted.
414     while (start < end && urlFilter[start] === "*") {
415       ++start;
416       this.#isAnchorLeft = false;
417     }
418     while (end > start && urlFilter[end - 1] === "*") {
419       --end;
420       this.#isAnchorRight = false;
421     }
423     // Special-case the last "^", so that the matching algorithm can rely on
424     // the simple assumption that a "^" in the filter matches exactly one char:
425     // The "^" at the end of the pattern is specified to match either one char
426     // as usual, or as an anchor for the end of the URL (i.e. zero characters).
427     this.#isTrailingSeparator = urlFilter[end - 1] === "^";
429     let urlFilterWithoutAnchors = urlFilter.slice(start, end);
430     if (!isUrlFilterCaseSensitive) {
431       urlFilterWithoutAnchors = urlFilterWithoutAnchors.toLowerCase();
432     }
433     this.#urlFilterParts = urlFilterWithoutAnchors.split("*");
434   }
436   /**
437    * Tests whether |request| matches the urlFilter.
438    *
439    * @param {RequestDataForUrlFilter} requestDataForUrlFilter
440    * @returns {boolean} Whether the condition matches the URL.
441    */
442   matchesRequest(requestDataForUrlFilter) {
443     const url = requestDataForUrlFilter.getUrl(this.#isUrlFilterCaseSensitive);
444     const domainAnchors = requestDataForUrlFilter.domainAnchors;
446     const urlFilterParts = this.#urlFilterParts;
448     const REAL_END_OF_URL = url.length - 1; // minus trailing "^"
450     // atUrlIndex is the position after the most recently matched part.
451     // If a match is not found, it is -1 and we should return false.
452     let atUrlIndex = 0;
454     // The head always exists, potentially even an empty string.
455     const head = urlFilterParts[0];
456     if (this.#isAnchorLeft) {
457       if (!this.#startsWithPart(head, url, 0)) {
458         return false;
459       }
460       atUrlIndex = head.length;
461     } else if (this.#isAnchorDomain) {
462       atUrlIndex = this.#indexAfterDomainPart(head, url, domainAnchors);
463     } else {
464       atUrlIndex = this.#indexAfterPart(head, url, 0);
465     }
467     let previouslyAtUrlIndex = 0;
468     for (let i = 1; i < urlFilterParts.length && atUrlIndex !== -1; ++i) {
469       previouslyAtUrlIndex = atUrlIndex;
470       atUrlIndex = this.#indexAfterPart(urlFilterParts[i], url, atUrlIndex);
471     }
472     if (atUrlIndex === -1) {
473       return false;
474     }
475     if (atUrlIndex === url.length) {
476       // We always append a "^" to the URL, so if the match is at the end of the
477       // URL (REAL_END_OF_URL), only accept if the pattern ended with a "^".
478       return this.#isTrailingSeparator;
479     }
480     if (!this.#isAnchorRight || atUrlIndex === REAL_END_OF_URL) {
481       // Either not interested in the end, or already at the end of the URL.
482       return true;
483     }
485     // #isAnchorRight is true but we are not at the end of the URL.
486     // Backtrack once, to retry the last pattern (tail) with the end of the URL.
488     const tail = urlFilterParts[urlFilterParts.length - 1];
489     // The expected offset where the tail should be located.
490     const expectedTailIndex = REAL_END_OF_URL - tail.length;
491     // If #isTrailingSeparator is true, then accept the URL's trailing "^".
492     const expectedTailIndexPlus1 = expectedTailIndex + 1;
493     if (urlFilterParts.length === 1) {
494       if (this.#isAnchorLeft) {
495         // If matched, we would have returned at the REAL_END_OF_URL checks.
496         return false;
497       }
498       if (this.#isAnchorDomain) {
499         // The tail must be exactly at one of the domain anchors.
500         return (
501           (domainAnchors.includes(expectedTailIndex) &&
502             this.#startsWithPart(tail, url, expectedTailIndex)) ||
503           (this.#isTrailingSeparator &&
504             domainAnchors.includes(expectedTailIndexPlus1) &&
505             this.#startsWithPart(tail, url, expectedTailIndexPlus1))
506         );
507       }
508       // head has no left/domain anchor, fall through.
509     }
510     // The tail is not left/domain anchored, accept it as long as it did not
511     // overlap with an already-matched part of the URL.
512     return (
513       (expectedTailIndex > previouslyAtUrlIndex &&
514         this.#startsWithPart(tail, url, expectedTailIndex)) ||
515       (this.#isTrailingSeparator &&
516         expectedTailIndexPlus1 > previouslyAtUrlIndex &&
517         this.#startsWithPart(tail, url, expectedTailIndexPlus1))
518     );
519   }
521   // Whether a character should match "^" in an urlFilter.
522   // The "match end of URL" meaning of "^" is covered by #isTrailingSeparator.
523   static #regexIsSep = /[^A-Za-z0-9_\-.%]/;
525   #matchPartAt(part, url, urlIndex, sepStart) {
526     if (sepStart === -1) {
527       // Fast path.
528       return url.startsWith(part, urlIndex);
529     }
530     if (urlIndex + part.length > url.length) {
531       return false;
532     }
533     for (let i = 0; i < part.length; ++i) {
534       let partChar = part[i];
535       let urlChar = url[urlIndex + i];
536       if (
537         partChar !== urlChar &&
538         (partChar !== "^" || !CompiledUrlFilter.#regexIsSep.test(urlChar))
539       ) {
540         return false;
541       }
542     }
543     return true;
544   }
546   #startsWithPart(part, url, urlIndex) {
547     const sepStart = part.indexOf("^");
548     return this.#matchPartAt(part, url, urlIndex, sepStart);
549   }
551   #indexAfterPart(part, url, urlIndex) {
552     let sepStart = part.indexOf("^");
553     if (sepStart === -1) {
554       // Fast path.
555       let i = url.indexOf(part, urlIndex);
556       return i === -1 ? i : i + part.length;
557     }
558     let maxUrlIndex = url.length - part.length;
559     for (let i = urlIndex; i <= maxUrlIndex; ++i) {
560       if (this.#matchPartAt(part, url, i, sepStart)) {
561         return i + part.length;
562       }
563     }
564     return -1;
565   }
567   #indexAfterDomainPart(part, url, domainAnchors) {
568     const sepStart = part.indexOf("^");
569     for (let offset of domainAnchors) {
570       if (this.#matchPartAt(part, url, offset, sepStart)) {
571         return offset + part.length;
572       }
573     }
574     return -1;
575   }
578 // See CompiledUrlFilter for documentation of RequestDataForUrlFilter.
579 class RequestDataForUrlFilter {
580   /**
581    * @param {string} requestURIspec - The URL to match against.
582    */
583   constructor(requestURIspec) {
584     // "^" is appended, see CompiledUrlFilter's #initializeUrlFilter.
585     this.urlAnyCase = requestURIspec + "^";
586     this.urlLowerCase = this.urlAnyCase.toLowerCase();
587     // For "||..." (Domain name anchor): where (sub)domains start in the URL.
588     this.domainAnchors = this.#getDomainAnchors(this.urlAnyCase);
589   }
591   getUrl(isUrlFilterCaseSensitive) {
592     return isUrlFilterCaseSensitive ? this.urlAnyCase : this.urlLowerCase;
593   }
595   #getDomainAnchors(url) {
596     let hostStart = url.indexOf("://") + 3;
597     let hostEnd = url.indexOf("/", hostStart);
598     let userpassEnd = url.lastIndexOf("@", hostEnd) + 1;
599     if (userpassEnd) {
600       hostStart = userpassEnd;
601     }
602     let host = url.slice(hostStart, hostEnd);
603     let domainAnchors = [hostStart];
604     let offset = 0;
605     // Find all offsets after ".". If not found, -1 + 1 = 0, and the loop ends.
606     while ((offset = host.indexOf(".", offset) + 1)) {
607       domainAnchors.push(hostStart + offset);
608     }
609     return domainAnchors;
610   }
613 function compileRegexFilter(regexFilter, isUrlFilterCaseSensitive) {
614   // TODO bug 1821033: Restrict supported regex to avoid perf issues. For
615   // discussion on the desired syntax, see
616   // https://github.com/w3c/webextensions/issues/344
617   return new RegExp(regexFilter, isUrlFilterCaseSensitive ? "" : "i");
620 class ModifyHeadersBase {
621   // Map<string,MatchedRule> - The first MatchedRule that modified the header.
622   // After modifying a header, it cannot be modified further, with the exception
623   // of the "append" operation, provided that they are from the same extension.
624   #alreadyModifiedMap = new Map();
625   // Set<string> - The list of headers allowed to be modified with "append",
626   // despite having been modified. Allowed for "set"/"append", not for "remove".
627   #appendStillAllowed = new Set();
629   /**
630    * @param {ChannelWrapper} channel
631    */
632   constructor(channel) {
633     this.channel = channel;
634   }
636   /**
637    * @param {MatchedRule} matchedRule
638    * @returns {object[]}
639    */
640   headerActionsFor(matchedRule) {
641     throw new Error("Not implemented.");
642   }
644   /**
645    * @param {MatchedRule} matchedrule
646    * @param {string} name
647    * @param {string} value
648    * @param {boolean} merge
649    */
650   setHeaderImpl(matchedrule, name, value, merge) {
651     throw new Error("Not implemented.");
652   }
654   /** @param {MatchedRule[]} matchedRules */
655   applyModifyHeaders(matchedRules) {
656     for (const matchedRule of matchedRules) {
657       for (const headerAction of this.headerActionsFor(matchedRule)) {
658         const { header: name, operation, value } = headerAction;
659         if (!this.#isOperationAllowed(name, operation, matchedRule)) {
660           continue;
661         }
662         let ok;
663         switch (operation) {
664           case "set":
665             ok = this.setHeader(matchedRule, name, value, /* merge */ false);
666             if (ok) {
667               this.#appendStillAllowed.add(name);
668             }
669             break;
670           case "append":
671             ok = this.setHeader(matchedRule, name, value, /* merge */ true);
672             if (ok) {
673               this.#appendStillAllowed.add(name);
674             }
675             break;
676           case "remove":
677             ok = this.setHeader(matchedRule, name, "", /* merge */ false);
678             // Note: removal is final, so we don't add to #appendStillAllowed.
679             break;
680         }
681         if (ok) {
682           this.#alreadyModifiedMap.set(name, matchedRule);
683         }
684       }
685     }
686   }
688   #isOperationAllowed(name, operation, matchedRule) {
689     const modifiedBy = this.#alreadyModifiedMap.get(name);
690     if (!modifiedBy) {
691       return true;
692     }
693     if (
694       operation === "append" &&
695       this.#appendStillAllowed.has(name) &&
696       matchedRule.ruleManager === modifiedBy.ruleManager
697     ) {
698       return true;
699     }
700     // TODO bug 1803369: dev experience improvement: consider logging when
701     // a header modification was rejected.
702     return false;
703   }
705   setHeader(matchedRule, name, value, merge) {
706     try {
707       this.setHeaderImpl(matchedRule, name, value, merge);
708       return true;
709     } catch (e) {
710       const extension = matchedRule.ruleManager.extension;
711       extension.logger.error(
712         `Failed to apply modifyHeaders action to header "${name}" (DNR rule id ${matchedRule.rule.id} from ruleset "${matchedRule.ruleset.id}"): ${e}`
713       );
714     }
715     return false;
716   }
718   // kName should already be in lower case.
719   isHeaderNameEqual(name, kName) {
720     return name.length === kName.length && name.toLowerCase() === kName;
721   }
724 class ModifyRequestHeaders extends ModifyHeadersBase {
725   static maybeApplyModifyHeaders(channel, matchedRules) {
726     matchedRules = matchedRules.filter(mr => {
727       const action = mr.rule.action;
728       return action.type === "modifyHeaders" && action.requestHeaders?.length;
729     });
730     if (matchedRules.length) {
731       new ModifyRequestHeaders(channel).applyModifyHeaders(matchedRules);
732     }
733   }
735   /** @param {MatchedRule} matchedRule */
736   headerActionsFor(matchedRule) {
737     return matchedRule.rule.action.requestHeaders;
738   }
740   setHeaderImpl(matchedRule, name, value, merge) {
741     if (this.isHeaderNameEqual(name, "host")) {
742       this.#checkHostHeader(matchedRule, value);
743     }
744     if (merge && value && this.isHeaderNameEqual(name, "cookie")) {
745       // By default, headers are merged with ",". But Cookie should use "; ".
746       // HTTP/1.1 allowed only one Cookie header, but HTTP/2.0 allows multiple,
747       // but recommends concatenation on one line. Relevant RFCs:
748       // - https://www.rfc-editor.org/rfc/rfc6265#section-5.4
749       // - https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5
750       // Consistent with Firefox internals, we ensure that there is at most one
751       // Cookie header, by overwriting the previous one, if any.
752       let existingCookie = this.channel.getRequestHeader("cookie");
753       if (existingCookie) {
754         value = existingCookie + "; " + value;
755         merge = false;
756       }
757     }
758     this.channel.setRequestHeader(name, value, merge);
759   }
761   #checkHostHeader(matchedRule, value) {
762     let uri = Services.io.newURI(`https://${value}/`);
763     let { policy } = matchedRule.ruleManager.extension;
765     if (!policy.allowedOrigins.matches(uri)) {
766       throw new Error(
767         `Unable to set host header, url missing from permissions.`
768       );
769     }
771     if (WebExtensionPolicy.isRestrictedURI(uri)) {
772       throw new Error(`Unable to set host header to restricted url.`);
773     }
774   }
777 class ModifyResponseHeaders extends ModifyHeadersBase {
778   static maybeApplyModifyHeaders(channel, matchedRules) {
779     matchedRules = matchedRules.filter(mr => {
780       const action = mr.rule.action;
781       return action.type === "modifyHeaders" && action.responseHeaders?.length;
782     });
783     if (matchedRules.length) {
784       new ModifyResponseHeaders(channel).applyModifyHeaders(matchedRules);
785     }
786   }
788   headerActionsFor(matchedRule) {
789     return matchedRule.rule.action.responseHeaders;
790   }
792   setHeaderImpl(matchedRule, name, value, merge) {
793     this.channel.setResponseHeader(name, value, merge);
794   }
797 class RuleValidator {
798   constructor(alreadyValidatedRules, { isSessionRuleset = false } = {}) {
799     this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r]));
800     this.failures = [];
801     this.isSessionRuleset = isSessionRuleset;
802   }
804   /**
805    * Static method used to deserialize Rule class instances from a plain
806    * js object rule as serialized implicitly by aomStartup.encodeBlob
807    * when we store the rules into the startup cache file.
808    *
809    * @param {object} rule
810    * @returns {Rule}
811    */
812   static deserializeRule(rule) {
813     const newRule = new Rule(rule);
814     if (newRule.condition.regexFilter) {
815       newRule.condition.setCompiledRegexFilter(
816         compileRegexFilter(
817           newRule.condition.regexFilter,
818           newRule.condition.isUrlFilterCaseSensitive
819         )
820       );
821     }
822     return newRule;
823   }
825   removeRuleIds(ruleIds) {
826     for (const ruleId of ruleIds) {
827       this.rulesMap.delete(ruleId);
828     }
829   }
831   /**
832    * @param {object[]} rules - A list of objects that adhere to the Rule type
833    *    from declarative_net_request.json.
834    */
835   addRules(rules) {
836     for (const rule of rules) {
837       if (this.rulesMap.has(rule.id)) {
838         this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`);
839         continue;
840       }
841       // declarative_net_request.json defines basic types, such as the expected
842       // object properties and (primitive) type. Trivial constraints such as
843       // minimum array lengths are also expressed in the schema.
844       // Anything more complex is validated here. In particular, constraints
845       // involving multiple properties (e.g. mutual exclusiveness).
846       //
847       // The following conditions have already been validated by the schema:
848       // - isUrlFilterCaseSensitive (boolean)
849       // - domainType (enum string)
850       // - initiatorDomains & excludedInitiatorDomains & requestDomains &
851       //   excludedRequestDomains (array of string in canonicalDomain format)
852       if (
853         !this.#checkCondResourceTypes(rule) ||
854         !this.#checkCondRequestMethods(rule) ||
855         !this.#checkCondTabIds(rule) ||
856         !this.#checkCondUrlFilterAndRegexFilter(rule) ||
857         !this.#checkAction(rule)
858       ) {
859         continue;
860       }
862       const newRule = new Rule(rule);
863       // #lastCompiledRegexFilter is set if regexFilter is set, and null
864       // otherwise by the above call to #checkCondUrlFilterAndRegexFilter().
865       if (this.#lastCompiledRegexFilter) {
866         newRule.condition.setCompiledRegexFilter(this.#lastCompiledRegexFilter);
867       }
869       this.rulesMap.set(rule.id, newRule);
870     }
871   }
873   // #checkCondUrlFilterAndRegexFilter() compiles the regexFilter to check its
874   // validity. To avoid having to compile it again when the Rule (RuleCondition)
875   // is constructed, we temporarily cache the result.
876   #lastCompiledRegexFilter;
878   // Checks: resourceTypes & excludedResourceTypes
879   #checkCondResourceTypes(rule) {
880     const { resourceTypes, excludedResourceTypes } = rule.condition;
881     if (this.#hasOverlap(resourceTypes, excludedResourceTypes)) {
882       this.#collectInvalidRule(
883         rule,
884         "resourceTypes and excludedResourceTypes should not overlap"
885       );
886       return false;
887     }
888     if (rule.action.type === "allowAllRequests") {
889       if (!resourceTypes) {
890         this.#collectInvalidRule(
891           rule,
892           "An allowAllRequests rule must have a non-empty resourceTypes array"
893         );
894         return false;
895       }
896       if (resourceTypes.some(r => r !== "main_frame" && r !== "sub_frame")) {
897         this.#collectInvalidRule(
898           rule,
899           "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes"
900         );
901         return false;
902       }
903     }
904     return true;
905   }
907   // Checks: requestMethods & excludedRequestMethods
908   #checkCondRequestMethods(rule) {
909     const { requestMethods, excludedRequestMethods } = rule.condition;
910     if (this.#hasOverlap(requestMethods, excludedRequestMethods)) {
911       this.#collectInvalidRule(
912         rule,
913         "requestMethods and excludedRequestMethods should not overlap"
914       );
915       return false;
916     }
917     const isInvalidRequestMethod = method => method.toLowerCase() !== method;
918     if (
919       requestMethods?.some(isInvalidRequestMethod) ||
920       excludedRequestMethods?.some(isInvalidRequestMethod)
921     ) {
922       this.#collectInvalidRule(rule, "request methods must be in lower case");
923       return false;
924     }
925     return true;
926   }
928   // Checks: tabIds & excludedTabIds
929   #checkCondTabIds(rule) {
930     const { tabIds, excludedTabIds } = rule.condition;
932     if ((tabIds || excludedTabIds) && !this.isSessionRuleset) {
933       this.#collectInvalidRule(
934         rule,
935         "tabIds and excludedTabIds can only be specified in session rules"
936       );
937       return false;
938     }
940     if (this.#hasOverlap(tabIds, excludedTabIds)) {
941       this.#collectInvalidRule(
942         rule,
943         "tabIds and excludedTabIds should not overlap"
944       );
945       return false;
946     }
947     return true;
948   }
950   static #regexNonASCII = /[^\x00-\x7F]/; // eslint-disable-line no-control-regex
951   static #regexDigitOrBackslash = /^[0-9\\]$/;
953   // Checks: urlFilter & regexFilter
954   #checkCondUrlFilterAndRegexFilter(rule) {
955     const { urlFilter, regexFilter } = rule.condition;
957     this.#lastCompiledRegexFilter = null;
959     const checkEmptyOrNonASCII = (str, prop) => {
960       if (!str) {
961         this.#collectInvalidRule(rule, `${prop} should not be an empty string`);
962         return false;
963       }
964       // Non-ASCII in URLs are always encoded in % (or punycode in domains).
965       if (RuleValidator.#regexNonASCII.test(str)) {
966         this.#collectInvalidRule(
967           rule,
968           `${prop} should not contain non-ASCII characters`
969         );
970         return false;
971       }
972       return true;
973     };
974     if (urlFilter != null) {
975       if (regexFilter != null) {
976         this.#collectInvalidRule(
977           rule,
978           "urlFilter and regexFilter are mutually exclusive"
979         );
980         return false;
981       }
982       if (!checkEmptyOrNonASCII(urlFilter, "urlFilter")) {
983         // #collectInvalidRule already called by checkEmptyOrNonASCII.
984         return false;
985       }
986       if (urlFilter.startsWith("||*")) {
987         // Rejected because Chrome does too. '||*' is equivalent to '*'.
988         this.#collectInvalidRule(rule, "urlFilter should not start with '||*'");
989         return false;
990       }
991     } else if (regexFilter != null) {
992       if (!checkEmptyOrNonASCII(regexFilter, "regexFilter")) {
993         // #collectInvalidRule already called by checkEmptyOrNonASCII.
994         return false;
995       }
996       try {
997         this.#lastCompiledRegexFilter = compileRegexFilter(
998           regexFilter,
999           rule.condition.isUrlFilterCaseSensitive
1000         );
1001       } catch (e) {
1002         this.#collectInvalidRule(
1003           rule,
1004           "regexFilter is not a valid regular expression"
1005         );
1006         return false;
1007       }
1008     }
1009     return true;
1010   }
1012   #checkAction(rule) {
1013     switch (rule.action.type) {
1014       case "allow":
1015       case "allowAllRequests":
1016       case "block":
1017       case "upgradeScheme":
1018         // These actions have no extra properties.
1019         break;
1020       case "redirect":
1021         return this.#checkActionRedirect(rule);
1022       case "modifyHeaders":
1023         return this.#checkActionModifyHeaders(rule);
1024       default:
1025         // Other values are not possible because declarative_net_request.json
1026         // only accepts the above action types.
1027         throw new Error(`Unexpected action type: ${rule.action.type}`);
1028     }
1029     return true;
1030   }
1032   #checkActionRedirect(rule) {
1033     const { url, extensionPath, transform, regexSubstitution } =
1034       rule.action.redirect ?? {};
1035     const hasExtensionPath = extensionPath != null;
1036     const hasRegexSubstitution = regexSubstitution != null;
1037     const redirectKeyCount =
1038       !!url + !!hasExtensionPath + !!transform + !!hasRegexSubstitution;
1039     if (redirectKeyCount !== 1) {
1040       if (redirectKeyCount === 0) {
1041         this.#collectInvalidRule(
1042           rule,
1043           "A redirect rule must have a non-empty action.redirect object"
1044         );
1045         return false;
1046       }
1047       // Side note: Chrome silently ignores excess keys, and skips validation
1048       // for ignored keys, in this order:
1049       // - url > extensionPath > transform > regexSubstitution
1050       this.#collectInvalidRule(
1051         rule,
1052         "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
1053       );
1054       return false;
1055     }
1057     if (hasExtensionPath && !extensionPath.startsWith("/")) {
1058       this.#collectInvalidRule(
1059         rule,
1060         "redirect.extensionPath should start with a '/'"
1061       );
1062       return false;
1063     }
1065     // If specified, the "url" property is described as "format": "url" in the
1066     // JSON schema, which ensures that the URL is a canonical form, and that
1067     // the extension is allowed to trigger a navigation to the URL.
1068     // E.g. javascript: and privileged about:-URLs cannot be navigated to, but
1069     // http(s) URLs can (regardless of extension permissions).
1070     // data:-URLs are currently blocked due to bug 1622986.
1072     if (transform) {
1073       if (transform.query != null && transform.queryTransform) {
1074         this.#collectInvalidRule(
1075           rule,
1076           "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive"
1077         );
1078         return false;
1079       }
1080       // Most of the validation is done by nsIURIMutator via applyURLTransform.
1081       // nsIURIMutator is not very strict, so we perform some extra checks here
1082       // to reject values that are not technically valid URLs.
1084       if (transform.port && /\D/.test(transform.port)) {
1085         // nsIURIMutator's setPort takes an int, so any string will implicitly
1086         // be converted to a number. This part verifies that the input only
1087         // consists of digits. setPort will ensure that it is at most 65535.
1088         this.#collectInvalidRule(
1089           rule,
1090           "redirect.transform.port should be empty or an integer"
1091         );
1092         return false;
1093       }
1095       // Note: we don't verify whether transform.query starts with '/', because
1096       // Chrome does not require it, and nsIURIMutator prepends it if missing.
1098       if (transform.query && !transform.query.startsWith("?")) {
1099         this.#collectInvalidRule(
1100           rule,
1101           "redirect.transform.query should be empty or start with a '?'"
1102         );
1103         return false;
1104       }
1105       if (transform.fragment && !transform.fragment.startsWith("#")) {
1106         this.#collectInvalidRule(
1107           rule,
1108           "redirect.transform.fragment should be empty or start with a '#'"
1109         );
1110         return false;
1111       }
1112       try {
1113         const dummyURI = Services.io.newURI("http://dummy");
1114         // applyURLTransform uses nsIURIMutator to transform a URI, and throws
1115         // if |transform| is invalid, e.g. invalid host, port, etc.
1116         applyURLTransform(dummyURI, transform);
1117       } catch (e) {
1118         this.#collectInvalidRule(
1119           rule,
1120           "redirect.transform does not describe a valid URL transformation"
1121         );
1122         return false;
1123       }
1124     }
1126     if (hasRegexSubstitution) {
1127       if (!rule.condition.regexFilter) {
1128         this.#collectInvalidRule(
1129           rule,
1130           "redirect.regexSubstitution requires the regexFilter condition to be specified"
1131         );
1132         return false;
1133       }
1134       let i = 0;
1135       // i will be index after \. Loop breaks if not found (-1+1=0 = false).
1136       while ((i = regexSubstitution.indexOf("\\", i) + 1)) {
1137         let c = regexSubstitution[i++]; // may be undefined if \ is at end.
1138         if (c === undefined || !RuleValidator.#regexDigitOrBackslash.test(c)) {
1139           this.#collectInvalidRule(
1140             rule,
1141             "redirect.regexSubstitution only allows digit or \\ after \\."
1142           );
1143           return false;
1144         }
1145       }
1146     }
1148     return true;
1149   }
1151   #checkActionModifyHeaders(rule) {
1152     const { requestHeaders, responseHeaders } = rule.action;
1153     if (!requestHeaders && !responseHeaders) {
1154       this.#collectInvalidRule(
1155         rule,
1156         "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list"
1157       );
1158       return false;
1159     }
1161     const isValidModifyHeadersOp = ({ header, operation, value }) => {
1162       if (!header) {
1163         this.#collectInvalidRule(rule, "header must be non-empty");
1164         return false;
1165       }
1166       if (!value && (operation === "append" || operation === "set")) {
1167         this.#collectInvalidRule(
1168           rule,
1169           "value is required for operations append/set"
1170         );
1171         return false;
1172       }
1173       if (value && operation === "remove") {
1174         this.#collectInvalidRule(
1175           rule,
1176           "value must not be provided for operation remove"
1177         );
1178         return false;
1179       }
1180       return true;
1181     };
1182     if (
1183       (requestHeaders && !requestHeaders.every(isValidModifyHeadersOp)) ||
1184       (responseHeaders && !responseHeaders.every(isValidModifyHeadersOp))
1185     ) {
1186       // #collectInvalidRule already called by isValidModifyHeadersOp.
1187       return false;
1188     }
1189     return true;
1190   }
1192   // Conditions with a filter and an exclude-filter should reject overlapping
1193   // lists, because they can never simultaneously be true.
1194   #hasOverlap(arrayA, arrayB) {
1195     return arrayA && arrayB && arrayA.some(v => arrayB.includes(v));
1196   }
1198   #collectInvalidRule(rule, message) {
1199     this.failures.push({ rule, message });
1200   }
1202   getValidatedRules() {
1203     return Array.from(this.rulesMap.values());
1204   }
1206   getFailures() {
1207     return this.failures;
1208   }
1211 export class RuleQuotaCounter {
1212   constructor(isStaticRulesets) {
1213     this.isStaticRulesets = isStaticRulesets;
1214     this.ruleLimitName = isStaticRulesets
1215       ? "GUARANTEED_MINIMUM_STATIC_RULES"
1216       : "MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES";
1217     this.ruleLimitRemaining = lazy.ExtensionDNRLimits[this.ruleLimitName];
1218     this.regexRemaining = lazy.ExtensionDNRLimits.MAX_NUMBER_OF_REGEX_RULES;
1219   }
1221   tryAddRules(rulesetId, rules) {
1222     if (rules.length > this.ruleLimitRemaining) {
1223       this.#throwQuotaError(rulesetId, "rules", this.ruleLimitName);
1224     }
1225     let regexCount = 0;
1226     for (let rule of rules) {
1227       if (rule.condition.regexFilter && ++regexCount > this.regexRemaining) {
1228         this.#throwQuotaError(
1229           rulesetId,
1230           "regexFilter rules",
1231           "MAX_NUMBER_OF_REGEX_RULES"
1232         );
1233       }
1234     }
1236     // Update counters only when there are no quota errors.
1237     this.ruleLimitRemaining -= rules.length;
1238     this.regexRemaining -= regexCount;
1239   }
1241   #throwQuotaError(rulesetId, what, limitName) {
1242     if (this.isStaticRulesets) {
1243       throw new ExtensionError(
1244         `Number of ${what} across all enabled static rulesets exceeds ${limitName} if ruleset "${rulesetId}" were to be enabled.`
1245       );
1246     }
1247     throw new ExtensionError(
1248       `Number of ${what} in ruleset "${rulesetId}" exceeds ${limitName}.`
1249     );
1250   }
1254  * Compares two rules to determine the relative order of precedence.
1255  * Rules are only comparable if they are from the same extension!
1257  * @param {Rule} ruleA
1258  * @param {Rule} ruleB
1259  * @param {Ruleset} rulesetA - the ruleset ruleA is part of.
1260  * @param {Ruleset} rulesetB - the ruleset ruleB is part of.
1261  * @returns {integer}
1262  *   0 if equal.
1263  *   <0 if ruleA comes before ruleB.
1264  *   >0 if ruleA comes after ruleB.
1265  */
1266 function compareRule(ruleA, ruleB, rulesetA, rulesetB) {
1267   // Comparators: 0 if equal, >0 if a after b, <0 if a before b.
1268   function cmpHighestNumber(a, b) {
1269     return a === b ? 0 : b - a;
1270   }
1271   function cmpLowestNumber(a, b) {
1272     return a === b ? 0 : a - b;
1273   }
1274   return (
1275     // All compared operands are non-negative integers.
1276     cmpHighestNumber(ruleA.priority, ruleB.priority) ||
1277     cmpLowestNumber(ruleA.actionPrecedence(), ruleB.actionPrecedence()) ||
1278     // As noted in the big comment at the top of the file, the following two
1279     // comparisons only exist in order to have a stable ordering of rules. The
1280     // specific comparison is somewhat arbitrary and matches Chrome's behavior.
1281     // For context, see https://github.com/w3c/webextensions/issues/280
1282     cmpLowestNumber(rulesetA.rulesetPrecedence, rulesetB.rulesetPrecedence) ||
1283     cmpLowestNumber(ruleA.id, ruleB.id)
1284   );
1287 class MatchedRule {
1288   /**
1289    * @param {Rule} rule
1290    * @param {Ruleset} ruleset
1291    */
1292   constructor(rule, ruleset) {
1293     this.rule = rule;
1294     this.ruleset = ruleset;
1295   }
1297   // The RuleManager that generated this MatchedRule.
1298   get ruleManager() {
1299     return this.ruleset.ruleManager;
1300   }
1303 // tabId computation is currently not free, and depends on the initialization of
1304 // ExtensionParent.apiManager.global (see WebRequest.getTabIdForChannelWrapper).
1305 // Fortunately, DNR only supports tabIds in session rules, so by keeping track
1306 // of session rules with tabIds/excludedTabIds conditions, we can find tabId
1307 // exactly and only when necessary.
1308 let gHasAnyTabIdConditions = false;
1310 class RequestDetails {
1311   /**
1312    * @param {object} options
1313    * @param {nsIURI} options.requestURI - URL of the requested resource.
1314    * @param {nsIURI} [options.initiatorURI] - URL of triggering principal,
1315    *   provided that it is a content principal. Otherwise null.
1316    * @param {string} options.type - ResourceType (MozContentPolicyType).
1317    * @param {string} [options.method] - HTTP method
1318    * @param {integer} [options.tabId]
1319    * @param {BrowsingContext} [options.browsingContext] - The BrowsingContext
1320    *   associated with the request. Typically the bc for which the subresource
1321    *   request is initiated, if any. For document requests, this is the parent
1322    *   (i.e. the parent frame for sub_frame, null for main_frame).
1323    */
1324   constructor({
1325     requestURI,
1326     initiatorURI,
1327     type,
1328     method,
1329     tabId,
1330     browsingContext,
1331   }) {
1332     this.requestURI = requestURI;
1333     this.initiatorURI = initiatorURI;
1334     this.type = type;
1335     this.method = method;
1336     this.tabId = tabId;
1337     this.browsingContext = browsingContext;
1339     this.requestDomain = this.#domainFromURI(requestURI);
1340     this.initiatorDomain = initiatorURI
1341       ? this.#domainFromURI(initiatorURI)
1342       : null;
1344     this.requestURIspec = requestURI.spec;
1345     this.requestDataForUrlFilter = new RequestDataForUrlFilter(
1346       this.requestURIspec
1347     );
1348   }
1350   static fromChannelWrapper(channel) {
1351     let tabId = -1;
1352     if (gHasAnyTabIdConditions) {
1353       tabId = lazy.WebRequest.getTabIdForChannelWrapper(channel);
1354     }
1355     return new RequestDetails({
1356       requestURI: channel.finalURI,
1357       // Note: originURI may be null, if missing or null principal, as desired.
1358       initiatorURI: channel.originURI,
1359       type: channel.type,
1360       method: channel.method.toLowerCase(),
1361       tabId,
1362       browsingContext: channel.loadInfo.browsingContext,
1363     });
1364   }
1366   #ancestorRequestDetails;
1367   get ancestorRequestDetails() {
1368     if (this.#ancestorRequestDetails) {
1369       return this.#ancestorRequestDetails;
1370     }
1371     this.#ancestorRequestDetails = [];
1372     if (!this.browsingContext?.ancestorsAreCurrent) {
1373       // this.browsingContext is set for real requests (via fromChannelWrapper).
1374       // It may be void for testMatchOutcome and for the ancestor requests
1375       // simulated below.
1376       //
1377       // ancestorsAreCurrent being false is unexpected, but could theoretically
1378       // happen if the request is triggered from an unloaded (sub)frame. In that
1379       // case we don't want to use potentially incorrect ancestor information.
1380       //
1381       // In any case, nothing left to do.
1382       return this.#ancestorRequestDetails;
1383     }
1384     // Reconstruct the frame hierarchy of the request's document, in order to
1385     // retroactively recompute the relevant matches of allowAllRequests rules.
1386     //
1387     // The allowAllRequests rule is supposedly applying to all subresource
1388     // requests. For non-document requests, this is usually the document if any.
1389     // In case of document requests, there is some ambiguity:
1390     // - Usually, the initiator is the parent document that created the frame.
1391     // - Sometimes, the initiator is a different frame or even another window.
1392     //
1393     // In RequestDetails.fromChannelWrapper, the actual initiator is used and
1394     // reflected in initiatorURI, but here we use the document's parent. This
1395     // is done because the chain of initiators is unstable (e.g. an opener can
1396     // navigate/unload), whereas frame ancestor chain is constant as long as
1397     // the leaf BrowsingContext is current. Moreover, allowAllRequests was
1398     // originally designed to operate on frame hierarchies (crbug.com/1038831).
1399     //
1400     // This implementation of "initiator" for "allowAllRequests" is consistent
1401     // with Chrome and Safari.
1402     for (let bc = this.browsingContext; bc; bc = bc.parent) {
1403       // Note: requestURI may differ from the document's initial requestURI,
1404       // e.g. due to same-document navigations.
1405       const requestURI = bc.currentURI;
1406       if (!requestURI.schemeIs("https") && !requestURI.schemeIs("http")) {
1407         // DNR is currently only hooked up to http(s) requests. Ignore other
1408         // URLs, e.g. about:, blob:, moz-extension:, data:, etc.
1409         continue;
1410       }
1411       const isTop = !bc.parent;
1412       const parentPrin = bc.parentWindowContext?.documentPrincipal;
1413       const requestDetails = new RequestDetails({
1414         requestURI,
1415         // Note: initiatorURI differs from RequestDetails.fromChannelWrapper;
1416         // See the above comment for more info.
1417         initiatorURI: parentPrin?.isContentPrincipal ? parentPrin.URI : null,
1418         type: isTop ? "main_frame" : "sub_frame",
1419         method: bc.activeSessionHistoryEntry?.hasPostData ? "post" : "get",
1420         tabId: this.tabId,
1421         // In this loop we are already explicitly accounting for ancestors, so
1422         // we intentionally omit browsingContext even though we have |bc|. If
1423         // we were to set `browsingContext: bc`, the output would be the same,
1424         // but be derived from unnecessarily repeated request evaluations.
1425         browsingContext: null,
1426       });
1427       this.#ancestorRequestDetails.unshift(requestDetails);
1428     }
1429     return this.#ancestorRequestDetails;
1430   }
1432   canExtensionModify(extension) {
1433     const policy = extension.policy;
1434     if (!policy.canAccessURI(this.requestURI)) {
1435       return false;
1436     }
1437     if (
1438       this.initiatorURI &&
1439       this.type !== "main_frame" &&
1440       this.type !== "sub_frame" &&
1441       !policy.canAccessURI(this.initiatorURI)
1442     ) {
1443       // Host permissions for the initiator is required except for navigation
1444       // requests: https://bugzilla.mozilla.org/show_bug.cgi?id=1825824#c2
1445       return false;
1446     }
1447     return true;
1448   }
1450   #domainFromURI(uri) {
1451     try {
1452       let hostname = uri.host;
1453       // nsIURI omits brackets from IPv6 addresses. But the canonical form of an
1454       // IPv6 address is with brackets, so add them.
1455       return hostname.includes(":") ? `[${hostname}]` : hostname;
1456     } catch (e) {
1457       // uri.host throws for some schemes (e.g. about:). In practice we won't
1458       // encounter this for network (via NetworkIntegration.startDNREvaluation)
1459       // because isRestrictedPrincipalURI filters the initiatorURI. Furthermore,
1460       // because only http(s) requests are observed, requestURI is http(s).
1461       //
1462       // declarativeNetRequest.testMatchOutcome can pass arbitrary URIs and thus
1463       // trigger the error in nsIURI::GetHost.
1464       Cu.reportError(e);
1465       return null;
1466     }
1467   }
1471  * This RequestEvaluator class's logic is documented at the top of this file.
1472  */
1473 class RequestEvaluator {
1474   // private constructor, only used by RequestEvaluator.evaluateRequest.
1475   constructor(request, ruleManager) {
1476     this.req = request;
1477     this.ruleManager = ruleManager;
1478     this.canModify = request.canExtensionModify(ruleManager.extension);
1480     // These values are initialized by findMatchingRules():
1481     this.matchedRule = null;
1482     this.matchedModifyHeadersRules = [];
1483     this.didCheckAncestors = false;
1484     this.findMatchingRules();
1485   }
1487   /**
1488    * Finds the matched rules for the given request and extensions,
1489    * according to the logic documented at the top of this file.
1490    *
1491    * @param {RequestDetails} request
1492    * @param {RuleManager[]} ruleManagers
1493    *    The list of RuleManagers, ordered by importance of its extension.
1494    * @returns {MatchedRule[]}
1495    */
1496   static evaluateRequest(request, ruleManagers) {
1497     // Helper to determine precedence of rules from different extensions.
1498     function precedence(matchedRule) {
1499       switch (matchedRule.rule.action.type) {
1500         case "block":
1501           return 1;
1502         case "redirect":
1503         case "upgradeScheme":
1504           return 2;
1505         case "allow":
1506         case "allowAllRequests":
1507           return 3;
1508         // case "modifyHeaders": not comparable after the first pass.
1509         default:
1510           throw new Error(`Unexpected action: ${matchedRule.rule.action.type}`);
1511       }
1512     }
1514     let requestEvaluators = [];
1515     let finalMatch;
1516     for (let ruleManager of ruleManagers) {
1517       // Evaluate request with findMatchingRules():
1518       const requestEvaluator = new RequestEvaluator(request, ruleManager);
1519       // RequestEvaluator may be used after the loop when the request is
1520       // accepted, to collect modifyHeaders/allow/allowAllRequests actions.
1521       requestEvaluators.push(requestEvaluator);
1522       let matchedRule = requestEvaluator.matchedRule;
1523       if (
1524         matchedRule &&
1525         (!finalMatch || precedence(matchedRule) < precedence(finalMatch))
1526       ) {
1527         // Before choosing the matched rule as finalMatch, check whether there
1528         // is an allowAllRequests rule override among the ancestors.
1529         requestEvaluator.findAncestorRuleOverride();
1530         matchedRule = requestEvaluator.matchedRule;
1531         if (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) {
1532           finalMatch = matchedRule;
1533           if (finalMatch.rule.action.type === "block") {
1534             break;
1535           }
1536         }
1537       }
1538     }
1539     if (finalMatch && !finalMatch.rule.isAllowOrAllowAllRequestsAction()) {
1540       // Found block/redirect/upgradeScheme, request will be replaced.
1541       return [finalMatch];
1542     }
1543     // Request not canceled, collect all modifyHeaders actions:
1544     let matchedRules = requestEvaluators
1545       .map(re => re.getMatchingModifyHeadersRules())
1546       .flat(1);
1548     // ... and collect the allowAllRequests actions:
1549     // Note: Only needed for testMatchOutcome, getMatchedRules (bug 1745765) and
1550     // onRuleMatchedDebug (bug 1745773). Not for regular requests, since regular
1551     // requests do not distinguish between no rule vs allow vs allowAllRequests.
1552     let finalAllowAllRequestsMatches = [];
1553     for (let requestEvaluator of requestEvaluators) {
1554       // TODO bug 1745765 / bug 1745773: Uncomment findAncestorRuleOverride()
1555       // when getMatchedRules() or onRuleMatchedDebug are implemented.
1556       // requestEvaluator.findAncestorRuleOverride();
1557       let matchedRule = requestEvaluator.matchedRule;
1558       if (matchedRule && matchedRule.rule.action.type === "allowAllRequests") {
1559         // Even if a different extension wins the final match, an extension
1560         // may want to record the "allowAllRequests" action for the future.
1561         finalAllowAllRequestsMatches.push(matchedRule);
1562       }
1563     }
1564     if (finalAllowAllRequestsMatches.length) {
1565       matchedRules = finalAllowAllRequestsMatches.concat(matchedRules);
1566     }
1568     // ... and collect the "allow" action. At this point, finalMatch could also
1569     // be a modifyHeaders or allowAllRequests action, but these would already
1570     // have been added to the matchedRules result before.
1571     if (finalMatch && finalMatch.rule.action.type === "allow") {
1572       matchedRules.unshift(finalMatch);
1573     }
1574     return matchedRules;
1575   }
1577   /**
1578    * Finds the matching rules, as documented in the comment before the class.
1579    */
1580   findMatchingRules() {
1581     if (!this.canModify && !this.ruleManager.hasBlockPermission) {
1582       // If the extension cannot apply any action, don't bother.
1583       return;
1584     }
1586     this.#collectMatchInRuleset(this.ruleManager.sessionRules);
1587     this.#collectMatchInRuleset(this.ruleManager.dynamicRules);
1588     for (let ruleset of this.ruleManager.enabledStaticRules) {
1589       this.#collectMatchInRuleset(ruleset);
1590     }
1592     if (this.matchedRule && !this.#isRuleActionAllowed(this.matchedRule.rule)) {
1593       this.matchedRule = null;
1594       // Note: this.matchedModifyHeadersRules is [] because canModify access is
1595       // checked before populating the list.
1596     }
1597   }
1599   /**
1600    * Find an "allowAllRequests" rule among the ancestors that may override the
1601    * current matchedRule and/or matchedModifyHeadersRules rules.
1602    */
1603   findAncestorRuleOverride() {
1604     if (this.didCheckAncestors) {
1605       return;
1606     }
1607     this.didCheckAncestors = true;
1609     if (!this.ruleManager.hasRulesWithAllowAllRequests) {
1610       // Optimization: Skip ancestorRequestDetails lookup and/or request
1611       // evaluation if there are no allowAllRequests rules.
1612       return;
1613     }
1615     // Now we need to check whether any of the ancestor frames had a matching
1616     // allowAllRequests rule. matchedRule and/or matchedModifyHeadersRules
1617     // results may be ignored if their priority is lower or equal to the
1618     // highest-priority allowAllRequests rule among the frame ancestors.
1619     //
1620     // In theory, every ancestor may potentially yield an allowAllRequests rule,
1621     // and should therefore be checked unconditionally. But logically, if there
1622     // are no existing matches, then any matching allowAllRequests rules will
1623     // not have any effect on the request outcome. As an optimization, we
1624     // therefore skip ancestor checks in this case.
1625     if (
1626       (!this.matchedRule ||
1627         this.matchedRule.rule.isAllowOrAllowAllRequestsAction()) &&
1628       !this.matchedModifyHeadersRules.length
1629     ) {
1630       // Optimization: Do not look up ancestors if no rules were matched.
1631       //
1632       // TODO bug 1745773: onRuleMatchedDebug is supposed to report when a rule
1633       // has been matched. To be pedantic, when there is an onRuleMatchedDebug
1634       // listener, the parents need to be checked unconditionally, in order to
1635       // report potential allowAllRequests matches among ancestors.
1636       // TODO bug 1745765: the above may also apply to getMatchedRules().
1637       return;
1638     }
1640     for (let request of this.req.ancestorRequestDetails) {
1641       // TODO: Optimize by only evaluating allow/allowAllRequests rules, because
1642       // the request being seen here implies that the request was not canceled,
1643       // i.e. that there were no block/redirect/upgradeScheme rules in any of
1644       // the ancestors (across all extensions!).
1645       let requestEvaluator = new RequestEvaluator(request, this.ruleManager);
1646       let ancestorMatchedRule = requestEvaluator.matchedRule;
1647       if (
1648         ancestorMatchedRule &&
1649         ancestorMatchedRule.rule.action.type === "allowAllRequests" &&
1650         (!this.matchedRule ||
1651           compareRule(
1652             this.matchedRule.rule,
1653             ancestorMatchedRule.rule,
1654             this.matchedRule.ruleset,
1655             ancestorMatchedRule.ruleset
1656           ) > 0)
1657       ) {
1658         // Found an allowAllRequests rule that takes precedence over whatever
1659         // the current rule was.
1660         this.matchedRule = ancestorMatchedRule;
1661       }
1662     }
1663   }
1665   /**
1666    * Retrieves the list of matched modifyHeaders rules that should apply.
1667    *
1668    * @returns {MatchedRule[]}
1669    */
1670   getMatchingModifyHeadersRules() {
1671     if (this.matchedModifyHeadersRules.length) {
1672       // Find parent allowAllRequests rules, if any, to make sure that we can
1673       // appropriately ignore same-or-lower-priority modifyHeaders rules.
1674       this.findAncestorRuleOverride();
1675     }
1676     // The minimum priority is 1. Defaulting to 0 = include all.
1677     let priorityThreshold = 0;
1678     if (this.matchedRule?.rule.isAllowOrAllowAllRequestsAction()) {
1679       priorityThreshold = this.matchedRule.rule.priority;
1680     }
1681     // Note: the result cannot be non-empty if this.matchedRule is a non-allow
1682     // action, because if that were to be the case, then the request would have
1683     // been canceled, and therefore there would not be any header to modify.
1684     // Even if another extension were to override the action, it could only be
1685     // any other non-allow action, which would still cancel the request.
1686     let matchedRules = this.matchedModifyHeadersRules.filter(matchedRule => {
1687       return matchedRule.rule.priority > priorityThreshold;
1688     });
1689     // Sort output for a deterministic order.
1690     // NOTE: Sorting rules at registration (in RuleManagers) would avoid the
1691     // need to sort here. Since the number of matched modifyHeaders rules are
1692     // expected to be small, we don't bother optimizing.
1693     matchedRules.sort((a, b) => {
1694       return compareRule(a.rule, b.rule, a.ruleset, b.ruleset);
1695     });
1696     return matchedRules;
1697   }
1699   /** @param {Ruleset} ruleset */
1700   #collectMatchInRuleset(ruleset) {
1701     for (let rule of ruleset.rules) {
1702       if (!this.#matchesRuleCondition(rule.condition)) {
1703         continue;
1704       }
1705       if (rule.action.type === "modifyHeaders") {
1706         if (this.canModify) {
1707           this.matchedModifyHeadersRules.push(new MatchedRule(rule, ruleset));
1708         }
1709         continue;
1710       }
1711       if (
1712         this.matchedRule &&
1713         compareRule(
1714           this.matchedRule.rule,
1715           rule,
1716           this.matchedRule.ruleset,
1717           ruleset
1718         ) <= 0
1719       ) {
1720         continue;
1721       }
1722       this.matchedRule = new MatchedRule(rule, ruleset);
1723     }
1724   }
1726   /**
1727    * @param {RuleCondition} cond
1728    * @returns {boolean} Whether the condition matched.
1729    */
1730   #matchesRuleCondition(cond) {
1731     if (cond.resourceTypes) {
1732       if (!cond.resourceTypes.includes(this.req.type)) {
1733         return false;
1734       }
1735     } else if (cond.excludedResourceTypes) {
1736       if (cond.excludedResourceTypes.includes(this.req.type)) {
1737         return false;
1738       }
1739     } else if (this.req.type === "main_frame") {
1740       // When resourceTypes/excludedResourceTypes are not specified, the
1741       // documented behavior is to ignore main_frame requests.
1742       return false;
1743     }
1745     // Check this.req.requestURI:
1746     if (cond.urlFilter) {
1747       if (!cond.urlFilterMatches(this.req.requestDataForUrlFilter)) {
1748         return false;
1749       }
1750     } else if (cond.regexFilter) {
1751       if (!cond.getCompiledRegexFilter().test(this.req.requestURIspec)) {
1752         return false;
1753       }
1754     }
1755     if (
1756       cond.excludedRequestDomains &&
1757       this.#matchesDomains(cond.excludedRequestDomains, this.req.requestDomain)
1758     ) {
1759       return false;
1760     }
1761     if (
1762       cond.requestDomains &&
1763       !this.#matchesDomains(cond.requestDomains, this.req.requestDomain)
1764     ) {
1765       return false;
1766     }
1767     if (
1768       cond.excludedInitiatorDomains &&
1769       // Note: unable to only match null principals (bug 1798225).
1770       this.req.initiatorDomain &&
1771       this.#matchesDomains(
1772         cond.excludedInitiatorDomains,
1773         this.req.initiatorDomain
1774       )
1775     ) {
1776       return false;
1777     }
1778     if (
1779       cond.initiatorDomains &&
1780       // Note: unable to only match null principals (bug 1798225).
1781       (!this.req.initiatorDomain ||
1782         !this.#matchesDomains(cond.initiatorDomains, this.req.initiatorDomain))
1783     ) {
1784       return false;
1785     }
1787     // TODO bug 1797408: domainType
1789     if (cond.requestMethods) {
1790       if (!cond.requestMethods.includes(this.req.method)) {
1791         return false;
1792       }
1793     } else if (cond.excludedRequestMethods?.includes(this.req.method)) {
1794       return false;
1795     }
1797     if (cond.tabIds) {
1798       if (!cond.tabIds.includes(this.req.tabId)) {
1799         return false;
1800       }
1801     } else if (cond.excludedTabIds?.includes(this.req.tabId)) {
1802       return false;
1803     }
1805     return true;
1806   }
1808   /**
1809    * @param {string[]} domains - A list of canonicalized domain patterns.
1810    *   Canonical means punycode, no ports, and IPv6 without brackets, and not
1811    *   starting with a dot. May end with a dot if it is a FQDN.
1812    * @param {string} host - The canonical representation of the host of a URL.
1813    * @returns {boolean} Whether the given host is a (sub)domain of any of the
1814    *   given domains.
1815    */
1816   #matchesDomains(domains, host) {
1817     return domains.some(domain => {
1818       return (
1819         host.endsWith(domain) &&
1820         // either host === domain
1821         (host.length === domain.length ||
1822           // or host = "something." + domain (WITH a domain separator).
1823           host.charAt(host.length - domain.length - 1) === ".")
1824       );
1825     });
1826   }
1828   /**
1829    * @param {Rule} rule - The final rule from the first pass.
1830    * @returns {boolean} Whether the extension is allowed to execute the rule.
1831    */
1832   #isRuleActionAllowed(rule) {
1833     if (this.canModify) {
1834       return true;
1835     }
1836     switch (rule.action.type) {
1837       case "allow":
1838       case "allowAllRequests":
1839       case "block":
1840       case "upgradeScheme":
1841         return this.ruleManager.hasBlockPermission;
1842       case "redirect":
1843         return false;
1844       // case "modifyHeaders" is never an action for this.matchedRule.
1845       default:
1846         throw new Error(`Unexpected action type: ${rule.action.type}`);
1847     }
1848   }
1852  * Checks whether a request from a document with the given URI is allowed to
1853  * be modified by an unprivileged extension (e.g. an extension without host
1854  * permissions but the "declarativeNetRequest" permission).
1855  * The output is comparable to WebExtensionPolicy::CanAccessURI for an extension
1856  * with the `<all_urls>` permission, for consistency with the webRequest API.
1858  * @param {nsIURI} [uri] The URI of a request's loadingPrincipal. May be void
1859  *   if missing (e.g. top-level requests) or not a content principal.
1860  * @returns {boolean} Whether there is any extension that is allowed to see
1861  *   requests from a document with the given URI. Callers are expected to:
1862  *   - check system requests (and treat as true).
1863  *   - check WebExtensionPolicy.isRestrictedURI (and treat as true).
1864  */
1865 function isRestrictedPrincipalURI(uri) {
1866   if (!uri) {
1867     // No URI, could be:
1868     // - System principal (caller should have checked and disallowed access).
1869     // - Expanded principal, typically content script in documents. If an
1870     //   extension content script managed to run there, that implies that an
1871     //   extension was able to access it.
1872     // - Null principal (e.g. sandboxed document, about:blank, data:).
1873     return false;
1874   }
1876   // An unprivileged extension with maximal host permissions has allowedOrigins
1877   // set to [`<all_urls>`, `moz-extension://extensions-own-uuid-here`].
1878   // `<all_urls>` matches PermittedSchemes from MatchPattern.cpp:
1879   // https://searchfox.org/mozilla-central/rev/55d5c4b9dffe5e59eb6b019c1a930ec9ada47e10/toolkit/components/extensions/MatchPattern.cpp#209-211
1880   // i.e. "http", "https", "ws", "wss", "file", "ftp", "data".
1881   // - It is not possible to have a loadingPrincipal for: ws, wss, ftp.
1882   // - data:-URIs always have an opaque origin, i.e. the principal is not a
1883   //   content principal, thus void here.
1884   // - The remaining schemes from `<all_urls>` are: http, https, file, data,
1885   //   and checked below.
1886   //
1887   // Privileged addons can also access resource: and about:, but we do not need
1888   // to support these now.
1890   // http(s) are common, and allowed, except for some restricted domains. The
1891   // caller is expected to check WebExtensionPolicy.isRestrictedURI.
1892   if (uri.schemeIs("http") || uri.schemeIs("https")) {
1893     return false; // Very common.
1894   }
1896   // moz-extension: is not restricted because an extension always has permission
1897   // to its own moz-extension:-origin. The caller is expected to verify that an
1898   // extension can only access its own URI.
1899   if (uri.schemeIs("moz-extension")) {
1900     return false;
1901   }
1903   // Requests from local files are intentionally allowed (bug 1621935).
1904   if (uri.schemeIs("file")) {
1905     return false;
1906   }
1908   // Anything else (e.g. resource:, about:newtab, etc.) is not allowed.
1909   return true;
1912 const NetworkIntegration = {
1913   maxEvaluatedRulesCount: 0,
1915   register() {
1916     // We register via WebRequest.jsm to ensure predictable ordering of DNR and
1917     // WebRequest behavior.
1918     lazy.WebRequest.setDNRHandlingEnabled(true);
1919   },
1920   unregister() {
1921     lazy.WebRequest.setDNRHandlingEnabled(false);
1922   },
1923   maybeUpdateTabIdChecker() {
1924     gHasAnyTabIdConditions = gRuleManagers.some(rm => rm.hasRulesWithTabIds);
1925   },
1927   startDNREvaluation(channel) {
1928     let ruleManagers = gRuleManagers;
1929     // TODO bug 1827422: Merge isRestrictedPrincipalURI with canModify.
1930     if (!channel.canModify || isRestrictedPrincipalURI(channel.documentURI)) {
1931       // Ignore system requests or requests to restricted domains.
1932       ruleManagers = [];
1933     }
1934     if (channel.loadInfo.originAttributes.privateBrowsingId > 0) {
1935       ruleManagers = ruleManagers.filter(
1936         rm => rm.extension.privateBrowsingAllowed
1937       );
1938     }
1939     if (ruleManagers.length && !lazy.gMatchRequestsFromOtherExtensions) {
1940       const policy = channel.loadInfo.loadingPrincipal?.addonPolicy;
1941       if (policy) {
1942         ruleManagers = ruleManagers.filter(
1943           rm => rm.extension.policy === policy
1944         );
1945       }
1946     }
1947     let matchedRules;
1948     if (ruleManagers.length) {
1949       const evaluateRulesTimerId =
1950         Glean.extensionsApisDnr.evaluateRulesTime.start();
1951       try {
1952         const request = RequestDetails.fromChannelWrapper(channel);
1953         matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers);
1954       } finally {
1955         if (evaluateRulesTimerId !== undefined) {
1956           Glean.extensionsApisDnr.evaluateRulesTime.stopAndAccumulate(
1957             evaluateRulesTimerId
1958           );
1959         }
1960       }
1961       const evaluateRulesCount = ruleManagers.reduce(
1962         (sum, ruleManager) => sum + ruleManager.getRulesCount(),
1963         0
1964       );
1965       if (evaluateRulesCount > this.maxEvaluatedRulesCount) {
1966         Glean.extensionsApisDnr.evaluateRulesCountMax.set(evaluateRulesCount);
1967         this.maxEvaluatedRulesCount = evaluateRulesCount;
1968       }
1969     }
1970     // Cache for later. In case of redirects, _dnrMatchedRules may exist for
1971     // the pre-redirect HTTP channel, and is overwritten here again.
1972     channel._dnrMatchedRules = matchedRules;
1973   },
1975   /**
1976    * Applies the actions of the DNR rules.
1977    *
1978    * @param {ChannelWrapper} channel
1979    * @returns {boolean} Whether to ignore any responses from the webRequest API.
1980    */
1981   onBeforeRequest(channel) {
1982     let matchedRules = channel._dnrMatchedRules;
1983     if (!matchedRules?.length) {
1984       return false;
1985     }
1986     // If a matched rule closes the channel, it is the sole match.
1987     const finalMatch = matchedRules[0];
1988     switch (finalMatch.rule.action.type) {
1989       case "block":
1990         this.applyBlock(channel, finalMatch);
1991         return true;
1992       case "redirect":
1993         this.applyRedirect(channel, finalMatch);
1994         return true;
1995       case "upgradeScheme":
1996         this.applyUpgradeScheme(channel, finalMatch);
1997         return true;
1998     }
1999     // If there are multiple rules, then it may be a combination of allow,
2000     // allowAllRequests and/or modifyHeaders.
2002     // "modifyHeaders" is handled by onBeforeSendHeaders/onHeadersReceived.
2003     // "allow" and "allowAllRequests" require no further action now.
2004     // "allowAllRequests" is applied to new requests in the future (if any)
2005     // through RequestEvaluator's findAncestorRuleOverride().
2007     return false;
2008   },
2010   onBeforeSendHeaders(channel) {
2011     let matchedRules = channel._dnrMatchedRules;
2012     if (!matchedRules?.length) {
2013       return;
2014     }
2015     ModifyRequestHeaders.maybeApplyModifyHeaders(channel, matchedRules);
2016   },
2018   onHeadersReceived(channel) {
2019     let matchedRules = channel._dnrMatchedRules;
2020     if (!matchedRules?.length) {
2021       return;
2022     }
2023     ModifyResponseHeaders.maybeApplyModifyHeaders(channel, matchedRules);
2024   },
2026   applyBlock(channel, matchedRule) {
2027     // TODO bug 1802259: Consider a DNR-specific reason.
2028     channel.cancel(
2029       Cr.NS_ERROR_ABORT,
2030       Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
2031     );
2032     const addonId = matchedRule.ruleManager.extension.id;
2033     let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag);
2034     properties.setProperty("cancelledByExtension", addonId);
2035   },
2037   applyUpgradeScheme(channel, matchedRule) {
2038     // Request upgrade. No-op if already secure (i.e. https).
2039     channel.upgradeToSecure();
2040   },
2042   applyRedirect(channel, matchedRule) {
2043     // Ambiguity resolution order of redirect dict keys, consistent with Chrome:
2044     // - url > extensionPath > transform > regexSubstitution
2045     const redirect = matchedRule.rule.action.redirect;
2046     const extension = matchedRule.ruleManager.extension;
2047     const preRedirectUri = channel.finalURI;
2048     let redirectUri;
2049     if (redirect.url) {
2050       // redirect.url already validated by checkActionRedirect.
2051       redirectUri = Services.io.newURI(redirect.url);
2052     } else if (redirect.extensionPath) {
2053       redirectUri = extension.baseURI
2054         .mutate()
2055         .setPathQueryRef(redirect.extensionPath)
2056         .finalize();
2057     } else if (redirect.transform) {
2058       redirectUri = applyURLTransform(preRedirectUri, redirect.transform);
2059     } else if (redirect.regexSubstitution) {
2060       // Note: may throw if regexSubstitution results in an invalid redirect.
2061       // The error propagates up to handleRequest, which will just allow the
2062       // request to continue.
2063       redirectUri = applyRegexSubstitution(preRedirectUri, matchedRule);
2064     } else {
2065       // #checkActionRedirect ensures that the redirect action is non-empty.
2066     }
2068     if (preRedirectUri.equals(redirectUri)) {
2069       // URL did not change. Sometimes it is a bug in the extension, but there
2070       // are also cases where the result is unavoidable. E.g. redirect.transform
2071       // with queryTransform.removeParams that does not remove anything.
2072       // TODO: consider logging to help with debugging.
2073       return;
2074     }
2076     channel.redirectTo(redirectUri);
2078     let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag);
2079     properties.setProperty("redirectedByExtension", extension.id);
2081     let origin = channel.getRequestHeader("Origin");
2082     if (origin) {
2083       channel.setResponseHeader("Access-Control-Allow-Origin", origin);
2084       channel.setResponseHeader("Access-Control-Allow-Credentials", "true");
2085       channel.setResponseHeader("Access-Control-Max-Age", "0");
2086     }
2087   },
2090 class RuleManager {
2091   constructor(extension) {
2092     this.extension = extension;
2093     this.sessionRules = this.makeRuleset(
2094       "_session",
2095       PRECEDENCE_SESSION_RULESET
2096     );
2097     this.dynamicRules = this.makeRuleset(
2098       "_dynamic",
2099       PRECEDENCE_DYNAMIC_RULESET
2100     );
2101     this.enabledStaticRules = [];
2103     this.hasBlockPermission = extension.hasPermission("declarativeNetRequest");
2104     this.hasRulesWithTabIds = false;
2105     this.hasRulesWithAllowAllRequests = false;
2106     this.totalRulesCount = 0;
2107   }
2109   get availableStaticRuleCount() {
2110     return Math.max(
2111       lazy.ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES -
2112         this.enabledStaticRules.reduce(
2113           (acc, ruleset) => acc + ruleset.rules.length,
2114           0
2115         ),
2116       0
2117     );
2118   }
2120   get enabledStaticRulesetIds() {
2121     return this.enabledStaticRules.map(ruleset => ruleset.id);
2122   }
2124   makeRuleset(rulesetId, rulesetPrecedence, rules = []) {
2125     return new Ruleset(rulesetId, rulesetPrecedence, rules, this);
2126   }
2128   setSessionRules(validatedSessionRules) {
2129     let oldRulesCount = this.sessionRules.rules.length;
2130     let newRulesCount = validatedSessionRules.length;
2131     this.sessionRules.rules = validatedSessionRules;
2132     this.totalRulesCount += newRulesCount - oldRulesCount;
2133     this.hasRulesWithTabIds = !!this.sessionRules.rules.find(rule => {
2134       return rule.condition.tabIds || rule.condition.excludedTabIds;
2135     });
2136     this.#updateAllowAllRequestRules();
2137     NetworkIntegration.maybeUpdateTabIdChecker();
2138   }
2140   setDynamicRules(validatedDynamicRules) {
2141     let oldRulesCount = this.dynamicRules.rules.length;
2142     let newRulesCount = validatedDynamicRules.length;
2143     this.dynamicRules.rules = validatedDynamicRules;
2144     this.totalRulesCount += newRulesCount - oldRulesCount;
2145     this.#updateAllowAllRequestRules();
2146   }
2148   /**
2149    * Set the enabled static rulesets.
2150    *
2151    * @param {Array<{ id, rules }>} enabledStaticRulesets
2152    *        Array of objects including the ruleset id and rules.
2153    *        The order of the rulesets in the Array is expected to
2154    *        match the order of the rulesets in the extension manifest.
2155    */
2156   setEnabledStaticRulesets(enabledStaticRulesets) {
2157     const rulesets = [];
2158     for (const [idx, { id, rules }] of enabledStaticRulesets.entries()) {
2159       rulesets.push(
2160         this.makeRuleset(id, idx + PRECEDENCE_STATIC_RULESETS_BASE, rules)
2161       );
2162     }
2163     const countRules = rulesets =>
2164       rulesets.reduce((sum, ruleset) => sum + ruleset.rules.length, 0);
2165     const oldRulesCount = countRules(this.enabledStaticRules);
2166     const newRulesCount = countRules(rulesets);
2167     this.enabledStaticRules = rulesets;
2168     this.totalRulesCount += newRulesCount - oldRulesCount;
2169     this.#updateAllowAllRequestRules();
2170   }
2172   getSessionRules() {
2173     return this.sessionRules.rules;
2174   }
2176   getDynamicRules() {
2177     return this.dynamicRules.rules;
2178   }
2180   getRulesCount() {
2181     return this.totalRulesCount;
2182   }
2184   #updateAllowAllRequestRules() {
2185     const filterAAR = rule => rule.action.type === "allowAllRequests";
2186     this.hasRulesWithAllowAllRequests =
2187       this.sessionRules.rules.some(filterAAR) ||
2188       this.dynamicRules.rules.some(filterAAR) ||
2189       this.enabledStaticRules.some(ruleset => ruleset.rules.some(filterAAR));
2190   }
2193 function getRuleManager(extension, createIfMissing = true) {
2194   let ruleManager = gRuleManagers.find(rm => rm.extension === extension);
2195   if (!ruleManager && createIfMissing) {
2196     if (extension.hasShutdown) {
2197       throw new Error(
2198         `Error on creating new DNR RuleManager after extension shutdown: ${extension.id}`
2199       );
2200     }
2201     ruleManager = new RuleManager(extension);
2202     // The most recently installed extension gets priority, i.e. appears at the
2203     // start of the gRuleManagers list. It is not yet possible to determine the
2204     // installation time of a given Extension, so currently the last to
2205     // instantiate a RuleManager claims the highest priority.
2206     // TODO bug 1786059: order extensions by "installation time".
2207     gRuleManagers.unshift(ruleManager);
2208     if (gRuleManagers.length === 1) {
2209       // The first DNR registration.
2210       NetworkIntegration.register();
2211     }
2212   }
2213   return ruleManager;
2216 function clearRuleManager(extension) {
2217   let i = gRuleManagers.findIndex(rm => rm.extension === extension);
2218   if (i !== -1) {
2219     gRuleManagers.splice(i, 1);
2220     NetworkIntegration.maybeUpdateTabIdChecker();
2221     if (gRuleManagers.length === 0) {
2222       // The last DNR registration.
2223       NetworkIntegration.unregister();
2224     }
2225   }
2229  * Finds all matching rules for a request, optionally restricted to one
2230  * extension. Used by declarativeNetRequest.testMatchOutcome.
2232  * @param {object|RequestDetails} request
2233  * @param {Extension} [extension]
2234  * @returns {MatchedRule[]}
2235  */
2236 function getMatchedRulesForRequest(request, extension) {
2237   let requestDetails = new RequestDetails(request);
2238   const { requestURI, initiatorURI } = requestDetails;
2239   let ruleManagers = gRuleManagers;
2240   if (extension) {
2241     ruleManagers = ruleManagers.filter(rm => rm.extension === extension);
2242   }
2243   if (
2244     // NetworkIntegration.startDNREvaluation does not check requestURI, but we
2245     // do that here to filter URIs that are obviously disallowed. In practice,
2246     // anything other than http(s) is bogus and unsupported in DNR.
2247     isRestrictedPrincipalURI(requestURI) ||
2248     // Equivalent to NetworkIntegration.startDNREvaluation's channel.canModify
2249     // check, which excludes system requests and restricted domains.
2250     WebExtensionPolicy.isRestrictedURI(requestURI) ||
2251     (initiatorURI && WebExtensionPolicy.isRestrictedURI(initiatorURI)) ||
2252     isRestrictedPrincipalURI(initiatorURI)
2253   ) {
2254     ruleManagers = [];
2255   }
2256   // While this simulated request is not really from another extension, apply
2257   // the same access control checks from NetworkIntegration.startDNREvaluation
2258   // for consistency.
2259   if (
2260     !lazy.gMatchRequestsFromOtherExtensions &&
2261     initiatorURI?.schemeIs("moz-extension")
2262   ) {
2263     const extUuid = initiatorURI.host;
2264     ruleManagers = ruleManagers.filter(rm => rm.extension.uuid === extUuid);
2265   }
2266   return RequestEvaluator.evaluateRequest(requestDetails, ruleManagers);
2270  * Runs before any webRequest event is notified. Headers may be modified, but
2271  * the request should not be canceled (see handleRequest instead).
2273  * @param {ChannelWrapper} channel
2274  * @param {string} kind - The name of the webRequest event.
2275  */
2276 function beforeWebRequestEvent(channel, kind) {
2277   try {
2278     switch (kind) {
2279       case "onBeforeRequest":
2280         NetworkIntegration.startDNREvaluation(channel);
2281         break;
2282       case "onBeforeSendHeaders":
2283         NetworkIntegration.onBeforeSendHeaders(channel);
2284         break;
2285       case "onHeadersReceived":
2286         NetworkIntegration.onHeadersReceived(channel);
2287         break;
2288     }
2289   } catch (e) {
2290     Cu.reportError(e);
2291   }
2295  * Applies matching DNR rules, some of which may potentially cancel the request.
2297  * @param {ChannelWrapper} channel
2298  * @param {string} kind - The name of the webRequest event.
2299  * @returns {boolean} Whether to ignore any responses from the webRequest API.
2300  */
2301 function handleRequest(channel, kind) {
2302   try {
2303     if (kind === "onBeforeRequest") {
2304       return NetworkIntegration.onBeforeRequest(channel);
2305     }
2306   } catch (e) {
2307     Cu.reportError(e);
2308   }
2309   return false;
2312 async function initExtension(extension) {
2313   // These permissions are NOT an OptionalPermission, so their status can be
2314   // assumed to be constant for the lifetime of the extension.
2315   if (
2316     extension.hasPermission("declarativeNetRequest") ||
2317     extension.hasPermission("declarativeNetRequestWithHostAccess")
2318   ) {
2319     if (extension.hasShutdown) {
2320       throw new Error(
2321         `Aborted ExtensionDNR.initExtension call, extension "${extension.id}" is not active anymore`
2322       );
2323     }
2324     extension.once("shutdown", () => clearRuleManager(extension));
2325     await lazy.ExtensionDNRStore.initExtension(extension);
2326   }
2329 function ensureInitialized(extension) {
2330   return (extension._dnrReady ??= initExtension(extension));
2333 function validateManifestEntry(extension) {
2334   const ruleResourcesArray =
2335     extension.manifest.declarative_net_request.rule_resources;
2337   const getWarningMessage = msg =>
2338     `Warning processing declarative_net_request: ${msg}`;
2340   const { MAX_NUMBER_OF_STATIC_RULESETS } = lazy.ExtensionDNRLimits;
2341   if (ruleResourcesArray.length > MAX_NUMBER_OF_STATIC_RULESETS) {
2342     extension.manifestWarning(
2343       getWarningMessage(
2344         `Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit (${MAX_NUMBER_OF_STATIC_RULESETS}).`
2345       )
2346     );
2347   }
2349   const seenRulesetIds = new Set();
2350   const seenRulesetPaths = new Set();
2351   const duplicatedRulesetIds = [];
2352   const duplicatedRulesetPaths = [];
2353   for (const [idx, { id, path }] of ruleResourcesArray.entries()) {
2354     if (seenRulesetIds.has(id)) {
2355       duplicatedRulesetIds.push({ idx, id });
2356     }
2357     if (seenRulesetPaths.has(path)) {
2358       duplicatedRulesetPaths.push({ idx, path });
2359     }
2360     seenRulesetIds.add(id);
2361     seenRulesetPaths.add(path);
2362   }
2364   if (duplicatedRulesetIds.length) {
2365     const errorDetails = duplicatedRulesetIds
2366       .map(({ idx, id }) => `"${id}" at index ${idx}`)
2367       .join(", ");
2368     extension.manifestWarning(
2369       getWarningMessage(
2370         `Static ruleset ids should be unique, duplicated ruleset ids: ${errorDetails}.`
2371       )
2372     );
2373   }
2375   if (duplicatedRulesetPaths.length) {
2376     // NOTE: technically Chrome allows duplicated paths without any manifest
2377     // validation warnings or errors, but if this happens it not unlikely to be
2378     // actually a mistake in the manifest that may have been missed.
2379     //
2380     // In Firefox we decided to allow the same behavior to avoid introducing a chrome
2381     // incompatibility, but we still warn about it to avoid extension developers
2382     // to investigate more easily issue that may be due to duplicated rulesets
2383     // paths.
2384     const errorDetails = duplicatedRulesetPaths
2385       .map(({ idx, path }) => `"${path}" at index ${idx}`)
2386       .join(", ");
2387     extension.manifestWarning(
2388       getWarningMessage(
2389         `Static rulesets paths are not unique, duplicated ruleset paths: ${errorDetails}.`
2390       )
2391     );
2392   }
2394   const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits;
2396   const enabledRulesets = ruleResourcesArray.filter(rs => rs.enabled);
2397   if (enabledRulesets.length > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) {
2398     const exceedingRulesetIds = enabledRulesets
2399       .slice(MAX_NUMBER_OF_ENABLED_STATIC_RULESETS)
2400       .map(ruleset => `"${ruleset.id}"`)
2401       .join(", ");
2402     extension.manifestWarning(
2403       getWarningMessage(
2404         `Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ${exceedingRulesetIds}.`
2405       )
2406     );
2407   }
2410 async function updateEnabledStaticRulesets(extension, updateRulesetOptions) {
2411   await ensureInitialized(extension);
2412   await lazy.ExtensionDNRStore.updateEnabledStaticRulesets(
2413     extension,
2414     updateRulesetOptions
2415   );
2418 async function updateDynamicRules(extension, updateRuleOptions) {
2419   await ensureInitialized(extension);
2420   await lazy.ExtensionDNRStore.updateDynamicRules(extension, updateRuleOptions);
2423 // exports used by the DNR API implementation.
2424 export const ExtensionDNR = {
2425   RuleValidator,
2426   RuleQuotaCounter,
2427   clearRuleManager,
2428   ensureInitialized,
2429   getMatchedRulesForRequest,
2430   getRuleManager,
2431   updateDynamicRules,
2432   updateEnabledStaticRulesets,
2433   validateManifestEntry,
2434   beforeWebRequestEvent,
2435   handleRequest,