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 = [];
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.
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.
21 * Unless stated otherwise, the explanation below describes the behavior within
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.
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.
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.
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.
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.
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).
60 * If an extension does not have sufficient permissions for the action, the
61 * resulting action is ignored.
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():
68 * - redirect / upgradeScheme
69 * - allow / allowAllRequests
72 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
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",
82 import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
84 const { ExtensionError } = ExtensionUtils;
86 XPCOMUtils.defineLazyPreferenceGetter(
88 "gMatchRequestsFromOtherExtensions",
89 "extensions.dnr.match_requests_from_other_extensions",
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 {
104 #compiledRegexFilter;
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;
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(
129 this.isUrlFilterCaseSensitive
132 return this.#compiledUrlFilter.matchesRequest(requestDataForUrlFilter);
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;
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;
151 this.priority = rule.priority;
152 this.condition = new RuleCondition(rule.condition);
153 this.action = rule.action;
156 // The precedence of rules within an extension. This method is frequently
157 // used during the first pass of the RequestEvaluator.
159 switch (this.action.type) {
161 return 1; // Highest precedence.
162 case "allowAllRequests":
166 case "upgradeScheme":
170 case "modifyHeaders":
173 throw new Error(`Unexpected action type: ${this.action.type}`);
177 isAllowOrAllowAllRequestsAction() {
178 const type = this.action.type;
179 return type === "allow" || type === "allowAllRequests";
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.
190 constructor(rulesetId, rulesetPrecedence, rules, ruleManager) {
192 this.rulesetPrecedence = rulesetPrecedence;
194 // For use by MatchedRule.
195 this.ruleManager = ruleManager;
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.
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);
218 if (!uriQuery.length && !queryTransform.addOrReplaceParams) {
222 const removeParamsSet = new Set(queryTransform.removeParams?.map(urlencode));
223 const addParams = (queryTransform.addOrReplaceParams || []).map(orig => ({
224 normalizedKey: urlencode(orig.key),
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)) {
234 let i = addParams.findIndex(p => p.normalizedKey === key);
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);
243 finalParams.push(part);
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)}`);
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.
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);
278 if (transform.username != null) {
279 mut.setUsername(transform.username);
281 if (transform.password != null) {
282 mut.setPassword(transform.password);
284 if (transform.host != null) {
285 mut.setHost(transform.host);
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);
292 if (transform.path != null) {
293 mut.setFilePath(transform.path);
295 if (transform.query != null) {
296 mut.setQuery(transform.query);
297 } else if (transform.queryTransform) {
298 mut.setQuery(applyQueryTransform(uri.query, transform.queryTransform));
300 if (transform.fragment != null) {
301 mut.setRef(transform.fragment);
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.
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] ?? "";
328 // Throws if the URL is invalid:
331 redirectUri = Services.io.newURI(redirectUrl);
334 `Extension ${extension.id} tried to redirect to an invalid URL: ${redirectUrl}`
337 if (!extension.checkLoadURI(redirectUri, { dontReportErrors: true })) {
339 `Extension ${extension.id} may not redirect to: ${redirectUrl}`
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.
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 "^".
383 * @param {string} urlFilter - non-empty urlFilter
384 * @param {boolean} [isUrlFilterCaseSensitive]
386 constructor(urlFilter, isUrlFilterCaseSensitive) {
387 this.#isUrlFilterCaseSensitive = isUrlFilterCaseSensitive;
388 this.#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive);
391 #initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive) {
393 let end = urlFilter.length;
395 // First, trim the anchors off urlFilter.
396 if (urlFilter[0] === "|") {
397 if (urlFilter[1] === "|") {
399 this.#isAnchorDomain = true;
400 // ^ will not revert to false below, because "||*" is already rejected
401 // by RuleValidator's #checkCondUrlFilterAndRegexFilter method.
404 this.#isAnchorLeft = true; // may revert to false below.
407 if (end > start && urlFilter[end - 1] === "|") {
409 this.#isAnchorRight = true; // may revert to false below.
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] === "*") {
416 this.#isAnchorLeft = false;
418 while (end > start && urlFilter[end - 1] === "*") {
420 this.#isAnchorRight = false;
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();
433 this.#urlFilterParts = urlFilterWithoutAnchors.split("*");
437 * Tests whether |request| matches the urlFilter.
439 * @param {RequestDataForUrlFilter} requestDataForUrlFilter
440 * @returns {boolean} Whether the condition matches the URL.
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.
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)) {
460 atUrlIndex = head.length;
461 } else if (this.#isAnchorDomain) {
462 atUrlIndex = this.#indexAfterDomainPart(head, url, domainAnchors);
464 atUrlIndex = this.#indexAfterPart(head, url, 0);
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);
472 if (atUrlIndex === -1) {
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;
480 if (!this.#isAnchorRight || atUrlIndex === REAL_END_OF_URL) {
481 // Either not interested in the end, or already at the end of the URL.
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.
498 if (this.#isAnchorDomain) {
499 // The tail must be exactly at one of the domain anchors.
501 (domainAnchors.includes(expectedTailIndex) &&
502 this.#startsWithPart(tail, url, expectedTailIndex)) ||
503 (this.#isTrailingSeparator &&
504 domainAnchors.includes(expectedTailIndexPlus1) &&
505 this.#startsWithPart(tail, url, expectedTailIndexPlus1))
508 // head has no left/domain anchor, fall through.
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.
513 (expectedTailIndex > previouslyAtUrlIndex &&
514 this.#startsWithPart(tail, url, expectedTailIndex)) ||
515 (this.#isTrailingSeparator &&
516 expectedTailIndexPlus1 > previouslyAtUrlIndex &&
517 this.#startsWithPart(tail, url, expectedTailIndexPlus1))
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) {
528 return url.startsWith(part, urlIndex);
530 if (urlIndex + part.length > url.length) {
533 for (let i = 0; i < part.length; ++i) {
534 let partChar = part[i];
535 let urlChar = url[urlIndex + i];
537 partChar !== urlChar &&
538 (partChar !== "^" || !CompiledUrlFilter.#regexIsSep.test(urlChar))
546 #startsWithPart(part, url, urlIndex) {
547 const sepStart = part.indexOf("^");
548 return this.#matchPartAt(part, url, urlIndex, sepStart);
551 #indexAfterPart(part, url, urlIndex) {
552 let sepStart = part.indexOf("^");
553 if (sepStart === -1) {
555 let i = url.indexOf(part, urlIndex);
556 return i === -1 ? i : i + part.length;
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;
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;
578 // See CompiledUrlFilter for documentation of RequestDataForUrlFilter.
579 class RequestDataForUrlFilter {
581 * @param {string} requestURIspec - The URL to match against.
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);
591 getUrl(isUrlFilterCaseSensitive) {
592 return isUrlFilterCaseSensitive ? this.urlAnyCase : this.urlLowerCase;
595 #getDomainAnchors(url) {
596 let hostStart = url.indexOf("://") + 3;
597 let hostEnd = url.indexOf("/", hostStart);
598 let userpassEnd = url.lastIndexOf("@", hostEnd) + 1;
600 hostStart = userpassEnd;
602 let host = url.slice(hostStart, hostEnd);
603 let domainAnchors = [hostStart];
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);
609 return domainAnchors;
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();
630 * @param {ChannelWrapper} channel
632 constructor(channel) {
633 this.channel = channel;
637 * @param {MatchedRule} matchedRule
638 * @returns {object[]}
640 headerActionsFor(matchedRule) {
641 throw new Error("Not implemented.");
645 * @param {MatchedRule} matchedrule
646 * @param {string} name
647 * @param {string} value
648 * @param {boolean} merge
650 setHeaderImpl(matchedrule, name, value, merge) {
651 throw new Error("Not implemented.");
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)) {
665 ok = this.setHeader(matchedRule, name, value, /* merge */ false);
667 this.#appendStillAllowed.add(name);
671 ok = this.setHeader(matchedRule, name, value, /* merge */ true);
673 this.#appendStillAllowed.add(name);
677 ok = this.setHeader(matchedRule, name, "", /* merge */ false);
678 // Note: removal is final, so we don't add to #appendStillAllowed.
682 this.#alreadyModifiedMap.set(name, matchedRule);
688 #isOperationAllowed(name, operation, matchedRule) {
689 const modifiedBy = this.#alreadyModifiedMap.get(name);
694 operation === "append" &&
695 this.#appendStillAllowed.has(name) &&
696 matchedRule.ruleManager === modifiedBy.ruleManager
700 // TODO bug 1803369: dev experience improvement: consider logging when
701 // a header modification was rejected.
705 setHeader(matchedRule, name, value, merge) {
707 this.setHeaderImpl(matchedRule, name, value, merge);
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}`
718 // kName should already be in lower case.
719 isHeaderNameEqual(name, kName) {
720 return name.length === kName.length && name.toLowerCase() === kName;
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;
730 if (matchedRules.length) {
731 new ModifyRequestHeaders(channel).applyModifyHeaders(matchedRules);
735 /** @param {MatchedRule} matchedRule */
736 headerActionsFor(matchedRule) {
737 return matchedRule.rule.action.requestHeaders;
740 setHeaderImpl(matchedRule, name, value, merge) {
741 if (this.isHeaderNameEqual(name, "host")) {
742 this.#checkHostHeader(matchedRule, value);
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;
758 this.channel.setRequestHeader(name, value, merge);
761 #checkHostHeader(matchedRule, value) {
762 let uri = Services.io.newURI(`https://${value}/`);
763 let { policy } = matchedRule.ruleManager.extension;
765 if (!policy.allowedOrigins.matches(uri)) {
767 `Unable to set host header, url missing from permissions.`
771 if (WebExtensionPolicy.isRestrictedURI(uri)) {
772 throw new Error(`Unable to set host header to restricted url.`);
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;
783 if (matchedRules.length) {
784 new ModifyResponseHeaders(channel).applyModifyHeaders(matchedRules);
788 headerActionsFor(matchedRule) {
789 return matchedRule.rule.action.responseHeaders;
792 setHeaderImpl(matchedRule, name, value, merge) {
793 this.channel.setResponseHeader(name, value, merge);
797 class RuleValidator {
798 constructor(alreadyValidatedRules, { isSessionRuleset = false } = {}) {
799 this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r]));
801 this.isSessionRuleset = isSessionRuleset;
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.
809 * @param {object} rule
812 static deserializeRule(rule) {
813 const newRule = new Rule(rule);
814 if (newRule.condition.regexFilter) {
815 newRule.condition.setCompiledRegexFilter(
817 newRule.condition.regexFilter,
818 newRule.condition.isUrlFilterCaseSensitive
825 removeRuleIds(ruleIds) {
826 for (const ruleId of ruleIds) {
827 this.rulesMap.delete(ruleId);
832 * @param {object[]} rules - A list of objects that adhere to the Rule type
833 * from declarative_net_request.json.
836 for (const rule of rules) {
837 if (this.rulesMap.has(rule.id)) {
838 this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`);
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).
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)
853 !this.#checkCondResourceTypes(rule) ||
854 !this.#checkCondRequestMethods(rule) ||
855 !this.#checkCondTabIds(rule) ||
856 !this.#checkCondUrlFilterAndRegexFilter(rule) ||
857 !this.#checkAction(rule)
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);
869 this.rulesMap.set(rule.id, newRule);
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(
884 "resourceTypes and excludedResourceTypes should not overlap"
888 if (rule.action.type === "allowAllRequests") {
889 if (!resourceTypes) {
890 this.#collectInvalidRule(
892 "An allowAllRequests rule must have a non-empty resourceTypes array"
896 if (resourceTypes.some(r => r !== "main_frame" && r !== "sub_frame")) {
897 this.#collectInvalidRule(
899 "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes"
907 // Checks: requestMethods & excludedRequestMethods
908 #checkCondRequestMethods(rule) {
909 const { requestMethods, excludedRequestMethods } = rule.condition;
910 if (this.#hasOverlap(requestMethods, excludedRequestMethods)) {
911 this.#collectInvalidRule(
913 "requestMethods and excludedRequestMethods should not overlap"
917 const isInvalidRequestMethod = method => method.toLowerCase() !== method;
919 requestMethods?.some(isInvalidRequestMethod) ||
920 excludedRequestMethods?.some(isInvalidRequestMethod)
922 this.#collectInvalidRule(rule, "request methods must be in lower case");
928 // Checks: tabIds & excludedTabIds
929 #checkCondTabIds(rule) {
930 const { tabIds, excludedTabIds } = rule.condition;
932 if ((tabIds || excludedTabIds) && !this.isSessionRuleset) {
933 this.#collectInvalidRule(
935 "tabIds and excludedTabIds can only be specified in session rules"
940 if (this.#hasOverlap(tabIds, excludedTabIds)) {
941 this.#collectInvalidRule(
943 "tabIds and excludedTabIds should not overlap"
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) => {
961 this.#collectInvalidRule(rule, `${prop} should not be an empty string`);
964 // Non-ASCII in URLs are always encoded in % (or punycode in domains).
965 if (RuleValidator.#regexNonASCII.test(str)) {
966 this.#collectInvalidRule(
968 `${prop} should not contain non-ASCII characters`
974 if (urlFilter != null) {
975 if (regexFilter != null) {
976 this.#collectInvalidRule(
978 "urlFilter and regexFilter are mutually exclusive"
982 if (!checkEmptyOrNonASCII(urlFilter, "urlFilter")) {
983 // #collectInvalidRule already called by checkEmptyOrNonASCII.
986 if (urlFilter.startsWith("||*")) {
987 // Rejected because Chrome does too. '||*' is equivalent to '*'.
988 this.#collectInvalidRule(rule, "urlFilter should not start with '||*'");
991 } else if (regexFilter != null) {
992 if (!checkEmptyOrNonASCII(regexFilter, "regexFilter")) {
993 // #collectInvalidRule already called by checkEmptyOrNonASCII.
997 this.#lastCompiledRegexFilter = compileRegexFilter(
999 rule.condition.isUrlFilterCaseSensitive
1002 this.#collectInvalidRule(
1004 "regexFilter is not a valid regular expression"
1012 #checkAction(rule) {
1013 switch (rule.action.type) {
1015 case "allowAllRequests":
1017 case "upgradeScheme":
1018 // These actions have no extra properties.
1021 return this.#checkActionRedirect(rule);
1022 case "modifyHeaders":
1023 return this.#checkActionModifyHeaders(rule);
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}`);
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(
1043 "A redirect rule must have a non-empty action.redirect object"
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(
1052 "redirect.url, redirect.extensionPath, redirect.transform and redirect.regexSubstitution are mutually exclusive"
1057 if (hasExtensionPath && !extensionPath.startsWith("/")) {
1058 this.#collectInvalidRule(
1060 "redirect.extensionPath should start with a '/'"
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.
1073 if (transform.query != null && transform.queryTransform) {
1074 this.#collectInvalidRule(
1076 "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive"
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(
1090 "redirect.transform.port should be empty or an integer"
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(
1101 "redirect.transform.query should be empty or start with a '?'"
1105 if (transform.fragment && !transform.fragment.startsWith("#")) {
1106 this.#collectInvalidRule(
1108 "redirect.transform.fragment should be empty or start with a '#'"
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);
1118 this.#collectInvalidRule(
1120 "redirect.transform does not describe a valid URL transformation"
1126 if (hasRegexSubstitution) {
1127 if (!rule.condition.regexFilter) {
1128 this.#collectInvalidRule(
1130 "redirect.regexSubstitution requires the regexFilter condition to be specified"
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(
1141 "redirect.regexSubstitution only allows digit or \\ after \\."
1151 #checkActionModifyHeaders(rule) {
1152 const { requestHeaders, responseHeaders } = rule.action;
1153 if (!requestHeaders && !responseHeaders) {
1154 this.#collectInvalidRule(
1156 "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list"
1161 const isValidModifyHeadersOp = ({ header, operation, value }) => {
1163 this.#collectInvalidRule(rule, "header must be non-empty");
1166 if (!value && (operation === "append" || operation === "set")) {
1167 this.#collectInvalidRule(
1169 "value is required for operations append/set"
1173 if (value && operation === "remove") {
1174 this.#collectInvalidRule(
1176 "value must not be provided for operation remove"
1183 (requestHeaders && !requestHeaders.every(isValidModifyHeadersOp)) ||
1184 (responseHeaders && !responseHeaders.every(isValidModifyHeadersOp))
1186 // #collectInvalidRule already called by isValidModifyHeadersOp.
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));
1198 #collectInvalidRule(rule, message) {
1199 this.failures.push({ rule, message });
1202 getValidatedRules() {
1203 return Array.from(this.rulesMap.values());
1207 return this.failures;
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;
1221 tryAddRules(rulesetId, rules) {
1222 if (rules.length > this.ruleLimitRemaining) {
1223 this.#throwQuotaError(rulesetId, "rules", this.ruleLimitName);
1226 for (let rule of rules) {
1227 if (rule.condition.regexFilter && ++regexCount > this.regexRemaining) {
1228 this.#throwQuotaError(
1230 "regexFilter rules",
1231 "MAX_NUMBER_OF_REGEX_RULES"
1236 // Update counters only when there are no quota errors.
1237 this.ruleLimitRemaining -= rules.length;
1238 this.regexRemaining -= regexCount;
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.`
1247 throw new ExtensionError(
1248 `Number of ${what} in ruleset "${rulesetId}" exceeds ${limitName}.`
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}
1263 * <0 if ruleA comes before ruleB.
1264 * >0 if ruleA comes after ruleB.
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;
1271 function cmpLowestNumber(a, b) {
1272 return a === b ? 0 : a - b;
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)
1289 * @param {Rule} rule
1290 * @param {Ruleset} ruleset
1292 constructor(rule, ruleset) {
1294 this.ruleset = ruleset;
1297 // The RuleManager that generated this MatchedRule.
1299 return this.ruleset.ruleManager;
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 {
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).
1332 this.requestURI = requestURI;
1333 this.initiatorURI = initiatorURI;
1335 this.method = method;
1337 this.browsingContext = browsingContext;
1339 this.requestDomain = this.#domainFromURI(requestURI);
1340 this.initiatorDomain = initiatorURI
1341 ? this.#domainFromURI(initiatorURI)
1344 this.requestURIspec = requestURI.spec;
1345 this.requestDataForUrlFilter = new RequestDataForUrlFilter(
1350 static fromChannelWrapper(channel) {
1352 if (gHasAnyTabIdConditions) {
1353 tabId = lazy.WebRequest.getTabIdForChannelWrapper(channel);
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,
1360 method: channel.method.toLowerCase(),
1362 browsingContext: channel.loadInfo.browsingContext,
1366 #ancestorRequestDetails;
1367 get ancestorRequestDetails() {
1368 if (this.#ancestorRequestDetails) {
1369 return this.#ancestorRequestDetails;
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
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.
1381 // In any case, nothing left to do.
1382 return this.#ancestorRequestDetails;
1384 // Reconstruct the frame hierarchy of the request's document, in order to
1385 // retroactively recompute the relevant matches of allowAllRequests rules.
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.
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).
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.
1411 const isTop = !bc.parent;
1412 const parentPrin = bc.parentWindowContext?.documentPrincipal;
1413 const requestDetails = new RequestDetails({
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",
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,
1427 this.#ancestorRequestDetails.unshift(requestDetails);
1429 return this.#ancestorRequestDetails;
1432 canExtensionModify(extension) {
1433 const policy = extension.policy;
1434 if (!policy.canAccessURI(this.requestURI)) {
1438 this.initiatorURI &&
1439 this.type !== "main_frame" &&
1440 this.type !== "sub_frame" &&
1441 !policy.canAccessURI(this.initiatorURI)
1443 // Host permissions for the initiator is required except for navigation
1444 // requests: https://bugzilla.mozilla.org/show_bug.cgi?id=1825824#c2
1450 #domainFromURI(uri) {
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;
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).
1462 // declarativeNetRequest.testMatchOutcome can pass arbitrary URIs and thus
1463 // trigger the error in nsIURI::GetHost.
1471 * This RequestEvaluator class's logic is documented at the top of this file.
1473 class RequestEvaluator {
1474 // private constructor, only used by RequestEvaluator.evaluateRequest.
1475 constructor(request, ruleManager) {
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();
1488 * Finds the matched rules for the given request and extensions,
1489 * according to the logic documented at the top of this file.
1491 * @param {RequestDetails} request
1492 * @param {RuleManager[]} ruleManagers
1493 * The list of RuleManagers, ordered by importance of its extension.
1494 * @returns {MatchedRule[]}
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) {
1503 case "upgradeScheme":
1506 case "allowAllRequests":
1508 // case "modifyHeaders": not comparable after the first pass.
1510 throw new Error(`Unexpected action: ${matchedRule.rule.action.type}`);
1514 let requestEvaluators = [];
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;
1525 (!finalMatch || precedence(matchedRule) < precedence(finalMatch))
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") {
1539 if (finalMatch && !finalMatch.rule.isAllowOrAllowAllRequestsAction()) {
1540 // Found block/redirect/upgradeScheme, request will be replaced.
1541 return [finalMatch];
1543 // Request not canceled, collect all modifyHeaders actions:
1544 let matchedRules = requestEvaluators
1545 .map(re => re.getMatchingModifyHeadersRules())
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);
1564 if (finalAllowAllRequestsMatches.length) {
1565 matchedRules = finalAllowAllRequestsMatches.concat(matchedRules);
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);
1574 return matchedRules;
1578 * Finds the matching rules, as documented in the comment before the class.
1580 findMatchingRules() {
1581 if (!this.canModify && !this.ruleManager.hasBlockPermission) {
1582 // If the extension cannot apply any action, don't bother.
1586 this.#collectMatchInRuleset(this.ruleManager.sessionRules);
1587 this.#collectMatchInRuleset(this.ruleManager.dynamicRules);
1588 for (let ruleset of this.ruleManager.enabledStaticRules) {
1589 this.#collectMatchInRuleset(ruleset);
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.
1600 * Find an "allowAllRequests" rule among the ancestors that may override the
1601 * current matchedRule and/or matchedModifyHeadersRules rules.
1603 findAncestorRuleOverride() {
1604 if (this.didCheckAncestors) {
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.
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.
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.
1626 (!this.matchedRule ||
1627 this.matchedRule.rule.isAllowOrAllowAllRequestsAction()) &&
1628 !this.matchedModifyHeadersRules.length
1630 // Optimization: Do not look up ancestors if no rules were matched.
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().
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;
1648 ancestorMatchedRule &&
1649 ancestorMatchedRule.rule.action.type === "allowAllRequests" &&
1650 (!this.matchedRule ||
1652 this.matchedRule.rule,
1653 ancestorMatchedRule.rule,
1654 this.matchedRule.ruleset,
1655 ancestorMatchedRule.ruleset
1658 // Found an allowAllRequests rule that takes precedence over whatever
1659 // the current rule was.
1660 this.matchedRule = ancestorMatchedRule;
1666 * Retrieves the list of matched modifyHeaders rules that should apply.
1668 * @returns {MatchedRule[]}
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();
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;
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;
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);
1696 return matchedRules;
1699 /** @param {Ruleset} ruleset */
1700 #collectMatchInRuleset(ruleset) {
1701 for (let rule of ruleset.rules) {
1702 if (!this.#matchesRuleCondition(rule.condition)) {
1705 if (rule.action.type === "modifyHeaders") {
1706 if (this.canModify) {
1707 this.matchedModifyHeadersRules.push(new MatchedRule(rule, ruleset));
1714 this.matchedRule.rule,
1716 this.matchedRule.ruleset,
1722 this.matchedRule = new MatchedRule(rule, ruleset);
1727 * @param {RuleCondition} cond
1728 * @returns {boolean} Whether the condition matched.
1730 #matchesRuleCondition(cond) {
1731 if (cond.resourceTypes) {
1732 if (!cond.resourceTypes.includes(this.req.type)) {
1735 } else if (cond.excludedResourceTypes) {
1736 if (cond.excludedResourceTypes.includes(this.req.type)) {
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.
1745 // Check this.req.requestURI:
1746 if (cond.urlFilter) {
1747 if (!cond.urlFilterMatches(this.req.requestDataForUrlFilter)) {
1750 } else if (cond.regexFilter) {
1751 if (!cond.getCompiledRegexFilter().test(this.req.requestURIspec)) {
1756 cond.excludedRequestDomains &&
1757 this.#matchesDomains(cond.excludedRequestDomains, this.req.requestDomain)
1762 cond.requestDomains &&
1763 !this.#matchesDomains(cond.requestDomains, this.req.requestDomain)
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
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))
1787 // TODO bug 1797408: domainType
1789 if (cond.requestMethods) {
1790 if (!cond.requestMethods.includes(this.req.method)) {
1793 } else if (cond.excludedRequestMethods?.includes(this.req.method)) {
1798 if (!cond.tabIds.includes(this.req.tabId)) {
1801 } else if (cond.excludedTabIds?.includes(this.req.tabId)) {
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
1816 #matchesDomains(domains, host) {
1817 return domains.some(domain => {
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) === ".")
1829 * @param {Rule} rule - The final rule from the first pass.
1830 * @returns {boolean} Whether the extension is allowed to execute the rule.
1832 #isRuleActionAllowed(rule) {
1833 if (this.canModify) {
1836 switch (rule.action.type) {
1838 case "allowAllRequests":
1840 case "upgradeScheme":
1841 return this.ruleManager.hasBlockPermission;
1844 // case "modifyHeaders" is never an action for this.matchedRule.
1846 throw new Error(`Unexpected action type: ${rule.action.type}`);
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).
1865 function isRestrictedPrincipalURI(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:).
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.
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.
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")) {
1903 // Requests from local files are intentionally allowed (bug 1621935).
1904 if (uri.schemeIs("file")) {
1908 // Anything else (e.g. resource:, about:newtab, etc.) is not allowed.
1912 const NetworkIntegration = {
1913 maxEvaluatedRulesCount: 0,
1916 // We register via WebRequest.jsm to ensure predictable ordering of DNR and
1917 // WebRequest behavior.
1918 lazy.WebRequest.setDNRHandlingEnabled(true);
1921 lazy.WebRequest.setDNRHandlingEnabled(false);
1923 maybeUpdateTabIdChecker() {
1924 gHasAnyTabIdConditions = gRuleManagers.some(rm => rm.hasRulesWithTabIds);
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.
1934 if (channel.loadInfo.originAttributes.privateBrowsingId > 0) {
1935 ruleManagers = ruleManagers.filter(
1936 rm => rm.extension.privateBrowsingAllowed
1939 if (ruleManagers.length && !lazy.gMatchRequestsFromOtherExtensions) {
1940 const policy = channel.loadInfo.loadingPrincipal?.addonPolicy;
1942 ruleManagers = ruleManagers.filter(
1943 rm => rm.extension.policy === policy
1948 if (ruleManagers.length) {
1949 const evaluateRulesTimerId =
1950 Glean.extensionsApisDnr.evaluateRulesTime.start();
1952 const request = RequestDetails.fromChannelWrapper(channel);
1953 matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers);
1955 if (evaluateRulesTimerId !== undefined) {
1956 Glean.extensionsApisDnr.evaluateRulesTime.stopAndAccumulate(
1957 evaluateRulesTimerId
1961 const evaluateRulesCount = ruleManagers.reduce(
1962 (sum, ruleManager) => sum + ruleManager.getRulesCount(),
1965 if (evaluateRulesCount > this.maxEvaluatedRulesCount) {
1966 Glean.extensionsApisDnr.evaluateRulesCountMax.set(evaluateRulesCount);
1967 this.maxEvaluatedRulesCount = evaluateRulesCount;
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;
1976 * Applies the actions of the DNR rules.
1978 * @param {ChannelWrapper} channel
1979 * @returns {boolean} Whether to ignore any responses from the webRequest API.
1981 onBeforeRequest(channel) {
1982 let matchedRules = channel._dnrMatchedRules;
1983 if (!matchedRules?.length) {
1986 // If a matched rule closes the channel, it is the sole match.
1987 const finalMatch = matchedRules[0];
1988 switch (finalMatch.rule.action.type) {
1990 this.applyBlock(channel, finalMatch);
1993 this.applyRedirect(channel, finalMatch);
1995 case "upgradeScheme":
1996 this.applyUpgradeScheme(channel, finalMatch);
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().
2010 onBeforeSendHeaders(channel) {
2011 let matchedRules = channel._dnrMatchedRules;
2012 if (!matchedRules?.length) {
2015 ModifyRequestHeaders.maybeApplyModifyHeaders(channel, matchedRules);
2018 onHeadersReceived(channel) {
2019 let matchedRules = channel._dnrMatchedRules;
2020 if (!matchedRules?.length) {
2023 ModifyResponseHeaders.maybeApplyModifyHeaders(channel, matchedRules);
2026 applyBlock(channel, matchedRule) {
2027 // TODO bug 1802259: Consider a DNR-specific reason.
2030 Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
2032 const addonId = matchedRule.ruleManager.extension.id;
2033 let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag);
2034 properties.setProperty("cancelledByExtension", addonId);
2037 applyUpgradeScheme(channel, matchedRule) {
2038 // Request upgrade. No-op if already secure (i.e. https).
2039 channel.upgradeToSecure();
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;
2050 // redirect.url already validated by checkActionRedirect.
2051 redirectUri = Services.io.newURI(redirect.url);
2052 } else if (redirect.extensionPath) {
2053 redirectUri = extension.baseURI
2055 .setPathQueryRef(redirect.extensionPath)
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);
2065 // #checkActionRedirect ensures that the redirect action is non-empty.
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.
2076 channel.redirectTo(redirectUri);
2078 let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag);
2079 properties.setProperty("redirectedByExtension", extension.id);
2081 let origin = channel.getRequestHeader("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");
2091 constructor(extension) {
2092 this.extension = extension;
2093 this.sessionRules = this.makeRuleset(
2095 PRECEDENCE_SESSION_RULESET
2097 this.dynamicRules = this.makeRuleset(
2099 PRECEDENCE_DYNAMIC_RULESET
2101 this.enabledStaticRules = [];
2103 this.hasBlockPermission = extension.hasPermission("declarativeNetRequest");
2104 this.hasRulesWithTabIds = false;
2105 this.hasRulesWithAllowAllRequests = false;
2106 this.totalRulesCount = 0;
2109 get availableStaticRuleCount() {
2111 lazy.ExtensionDNRLimits.GUARANTEED_MINIMUM_STATIC_RULES -
2112 this.enabledStaticRules.reduce(
2113 (acc, ruleset) => acc + ruleset.rules.length,
2120 get enabledStaticRulesetIds() {
2121 return this.enabledStaticRules.map(ruleset => ruleset.id);
2124 makeRuleset(rulesetId, rulesetPrecedence, rules = []) {
2125 return new Ruleset(rulesetId, rulesetPrecedence, rules, this);
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;
2136 this.#updateAllowAllRequestRules();
2137 NetworkIntegration.maybeUpdateTabIdChecker();
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();
2149 * Set the enabled static rulesets.
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.
2156 setEnabledStaticRulesets(enabledStaticRulesets) {
2157 const rulesets = [];
2158 for (const [idx, { id, rules }] of enabledStaticRulesets.entries()) {
2160 this.makeRuleset(id, idx + PRECEDENCE_STATIC_RULESETS_BASE, rules)
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();
2173 return this.sessionRules.rules;
2177 return this.dynamicRules.rules;
2181 return this.totalRulesCount;
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));
2193 function getRuleManager(extension, createIfMissing = true) {
2194 let ruleManager = gRuleManagers.find(rm => rm.extension === extension);
2195 if (!ruleManager && createIfMissing) {
2196 if (extension.hasShutdown) {
2198 `Error on creating new DNR RuleManager after extension shutdown: ${extension.id}`
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();
2216 function clearRuleManager(extension) {
2217 let i = gRuleManagers.findIndex(rm => rm.extension === extension);
2219 gRuleManagers.splice(i, 1);
2220 NetworkIntegration.maybeUpdateTabIdChecker();
2221 if (gRuleManagers.length === 0) {
2222 // The last DNR registration.
2223 NetworkIntegration.unregister();
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[]}
2236 function getMatchedRulesForRequest(request, extension) {
2237 let requestDetails = new RequestDetails(request);
2238 const { requestURI, initiatorURI } = requestDetails;
2239 let ruleManagers = gRuleManagers;
2241 ruleManagers = ruleManagers.filter(rm => rm.extension === extension);
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)
2256 // While this simulated request is not really from another extension, apply
2257 // the same access control checks from NetworkIntegration.startDNREvaluation
2260 !lazy.gMatchRequestsFromOtherExtensions &&
2261 initiatorURI?.schemeIs("moz-extension")
2263 const extUuid = initiatorURI.host;
2264 ruleManagers = ruleManagers.filter(rm => rm.extension.uuid === extUuid);
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.
2276 function beforeWebRequestEvent(channel, kind) {
2279 case "onBeforeRequest":
2280 NetworkIntegration.startDNREvaluation(channel);
2282 case "onBeforeSendHeaders":
2283 NetworkIntegration.onBeforeSendHeaders(channel);
2285 case "onHeadersReceived":
2286 NetworkIntegration.onHeadersReceived(channel);
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.
2301 function handleRequest(channel, kind) {
2303 if (kind === "onBeforeRequest") {
2304 return NetworkIntegration.onBeforeRequest(channel);
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.
2316 extension.hasPermission("declarativeNetRequest") ||
2317 extension.hasPermission("declarativeNetRequestWithHostAccess")
2319 if (extension.hasShutdown) {
2321 `Aborted ExtensionDNR.initExtension call, extension "${extension.id}" is not active anymore`
2324 extension.once("shutdown", () => clearRuleManager(extension));
2325 await lazy.ExtensionDNRStore.initExtension(extension);
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(
2344 `Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit (${MAX_NUMBER_OF_STATIC_RULESETS}).`
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 });
2357 if (seenRulesetPaths.has(path)) {
2358 duplicatedRulesetPaths.push({ idx, path });
2360 seenRulesetIds.add(id);
2361 seenRulesetPaths.add(path);
2364 if (duplicatedRulesetIds.length) {
2365 const errorDetails = duplicatedRulesetIds
2366 .map(({ idx, id }) => `"${id}" at index ${idx}`)
2368 extension.manifestWarning(
2370 `Static ruleset ids should be unique, duplicated ruleset ids: ${errorDetails}.`
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.
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
2384 const errorDetails = duplicatedRulesetPaths
2385 .map(({ idx, path }) => `"${path}" at index ${idx}`)
2387 extension.manifestWarning(
2389 `Static rulesets paths are not unique, duplicated ruleset paths: ${errorDetails}.`
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}"`)
2402 extension.manifestWarning(
2404 `Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ${exceedingRulesetIds}.`
2410 async function updateEnabledStaticRulesets(extension, updateRulesetOptions) {
2411 await ensureInitialized(extension);
2412 await lazy.ExtensionDNRStore.updateEnabledStaticRulesets(
2414 updateRulesetOptions
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 = {
2429 getMatchedRulesForRequest,
2432 updateEnabledStaticRulesets,
2433 validateManifestEntry,
2434 beforeWebRequestEvent,