forked from mirrors/gecko-dev
1902 lines
64 KiB
JavaScript
1902 lines
64 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
// Each extension that uses DNR has one RuleManager. All registered RuleManagers
|
|
// are checked whenever a network request occurs. Individual extensions may
|
|
// occasionally modify their rules (e.g. via the updateSessionRules API).
|
|
const gRuleManagers = [];
|
|
|
|
/**
|
|
* Whenever a request occurs, the rules of each RuleManager are matched against
|
|
* the request to determine the final action to take. The RequestEvaluator class
|
|
* is responsible for evaluating rules, and its behavior is described below.
|
|
*
|
|
* Short version:
|
|
* Find the highest-priority rule that matches the given request. If the
|
|
* request is not canceled, all matching allowAllRequests and modifyHeaders
|
|
* actions are returned.
|
|
*
|
|
* Longer version:
|
|
* Unless stated otherwise, the explanation below describes the behavior within
|
|
* an extension.
|
|
* An extension can specify rules, optionally in multiple rulesets. The ability
|
|
* to have multiple ruleset exists to support bulk updates of rules. Rulesets
|
|
* are NOT independent - rules from different rulesets can affect each other.
|
|
*
|
|
* When multiple rules match, the order between rules are defined as follows:
|
|
* - Ruleset precedence: session > dynamic > static (order from manifest.json).
|
|
* - Rules in ruleset precedence: ordered by rule.id, lowest (numeric) ID first.
|
|
* - Across all rules+rulesets: highest rule.priority (default 1) first,
|
|
* action precedence if rule priority are the same.
|
|
*
|
|
* The primary documented way for extensions to describe precedence is by
|
|
* specifying rule.priority. Between same-priority rules, their precedence is
|
|
* dependent on the rule action. The ruleset/rule ID precedence is only used to
|
|
* have a defined ordering if multiple rules have the same priority+action.
|
|
*
|
|
* Rule actions have the following order of precedence and meaning:
|
|
* - "allow" can be used to ignore other same-or-lower-priority rules.
|
|
* - "allowAllRequests" (for main_frame / sub_frame resourceTypes only) has the
|
|
* same effect as allow, but also applies to (future) subresource loads in
|
|
* the document (including descendant frames) generated from the request.
|
|
* - "block" cancels the matched request.
|
|
* - "upgradeScheme" upgrades the scheme of the request.
|
|
* - "redirect" redirects the request.
|
|
* - "modifyHeaders" rewrites request/response headers.
|
|
*
|
|
* The matched rules are evaluated in two passes:
|
|
* 1. findMatchingRules():
|
|
* Find the highest-priority rule(s), and choose the action with the highest
|
|
* precedence (across all rulesets, any action except modifyHeaders).
|
|
* This also accounts for any allowAllRequests from an ancestor frame.
|
|
*
|
|
* 2. getMatchingModifyHeadersRules():
|
|
* Find matching rules with the "modifyHeaders" action, minus ignored rules.
|
|
* Reaching this step implies that the request was not canceled, so either
|
|
* the first step did not yield a rule, or the rule action is "allow" or
|
|
* "allowAllRequests" (i.e. ignore same-or-lower-priority rules).
|
|
*
|
|
* If an extension does not have sufficient permissions for the action, the
|
|
* resulting action is ignored.
|
|
*
|
|
* The above describes the evaluation within one extension. When a sequence of
|
|
* (multiple) extensions is given, they may return conflicting actions in the
|
|
* first pass. This is resolved by choosing the action with the following order
|
|
* of precedence, in RequestEvaluator.evaluateRequest():
|
|
* - block
|
|
* - redirect / upgradeScheme
|
|
* - allow / allowAllRequests
|
|
*/
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineModuleGetter(
|
|
lazy,
|
|
"WebRequest",
|
|
"resource://gre/modules/WebRequest.jsm"
|
|
);
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
|
|
});
|
|
|
|
/**
|
|
* The minimum number of static rules guaranteed to an extension across its
|
|
* enabled static rulesets. Any rules above this limit will count towards the
|
|
* global static rule limit.
|
|
*/
|
|
const GUARANTEED_MINIMUM_STATIC_RULES = 30000;
|
|
|
|
/**
|
|
* The maximum number of static Rulesets an extension can specify as part of
|
|
* the "rule_resources" manifest key.
|
|
*
|
|
* NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318
|
|
*/
|
|
const MAX_NUMBER_OF_STATIC_RULESETS = 50;
|
|
|
|
/**
|
|
* The maximum number of static Rulesets an extension can enable at any one time.
|
|
*
|
|
* NOTE: this limit may be increased in the future, see https://github.com/w3c/webextensions/issues/318
|
|
*/
|
|
const MAX_NUMBER_OF_ENABLED_STATIC_RULESETS = 10;
|
|
|
|
// TODO(Bug 1803370): allow extension to exceed the GUARANTEED_MINIMUM_STATIC_RULES limit.
|
|
//
|
|
// The maximum number of static rules exceeding the per-extension
|
|
// GUARANTEED_MINIMUM_STATIC_RULES across every extensions.
|
|
//
|
|
// const MAX_GLOBAL_NUMBER_OF_STATIC_RULES = 300000;
|
|
|
|
// As documented above:
|
|
// Ruleset precedence: session > dynamic > static (order from manifest.json).
|
|
const PRECEDENCE_SESSION_RULESET = 1;
|
|
const PRECEDENCE_DYNAMIC_RULESET = 2;
|
|
const PRECEDENCE_STATIC_RULESETS_BASE = 3;
|
|
|
|
// The RuleCondition class represents a rule's "condition" type as described in
|
|
// schemas/declarative_net_request.json. This class exists to allow the JS
|
|
// engine to use one Shape for all Rule instances.
|
|
class RuleCondition {
|
|
#compiledUrlFilter;
|
|
|
|
constructor(cond) {
|
|
this.urlFilter = cond.urlFilter;
|
|
this.regexFilter = cond.regexFilter;
|
|
this.isUrlFilterCaseSensitive = cond.isUrlFilterCaseSensitive;
|
|
this.initiatorDomains = cond.initiatorDomains;
|
|
this.excludedInitiatorDomains = cond.excludedInitiatorDomains;
|
|
this.requestDomains = cond.requestDomains;
|
|
this.excludedRequestDomains = cond.excludedRequestDomains;
|
|
this.resourceTypes = cond.resourceTypes;
|
|
this.excludedResourceTypes = cond.excludedResourceTypes;
|
|
this.requestMethods = cond.requestMethods;
|
|
this.excludedRequestMethods = cond.excludedRequestMethods;
|
|
this.domainType = cond.domainType;
|
|
this.tabIds = cond.tabIds;
|
|
this.excludedTabIds = cond.excludedTabIds;
|
|
}
|
|
|
|
// See CompiledUrlFilter for documentation.
|
|
urlFilterMatches(requestDataForUrlFilter) {
|
|
if (!this.#compiledUrlFilter) {
|
|
// eslint-disable-next-line no-use-before-define
|
|
this.#compiledUrlFilter = new CompiledUrlFilter(
|
|
this.urlFilter,
|
|
this.isUrlFilterCaseSensitive
|
|
);
|
|
}
|
|
return this.#compiledUrlFilter.matchesRequest(requestDataForUrlFilter);
|
|
}
|
|
|
|
getCompiledUrlFilter() {
|
|
return this.#compiledUrlFilter;
|
|
}
|
|
setCompiledUrlFilter(compiledUrlFilter) {
|
|
this.#compiledUrlFilter = compiledUrlFilter;
|
|
}
|
|
}
|
|
|
|
class Rule {
|
|
constructor(rule) {
|
|
this.id = rule.id;
|
|
this.priority = rule.priority;
|
|
this.condition = new RuleCondition(rule.condition);
|
|
this.action = rule.action;
|
|
}
|
|
|
|
// The precedence of rules within an extension. This method is frequently
|
|
// used during the first pass of the RequestEvaluator.
|
|
actionPrecedence() {
|
|
switch (this.action.type) {
|
|
case "allow":
|
|
return 1; // Highest precedence.
|
|
case "allowAllRequests":
|
|
return 2;
|
|
case "block":
|
|
return 3;
|
|
case "upgradeScheme":
|
|
return 4;
|
|
case "redirect":
|
|
return 5;
|
|
case "modifyHeaders":
|
|
return 6;
|
|
default:
|
|
throw new Error(`Unexpected action type: ${this.action.type}`);
|
|
}
|
|
}
|
|
|
|
isAllowOrAllowAllRequestsAction() {
|
|
const type = this.action.type;
|
|
return type === "allow" || type === "allowAllRequests";
|
|
}
|
|
}
|
|
|
|
class Ruleset {
|
|
/**
|
|
* @param {string} rulesetId - extension-defined ruleset ID.
|
|
* @param {integer} rulesetPrecedence
|
|
* @param {Rule[]} rules - extension-defined rules
|
|
* @param {RuleManager} ruleManager - owner of this ruleset.
|
|
*/
|
|
constructor(rulesetId, rulesetPrecedence, rules, ruleManager) {
|
|
this.id = rulesetId;
|
|
this.rulesetPrecedence = rulesetPrecedence;
|
|
this.rules = rules;
|
|
// For use by MatchedRule.
|
|
this.ruleManager = ruleManager;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} uriQuery - The query of a nsIURI to transform.
|
|
* @param {object} queryTransform - The value of the
|
|
* Rule.action.redirect.transform.queryTransform property as defined in
|
|
* declarative_net_request.json.
|
|
* @returns {string} The uriQuery with the queryTransform applied to it.
|
|
*/
|
|
function applyQueryTransform(uriQuery, queryTransform) {
|
|
// URLSearchParams cannot be applied to the full query string, because that
|
|
// API formats the full query string using form-urlencoding. But the input
|
|
// may be in a different format. So we try to only modify matched params.
|
|
|
|
function urlencode(s) {
|
|
// Encode in application/x-www-form-urlencoded format.
|
|
// The only JS API to do that is URLSearchParams. encodeURIComponent is not
|
|
// the same, it differs in how it handles " " ("%20") and "!'()~" (raw).
|
|
// But urlencoded space should be "+" and the latter be "%21%27%28%29%7E".
|
|
return new URLSearchParams({ s }).toString().slice(2);
|
|
}
|
|
if (!uriQuery.length && !queryTransform.addOrReplaceParams) {
|
|
// Nothing to do.
|
|
return "";
|
|
}
|
|
const removeParamsSet = new Set(queryTransform.removeParams?.map(urlencode));
|
|
const addParams = (queryTransform.addOrReplaceParams || []).map(orig => ({
|
|
normalizedKey: urlencode(orig.key),
|
|
orig,
|
|
}));
|
|
const finalParams = [];
|
|
if (uriQuery.length) {
|
|
for (let part of uriQuery.split("&")) {
|
|
let key = part.split("=", 1)[0];
|
|
if (removeParamsSet.has(key)) {
|
|
continue;
|
|
}
|
|
let i = addParams.findIndex(p => p.normalizedKey === key);
|
|
if (i !== -1) {
|
|
// Replace found param with the key-value from addOrReplaceParams.
|
|
finalParams.push(`${key}=${urlencode(addParams[i].orig.value)}`);
|
|
// Omit param so that a future search for the same key can find the next
|
|
// specified key-value pair, if any. And to prevent the already-used
|
|
// key-value pairs from being appended after the loop.
|
|
addParams.splice(i, 1);
|
|
} else {
|
|
finalParams.push(part);
|
|
}
|
|
}
|
|
}
|
|
// Append remaining, unused key-value pairs.
|
|
for (let { normalizedKey, orig } of addParams) {
|
|
if (!orig.replaceOnly) {
|
|
finalParams.push(`${normalizedKey}=${urlencode(orig.value)}`);
|
|
}
|
|
}
|
|
return finalParams.length ? `?${finalParams.join("&")}` : "";
|
|
}
|
|
|
|
/**
|
|
* @param {nsIURI} uri - Usually a http(s) URL.
|
|
* @param {object} transform - The value of the Rule.action.redirect.transform
|
|
* property as defined in declarative_net_request.json.
|
|
* @returns {nsIURI} uri - The new URL.
|
|
* @throws if the transformation is invalid.
|
|
*/
|
|
function applyURLTransform(uri, transform) {
|
|
let mut = uri.mutate();
|
|
if (transform.scheme) {
|
|
// Note: declarative_net_request.json only allows http(s)/moz-extension:.
|
|
mut.setScheme(transform.scheme);
|
|
if (uri.port !== -1 || transform.port) {
|
|
// If the URI contains a port or transform.port was specified, the default
|
|
// port is significant. So we must set it in that case.
|
|
if (transform.scheme === "https") {
|
|
mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(443);
|
|
} else if (transform.scheme === "http") {
|
|
mut.QueryInterface(Ci.nsIStandardURLMutator).setDefaultPort(80);
|
|
}
|
|
}
|
|
}
|
|
if (transform.username != null) {
|
|
mut.setUsername(transform.username);
|
|
}
|
|
if (transform.password != null) {
|
|
mut.setPassword(transform.password);
|
|
}
|
|
if (transform.host != null) {
|
|
mut.setHost(transform.host);
|
|
}
|
|
if (transform.port != null) {
|
|
// The caller ensures that transform.port is a string consisting of digits
|
|
// only. When it is an empty string, it should be cleared (-1).
|
|
mut.setPort(transform.port || -1);
|
|
}
|
|
if (transform.path != null) {
|
|
mut.setFilePath(transform.path);
|
|
}
|
|
if (transform.query != null) {
|
|
mut.setQuery(transform.query);
|
|
} else if (transform.queryTransform) {
|
|
mut.setQuery(applyQueryTransform(uri.query, transform.queryTransform));
|
|
}
|
|
if (transform.fragment != null) {
|
|
mut.setRef(transform.fragment);
|
|
}
|
|
return mut.finalize();
|
|
}
|
|
|
|
/**
|
|
* An urlFilter is a string pattern to match a canonical http(s) URL.
|
|
* urlFilter matches anywhere in the string, unless an anchor is present:
|
|
* - ||... ("Domain name anchor") - domain or subdomain starts with ...
|
|
* - |... ("Left anchor") - URL starts with ...
|
|
* - ...| ("Right anchor") - URL ends with ...
|
|
*
|
|
* Other than the anchors, the following special characters exist:
|
|
* - ^ = end of URL, or any char except: alphanum _ - . % ("Separator")
|
|
* - * = any number of characters ("Wildcard")
|
|
*
|
|
* Ambiguous cases (undocumented but actual Chrome behavior):
|
|
* - Plain "||" is a domain name anchor, not left + empty + right anchor.
|
|
* - "^" repeated at end of pattern: "^" matches end of URL only once.
|
|
* - "^|" at end of pattern: "^" is allowed to match end of URL.
|
|
*
|
|
* Implementation details:
|
|
* - CompiledUrlFilter's constructor (+#initializeUrlFilter) extracts the
|
|
* actual urlFilter and anchors, for matching against URLs later.
|
|
* - RequestDataForUrlFilter class precomputes the URL / domain anchors to
|
|
* support matching more efficiently.
|
|
* - CompiledUrlFilter's matchesRequest(request) checks whether the request is
|
|
* actually matched, using the precomputed information.
|
|
*
|
|
* The class was designed to minimize the number of string allocations during
|
|
* request evaluation, because the matchesRequest method may be called very
|
|
* often for every network request.
|
|
*/
|
|
class CompiledUrlFilter {
|
|
#isUrlFilterCaseSensitive;
|
|
#urlFilterParts; // = parts of urlFilter, minus anchors, split at "*".
|
|
// isAnchorLeft and isAnchorDomain are mutually exclusive.
|
|
#isAnchorLeft = false;
|
|
#isAnchorDomain = false;
|
|
#isAnchorRight = false;
|
|
#isTrailingSeparator = false; // Whether urlFilter ends with "^".
|
|
|
|
/**
|
|
* @param {string} urlFilter - non-empty urlFilter
|
|
* @param {boolean} [isUrlFilterCaseSensitive]
|
|
*/
|
|
constructor(urlFilter, isUrlFilterCaseSensitive) {
|
|
this.#isUrlFilterCaseSensitive = isUrlFilterCaseSensitive;
|
|
this.#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive);
|
|
}
|
|
|
|
#initializeUrlFilter(urlFilter, isUrlFilterCaseSensitive) {
|
|
let start = 0;
|
|
let end = urlFilter.length;
|
|
|
|
// First, trim the anchors off urlFilter.
|
|
if (urlFilter[0] === "|") {
|
|
if (urlFilter[1] === "|") {
|
|
start = 2;
|
|
this.#isAnchorDomain = true;
|
|
// ^ will not revert to false below, because "||*" is already rejected
|
|
// by RuleValidator's #checkCondUrlFilterAndRegexFilter method.
|
|
} else {
|
|
start = 1;
|
|
this.#isAnchorLeft = true; // may revert to false below.
|
|
}
|
|
}
|
|
if (end > start && urlFilter[end - 1] === "|") {
|
|
--end;
|
|
this.#isAnchorRight = true; // may revert to false below.
|
|
}
|
|
|
|
// Skip unnecessary wildcards, and adjust meaningless anchors accordingly:
|
|
// "|*" and "*|" are not effective anchors, they could have been omitted.
|
|
while (start < end && urlFilter[start] === "*") {
|
|
++start;
|
|
this.#isAnchorLeft = false;
|
|
}
|
|
while (end > start && urlFilter[end - 1] === "*") {
|
|
--end;
|
|
this.#isAnchorRight = false;
|
|
}
|
|
|
|
// Special-case the last "^", so that the matching algorithm can rely on
|
|
// the simple assumption that a "^" in the filter matches exactly one char:
|
|
// The "^" at the end of the pattern is specified to match either one char
|
|
// as usual, or as an anchor for the end of the URL (i.e. zero characters).
|
|
this.#isTrailingSeparator = urlFilter[end - 1] === "^";
|
|
|
|
let urlFilterWithoutAnchors = urlFilter.slice(start, end);
|
|
if (!isUrlFilterCaseSensitive) {
|
|
urlFilterWithoutAnchors = urlFilterWithoutAnchors.toLowerCase();
|
|
}
|
|
this.#urlFilterParts = urlFilterWithoutAnchors.split("*");
|
|
}
|
|
|
|
/**
|
|
* Tests whether |request| matches the urlFilter.
|
|
*
|
|
* @param {RequestDataForUrlFilter} requestDataForUrlFilter
|
|
* @returns {boolean} Whether the condition matches the URL.
|
|
*/
|
|
matchesRequest(requestDataForUrlFilter) {
|
|
const url = requestDataForUrlFilter.getUrl(this.#isUrlFilterCaseSensitive);
|
|
const domainAnchors = requestDataForUrlFilter.domainAnchors;
|
|
|
|
const urlFilterParts = this.#urlFilterParts;
|
|
|
|
const REAL_END_OF_URL = url.length - 1; // minus trailing "^"
|
|
|
|
// atUrlIndex is the position after the most recently matched part.
|
|
// If a match is not found, it is -1 and we should return false.
|
|
let atUrlIndex = 0;
|
|
|
|
// The head always exists, potentially even an empty string.
|
|
const head = urlFilterParts[0];
|
|
if (this.#isAnchorLeft) {
|
|
if (!this.#startsWithPart(head, url, 0)) {
|
|
return false;
|
|
}
|
|
atUrlIndex = head.length;
|
|
} else if (this.#isAnchorDomain) {
|
|
atUrlIndex = this.#indexAfterDomainPart(head, url, domainAnchors);
|
|
} else {
|
|
atUrlIndex = this.#indexAfterPart(head, url, 0);
|
|
}
|
|
|
|
let previouslyAtUrlIndex = 0;
|
|
for (let i = 1; i < urlFilterParts.length && atUrlIndex !== -1; ++i) {
|
|
previouslyAtUrlIndex = atUrlIndex;
|
|
atUrlIndex = this.#indexAfterPart(urlFilterParts[i], url, atUrlIndex);
|
|
}
|
|
if (atUrlIndex === -1) {
|
|
return false;
|
|
}
|
|
if (atUrlIndex === url.length) {
|
|
// We always append a "^" to the URL, so if the match is at the end of the
|
|
// URL (REAL_END_OF_URL), only accept if the pattern ended with a "^".
|
|
return this.#isTrailingSeparator;
|
|
}
|
|
if (!this.#isAnchorRight || atUrlIndex === REAL_END_OF_URL) {
|
|
// Either not interested in the end, or already at the end of the URL.
|
|
return true;
|
|
}
|
|
|
|
// #isAnchorRight is true but we are not at the end of the URL.
|
|
// Backtrack once, to retry the last pattern (tail) with the end of the URL.
|
|
|
|
const tail = urlFilterParts[urlFilterParts.length - 1];
|
|
// The expected offset where the tail should be located.
|
|
const expectedTailIndex = REAL_END_OF_URL - tail.length;
|
|
// If #isTrailingSeparator is true, then accept the URL's trailing "^".
|
|
const expectedTailIndexPlus1 = expectedTailIndex + 1;
|
|
if (urlFilterParts.length === 1) {
|
|
if (this.#isAnchorLeft) {
|
|
// If matched, we would have returned at the REAL_END_OF_URL checks.
|
|
return false;
|
|
}
|
|
if (this.#isAnchorDomain) {
|
|
// The tail must be exactly at one of the domain anchors.
|
|
return (
|
|
(domainAnchors.includes(expectedTailIndex) &&
|
|
this.#startsWithPart(tail, url, expectedTailIndex)) ||
|
|
(this.#isTrailingSeparator &&
|
|
domainAnchors.includes(expectedTailIndexPlus1) &&
|
|
this.#startsWithPart(tail, url, expectedTailIndexPlus1))
|
|
);
|
|
}
|
|
// head has no left/domain anchor, fall through.
|
|
}
|
|
// The tail is not left/domain anchored, accept it as long as it did not
|
|
// overlap with an already-matched part of the URL.
|
|
return (
|
|
(expectedTailIndex > previouslyAtUrlIndex &&
|
|
this.#startsWithPart(tail, url, expectedTailIndex)) ||
|
|
(this.#isTrailingSeparator &&
|
|
expectedTailIndexPlus1 > previouslyAtUrlIndex &&
|
|
this.#startsWithPart(tail, url, expectedTailIndexPlus1))
|
|
);
|
|
}
|
|
|
|
// Whether a character should match "^" in an urlFilter.
|
|
// The "match end of URL" meaning of "^" is covered by #isTrailingSeparator.
|
|
static #regexIsSep = /[^A-Za-z0-9_\-.%]/;
|
|
|
|
#matchPartAt(part, url, urlIndex, sepStart) {
|
|
if (sepStart === -1) {
|
|
// Fast path.
|
|
return url.startsWith(part, urlIndex);
|
|
}
|
|
if (urlIndex + part.length > url.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < part.length; ++i) {
|
|
let partChar = part[i];
|
|
let urlChar = url[urlIndex + i];
|
|
if (
|
|
partChar !== urlChar &&
|
|
(partChar !== "^" || !CompiledUrlFilter.#regexIsSep.test(urlChar))
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#startsWithPart(part, url, urlIndex) {
|
|
const sepStart = part.indexOf("^");
|
|
return this.#matchPartAt(part, url, urlIndex, sepStart);
|
|
}
|
|
|
|
#indexAfterPart(part, url, urlIndex) {
|
|
let sepStart = part.indexOf("^");
|
|
if (sepStart === -1) {
|
|
// Fast path.
|
|
let i = url.indexOf(part, urlIndex);
|
|
return i === -1 ? i : i + part.length;
|
|
}
|
|
let maxUrlIndex = url.length - part.length;
|
|
for (let i = urlIndex; i <= maxUrlIndex; ++i) {
|
|
if (this.#matchPartAt(part, url, i, sepStart)) {
|
|
return i + part.length;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
#indexAfterDomainPart(part, url, domainAnchors) {
|
|
const sepStart = part.indexOf("^");
|
|
for (let offset of domainAnchors) {
|
|
if (this.#matchPartAt(part, url, offset, sepStart)) {
|
|
return offset + part.length;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// See CompiledUrlFilter for documentation of RequestDataForUrlFilter.
|
|
class RequestDataForUrlFilter {
|
|
/**
|
|
* @param {nsIURI} requestURI - The URL to match against.
|
|
* @returns {object} An object to p
|
|
*/
|
|
constructor(requestURI) {
|
|
// "^" is appended, see CompiledUrlFilter's #initializeUrlFilter.
|
|
this.urlAnyCase = requestURI.spec + "^";
|
|
this.urlLowerCase = this.urlAnyCase.toLowerCase();
|
|
// For "||..." (Domain name anchor): where (sub)domains start in the URL.
|
|
this.domainAnchors = this.#getDomainAnchors(this.urlAnyCase);
|
|
}
|
|
|
|
getUrl(isUrlFilterCaseSensitive) {
|
|
return isUrlFilterCaseSensitive ? this.urlAnyCase : this.urlLowerCase;
|
|
}
|
|
|
|
#getDomainAnchors(url) {
|
|
let hostStart = url.indexOf("://") + 3;
|
|
let hostEnd = url.indexOf("/", hostStart);
|
|
let userpassEnd = url.lastIndexOf("@", hostEnd) + 1;
|
|
if (userpassEnd) {
|
|
hostStart = userpassEnd;
|
|
}
|
|
let host = url.slice(hostStart, hostEnd);
|
|
let domainAnchors = [hostStart];
|
|
let offset = 0;
|
|
// Find all offsets after ".". If not found, -1 + 1 = 0, and the loop ends.
|
|
while ((offset = host.indexOf(".", offset) + 1)) {
|
|
domainAnchors.push(hostStart + offset);
|
|
}
|
|
return domainAnchors;
|
|
}
|
|
}
|
|
|
|
class ModifyHeadersBase {
|
|
// Map<string,MatchedRule> - The first MatchedRule that modified the header.
|
|
// After modifying a header, it cannot be modified further, with the exception
|
|
// of the "append" operation, provided that they are from the same extension.
|
|
#alreadyModifiedMap = new Map();
|
|
// Set<string> - The list of headers allowed to be modified with "append",
|
|
// despite having been modified. Allowed for "set"/"append", not for "remove".
|
|
#appendStillAllowed = new Set();
|
|
|
|
/**
|
|
* @param {ChannelWrapper} channel
|
|
*/
|
|
constructor(channel) {
|
|
this.channel = channel;
|
|
}
|
|
|
|
applyModifyHeaders(matchedRules) {
|
|
for (const matchedRule of matchedRules) {
|
|
for (const headerAction of this.headerActionsFor(matchedRule)) {
|
|
const { header: name, operation, value } = headerAction;
|
|
if (!this.#isOperationAllowed(name, operation, matchedRule)) {
|
|
continue;
|
|
}
|
|
let ok;
|
|
switch (operation) {
|
|
case "set":
|
|
ok = this.setHeader(matchedRule, name, value, /* merge */ false);
|
|
if (ok) {
|
|
this.#appendStillAllowed.add(name);
|
|
}
|
|
break;
|
|
case "append":
|
|
ok = this.setHeader(matchedRule, name, value, /* merge */ true);
|
|
if (ok) {
|
|
this.#appendStillAllowed.add(name);
|
|
}
|
|
break;
|
|
case "remove":
|
|
ok = this.setHeader(matchedRule, name, "", /* merge */ false);
|
|
// Note: removal is final, so we don't add to #appendStillAllowed.
|
|
break;
|
|
}
|
|
if (ok) {
|
|
this.#alreadyModifiedMap.set(name, matchedRule);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#isOperationAllowed(name, operation, matchedRule) {
|
|
const modifiedBy = this.#alreadyModifiedMap.get(name);
|
|
if (!modifiedBy) {
|
|
return true;
|
|
}
|
|
if (
|
|
operation === "append" &&
|
|
this.#appendStillAllowed.has(name) &&
|
|
matchedRule.ruleManager === modifiedBy.ruleManager
|
|
) {
|
|
return true;
|
|
}
|
|
// TODO bug 1803369: dev experience improvement: consider logging when
|
|
// a header modification was rejected.
|
|
return false;
|
|
}
|
|
|
|
setHeader(matchedRule, name, value, merge) {
|
|
try {
|
|
this.setHeaderImpl(matchedRule, name, value, merge);
|
|
return true;
|
|
} catch (e) {
|
|
const extension = matchedRule.ruleManager.extension;
|
|
extension.logger.error(
|
|
`Failed to apply modifyHeaders action to header "${name}" (DNR rule id ${matchedRule.rule.id} from ruleset "${matchedRule.ruleset.id}"): ${e}`
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// kName should already be in lower case.
|
|
isHeaderNameEqual(name, kName) {
|
|
return name.length === kName.length && name.toLowerCase() === kName;
|
|
}
|
|
}
|
|
|
|
class ModifyRequestHeaders extends ModifyHeadersBase {
|
|
static maybeApplyModifyHeaders(channel, matchedRules) {
|
|
matchedRules = matchedRules.filter(mr => {
|
|
const action = mr.rule.action;
|
|
return action.type === "modifyHeaders" && action.requestHeaders?.length;
|
|
});
|
|
if (matchedRules.length) {
|
|
new ModifyRequestHeaders(channel).applyModifyHeaders(matchedRules);
|
|
}
|
|
}
|
|
|
|
headerActionsFor(matchedRule) {
|
|
return matchedRule.rule.action.requestHeaders;
|
|
}
|
|
|
|
setHeaderImpl(matchedRule, name, value, merge) {
|
|
if (this.isHeaderNameEqual(name, "host")) {
|
|
this.#checkHostHeader(matchedRule, value);
|
|
}
|
|
if (merge && value && this.isHeaderNameEqual(name, "cookie")) {
|
|
// By default, headers are merged with ",". But Cookie should use "; ".
|
|
// HTTP/1.1 allowed only one Cookie header, but HTTP/2.0 allows multiple,
|
|
// but recommends concatenation on one line. Relevant RFCs:
|
|
// - https://www.rfc-editor.org/rfc/rfc6265#section-5.4
|
|
// - https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5
|
|
// Consistent with Firefox internals, we ensure that there is at most one
|
|
// Cookie header, by overwriting the previous one, if any.
|
|
let existingCookie = this.channel.getRequestHeader("cookie");
|
|
if (existingCookie) {
|
|
value = existingCookie + "; " + value;
|
|
merge = false;
|
|
}
|
|
}
|
|
this.channel.setRequestHeader(name, value, merge);
|
|
}
|
|
|
|
#checkHostHeader(matchedRule, value) {
|
|
let uri = Services.io.newURI(`https://${value}/`);
|
|
let { policy } = matchedRule.ruleManager.extension;
|
|
|
|
if (!policy.allowedOrigins.matches(uri)) {
|
|
throw new Error(
|
|
`Unable to set host header, url missing from permissions.`
|
|
);
|
|
}
|
|
|
|
if (WebExtensionPolicy.isRestrictedURI(uri)) {
|
|
throw new Error(`Unable to set host header to restricted url.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
class ModifyResponseHeaders extends ModifyHeadersBase {
|
|
static maybeApplyModifyHeaders(channel, matchedRules) {
|
|
matchedRules = matchedRules.filter(mr => {
|
|
const action = mr.rule.action;
|
|
return action.type === "modifyHeaders" && action.responseHeaders?.length;
|
|
});
|
|
if (matchedRules.length) {
|
|
new ModifyResponseHeaders(channel).applyModifyHeaders(matchedRules);
|
|
}
|
|
}
|
|
|
|
headerActionsFor(matchedRule) {
|
|
return matchedRule.rule.action.responseHeaders;
|
|
}
|
|
|
|
setHeaderImpl(matchedRule, name, value, merge) {
|
|
this.channel.setResponseHeader(name, value, merge);
|
|
}
|
|
}
|
|
|
|
class RuleValidator {
|
|
constructor(alreadyValidatedRules, { isSessionRuleset = false } = {}) {
|
|
this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r]));
|
|
this.failures = [];
|
|
this.isSessionRuleset = isSessionRuleset;
|
|
}
|
|
|
|
removeRuleIds(ruleIds) {
|
|
for (const ruleId of ruleIds) {
|
|
this.rulesMap.delete(ruleId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {object[]} rules - A list of objects that adhere to the Rule type
|
|
* from declarative_net_request.json.
|
|
*/
|
|
addRules(rules) {
|
|
for (const rule of rules) {
|
|
if (this.rulesMap.has(rule.id)) {
|
|
this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`);
|
|
continue;
|
|
}
|
|
// declarative_net_request.json defines basic types, such as the expected
|
|
// object properties and (primitive) type. Trivial constraints such as
|
|
// minimum array lengths are also expressed in the schema.
|
|
// Anything more complex is validated here. In particular, constraints
|
|
// involving multiple properties (e.g. mutual exclusiveness).
|
|
//
|
|
// The following conditions have already been validated by the schema:
|
|
// - isUrlFilterCaseSensitive (boolean)
|
|
// - domainType (enum string)
|
|
// - initiatorDomains & excludedInitiatorDomains & requestDomains &
|
|
// excludedRequestDomains (array of string in canonicalDomain format)
|
|
if (
|
|
!this.#checkCondResourceTypes(rule) ||
|
|
!this.#checkCondRequestMethods(rule) ||
|
|
!this.#checkCondTabIds(rule) ||
|
|
!this.#checkCondUrlFilterAndRegexFilter(rule) ||
|
|
!this.#checkAction(rule)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const newRule = new Rule(rule);
|
|
|
|
this.rulesMap.set(rule.id, newRule);
|
|
}
|
|
}
|
|
|
|
// Checks: resourceTypes & excludedResourceTypes
|
|
#checkCondResourceTypes(rule) {
|
|
const { resourceTypes, excludedResourceTypes } = rule.condition;
|
|
if (this.#hasOverlap(resourceTypes, excludedResourceTypes)) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"resourceTypes and excludedResourceTypes should not overlap"
|
|
);
|
|
return false;
|
|
}
|
|
if (rule.action.type === "allowAllRequests") {
|
|
if (!resourceTypes) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"An allowAllRequests rule must have a non-empty resourceTypes array"
|
|
);
|
|
return false;
|
|
}
|
|
if (resourceTypes.some(r => r !== "main_frame" && r !== "sub_frame")) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes"
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Checks: requestMethods & excludedRequestMethods
|
|
#checkCondRequestMethods(rule) {
|
|
const { requestMethods, excludedRequestMethods } = rule.condition;
|
|
if (this.#hasOverlap(requestMethods, excludedRequestMethods)) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"requestMethods and excludedRequestMethods should not overlap"
|
|
);
|
|
return false;
|
|
}
|
|
const isInvalidRequestMethod = method => method.toLowerCase() !== method;
|
|
if (
|
|
requestMethods?.some(isInvalidRequestMethod) ||
|
|
excludedRequestMethods?.some(isInvalidRequestMethod)
|
|
) {
|
|
this.#collectInvalidRule(rule, "request methods must be in lower case");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Checks: tabIds & excludedTabIds
|
|
#checkCondTabIds(rule) {
|
|
const { tabIds, excludedTabIds } = rule.condition;
|
|
|
|
if ((tabIds || excludedTabIds) && !this.isSessionRuleset) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"tabIds and excludedTabIds can only be specified in session rules"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (this.#hasOverlap(tabIds, excludedTabIds)) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"tabIds and excludedTabIds should not overlap"
|
|
);
|
|
return false;
|
|
}
|
|
// TODO bug 1745764 / bug 1745763: after adding support for dynamic/static
|
|
// rules, validate that we only have a session ruleset here.
|
|
return true;
|
|
}
|
|
|
|
static #regexNonASCII = /[^\x00-\x7F]/; // eslint-disable-line no-control-regex
|
|
|
|
// Checks: urlFilter & regexFilter
|
|
#checkCondUrlFilterAndRegexFilter(rule) {
|
|
const { urlFilter, regexFilter } = rule.condition;
|
|
const checkEmptyOrNonASCII = (str, prop) => {
|
|
if (!str) {
|
|
this.#collectInvalidRule(rule, `${prop} should not be an empty string`);
|
|
return false;
|
|
}
|
|
// Non-ASCII in URLs are always encoded in % (or punycode in domains).
|
|
if (RuleValidator.#regexNonASCII.test(str)) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
`${prop} should not contain non-ASCII characters`
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
if (urlFilter != null) {
|
|
if (regexFilter != null) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"urlFilter and regexFilter are mutually exclusive"
|
|
);
|
|
return false;
|
|
}
|
|
if (!checkEmptyOrNonASCII(urlFilter, "urlFilter")) {
|
|
// #collectInvalidRule already called by checkEmptyOrNonASCII.
|
|
return false;
|
|
}
|
|
if (urlFilter.startsWith("||*")) {
|
|
// Rejected because Chrome does too. '||*' is equivalent to '*'.
|
|
this.#collectInvalidRule(rule, "urlFilter should not start with '||*'");
|
|
return false;
|
|
}
|
|
} else if (regexFilter != null) {
|
|
if (!checkEmptyOrNonASCII(regexFilter, "regexFilter")) {
|
|
// #collectInvalidRule already called by checkEmptyOrNonASCII.
|
|
return false;
|
|
}
|
|
// TODO bug 1745760: accept when regexFilter is a valid regexp.
|
|
this.#collectInvalidRule(rule, "regexFilter is not supported yet");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#checkAction(rule) {
|
|
switch (rule.action.type) {
|
|
case "allow":
|
|
case "allowAllRequests":
|
|
case "block":
|
|
case "upgradeScheme":
|
|
// These actions have no extra properties.
|
|
break;
|
|
case "redirect":
|
|
return this.#checkActionRedirect(rule);
|
|
case "modifyHeaders":
|
|
return this.#checkActionModifyHeaders(rule);
|
|
default:
|
|
// Other values are not possible because declarative_net_request.json
|
|
// only accepts the above action types.
|
|
throw new Error(`Unexpected action type: ${rule.action.type}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#checkActionRedirect(rule) {
|
|
const { extensionPath, url, transform } = rule.action.redirect ?? {};
|
|
if (!url && extensionPath == null && !transform) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"A redirect rule must have a non-empty action.redirect object"
|
|
);
|
|
return false;
|
|
}
|
|
if (url && extensionPath != null) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.extensionPath and redirect.url are mutually exclusive"
|
|
);
|
|
return false;
|
|
}
|
|
if (extensionPath != null && !extensionPath.startsWith("/")) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.extensionPath should start with a '/'"
|
|
);
|
|
return false;
|
|
}
|
|
// If specified, the "url" property is described as "format": "url" in the
|
|
// JSON schema, which ensures that the URL is a canonical form, and that
|
|
// the extension is allowed to trigger a navigation to the URL.
|
|
// E.g. javascript: and privileged about:-URLs cannot be navigated to, but
|
|
// http(s) URLs can (regardless of extension permissions).
|
|
// data:-URLs are currently blocked due to bug 1622986.
|
|
|
|
if (transform) {
|
|
if (transform.query != null && transform.queryTransform) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.transform.query and redirect.transform.queryTransform are mutually exclusive"
|
|
);
|
|
return false;
|
|
}
|
|
// Most of the validation is done by nsIURIMutator via applyURLTransform.
|
|
// nsIURIMutator is not very strict, so we perform some extra checks here
|
|
// to reject values that are not technically valid URLs.
|
|
|
|
if (transform.port && /\D/.test(transform.port)) {
|
|
// nsIURIMutator's setPort takes an int, so any string will implicitly
|
|
// be converted to a number. This part verifies that the input only
|
|
// consists of digits. setPort will ensure that it is at most 65535.
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.transform.port should be empty or an integer"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
// Note: we don't verify whether transform.query starts with '/', because
|
|
// Chrome does not require it, and nsIURIMutator prepends it if missing.
|
|
|
|
if (transform.query && !transform.query.startsWith("?")) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.transform.query should be empty or start with a '?'"
|
|
);
|
|
return false;
|
|
}
|
|
if (transform.fragment && !transform.fragment.startsWith("#")) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.transform.fragment should be empty or start with a '#'"
|
|
);
|
|
return false;
|
|
}
|
|
try {
|
|
const dummyURI = Services.io.newURI("http://dummy");
|
|
// applyURLTransform uses nsIURIMutator to transform a URI, and throws
|
|
// if |transform| is invalid, e.g. invalid host, port, etc.
|
|
applyURLTransform(dummyURI, transform);
|
|
} catch (e) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"redirect.transform does not describe a valid URL transformation"
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// TODO bug 1745760: With regexFilter support, implement regexSubstitution.
|
|
return true;
|
|
}
|
|
|
|
#checkActionModifyHeaders(rule) {
|
|
const { requestHeaders, responseHeaders } = rule.action;
|
|
if (!requestHeaders && !responseHeaders) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const isValidModifyHeadersOp = ({ header, operation, value }) => {
|
|
if (!header) {
|
|
this.#collectInvalidRule(rule, "header must be non-empty");
|
|
return false;
|
|
}
|
|
if (!value && (operation === "append" || operation === "set")) {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"value is required for operations append/set"
|
|
);
|
|
return false;
|
|
}
|
|
if (value && operation === "remove") {
|
|
this.#collectInvalidRule(
|
|
rule,
|
|
"value must not be provided for operation remove"
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
if (
|
|
(requestHeaders && !requestHeaders.every(isValidModifyHeadersOp)) ||
|
|
(responseHeaders && !responseHeaders.every(isValidModifyHeadersOp))
|
|
) {
|
|
// #collectInvalidRule already called by isValidModifyHeadersOp.
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Conditions with a filter and an exclude-filter should reject overlapping
|
|
// lists, because they can never simultaneously be true.
|
|
#hasOverlap(arrayA, arrayB) {
|
|
return arrayA && arrayB && arrayA.some(v => arrayB.includes(v));
|
|
}
|
|
|
|
#collectInvalidRule(rule, message) {
|
|
this.failures.push({ rule, message });
|
|
}
|
|
|
|
getValidatedRules() {
|
|
return Array.from(this.rulesMap.values());
|
|
}
|
|
|
|
getFailures() {
|
|
return this.failures;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compares two rules to determine the relative order of precedence.
|
|
* Rules are only comparable if they are from the same extension!
|
|
*
|
|
* @param {Rule} ruleA
|
|
* @param {Rule} ruleB
|
|
* @param {Ruleset} rulesetA - the ruleset ruleA is part of.
|
|
* @param {Ruleset} rulesetB - the ruleset ruleB is part of.
|
|
* @returns {integer}
|
|
* 0 if equal.
|
|
* <0 if ruleA comes before ruleB.
|
|
* >0 if ruleA comes after ruleB.
|
|
*/
|
|
function compareRule(ruleA, ruleB, rulesetA, rulesetB) {
|
|
// Comparators: 0 if equal, >0 if a after b, <0 if a before b.
|
|
function cmpHighestNumber(a, b) {
|
|
return a === b ? 0 : b - a;
|
|
}
|
|
function cmpLowestNumber(a, b) {
|
|
return a === b ? 0 : a - b;
|
|
}
|
|
return (
|
|
// All compared operands are non-negative integers.
|
|
cmpHighestNumber(ruleA.priority, ruleB.priority) ||
|
|
cmpLowestNumber(ruleA.actionPrecedence(), ruleB.actionPrecedence()) ||
|
|
// As noted in the big comment at the top of the file, the following two
|
|
// comparisons only exist in order to have a stable ordering of rules. The
|
|
// specific comparison is somewhat arbitrary and matches Chrome's behavior.
|
|
// For context, see https://github.com/w3c/webextensions/issues/280
|
|
cmpLowestNumber(rulesetA.rulesetPrecedence, rulesetB.rulesetPrecedence) ||
|
|
cmpLowestNumber(ruleA.id, ruleB.id)
|
|
);
|
|
}
|
|
|
|
class MatchedRule {
|
|
constructor(rule, ruleset) {
|
|
this.rule = rule;
|
|
this.ruleset = ruleset;
|
|
}
|
|
|
|
// The RuleManager that generated this MatchedRule.
|
|
get ruleManager() {
|
|
return this.ruleset.ruleManager;
|
|
}
|
|
}
|
|
|
|
// tabId computation is currently not free, and depends on the initialization of
|
|
// ExtensionParent.apiManager.global (see WebRequest.getTabIdForChannelWrapper).
|
|
// Fortunately, DNR only supports tabIds in session rules, so by keeping track
|
|
// of session rules with tabIds/excludedTabIds conditions, we can find tabId
|
|
// exactly and only when necessary.
|
|
let gHasAnyTabIdConditions = false;
|
|
|
|
class RequestDetails {
|
|
/**
|
|
* @param {object} options
|
|
* @param {nsIURI} options.requestURI - URL of the requested resource.
|
|
* @param {nsIURI} [options.initiatorURI] - URL of triggering principal (non-null).
|
|
* @param {string} options.type - ResourceType (MozContentPolicyType).
|
|
* @param {string} [options.method] - HTTP method
|
|
* @param {integer} [options.tabId]
|
|
*/
|
|
constructor({ requestURI, initiatorURI, type, method, tabId }) {
|
|
this.requestURI = requestURI;
|
|
this.initiatorURI = initiatorURI;
|
|
this.type = type;
|
|
this.method = method;
|
|
this.tabId = tabId;
|
|
|
|
this.requestDomain = this.#domainFromURI(requestURI);
|
|
this.initiatorDomain = initiatorURI
|
|
? this.#domainFromURI(initiatorURI)
|
|
: null;
|
|
|
|
this.requestDataForUrlFilter = new RequestDataForUrlFilter(requestURI);
|
|
}
|
|
|
|
static fromChannelWrapper(channel) {
|
|
let tabId = -1;
|
|
if (gHasAnyTabIdConditions) {
|
|
tabId = lazy.WebRequest.getTabIdForChannelWrapper(channel);
|
|
}
|
|
return new RequestDetails({
|
|
requestURI: channel.finalURI,
|
|
// Note: originURI may be null, if missing or null principal, as desired.
|
|
initiatorURI: channel.originURI,
|
|
type: channel.type,
|
|
method: channel.method.toLowerCase(),
|
|
tabId,
|
|
});
|
|
}
|
|
|
|
canExtensionModify(extension) {
|
|
const policy = extension.policy;
|
|
return (
|
|
(!this.initiatorURI || policy.canAccessURI(this.initiatorURI)) &&
|
|
policy.canAccessURI(this.requestURI)
|
|
);
|
|
}
|
|
|
|
#domainFromURI(uri) {
|
|
let hostname = uri.host;
|
|
// nsIURI omits brackets from IPv6 addresses. But the canonical form of an
|
|
// IPv6 address is with brackets, so add them.
|
|
return hostname.includes(":") ? `[${hostname}]` : hostname;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This RequestEvaluator class's logic is documented at the top of this file.
|
|
*/
|
|
class RequestEvaluator {
|
|
// private constructor, only used by RequestEvaluator.evaluateRequest.
|
|
constructor(request, ruleManager) {
|
|
this.req = request;
|
|
this.ruleManager = ruleManager;
|
|
this.canModify = request.canExtensionModify(ruleManager.extension);
|
|
|
|
// These values are initialized by findMatchingRules():
|
|
this.matchedRule = null;
|
|
this.matchedModifyHeadersRules = [];
|
|
this.findMatchingRules();
|
|
}
|
|
|
|
/**
|
|
* Finds the matched rules for the given request and extensions,
|
|
* according to the logic documented at the top of this file.
|
|
*
|
|
* @param {RequestDetails} request
|
|
* @param {RuleManager[]} ruleManagers
|
|
* The list of RuleManagers, ordered by importance of its extension.
|
|
* @returns {MatchedRule[]}
|
|
*/
|
|
static evaluateRequest(request, ruleManagers) {
|
|
// Helper to determine precedence of rules from different extensions.
|
|
function precedence(matchedRule) {
|
|
switch (matchedRule.rule.action.type) {
|
|
case "block":
|
|
return 1;
|
|
case "redirect":
|
|
case "upgradeScheme":
|
|
return 2;
|
|
case "allow":
|
|
case "allowAllRequests":
|
|
return 3;
|
|
// case "modifyHeaders": not comparable after the first pass.
|
|
default:
|
|
throw new Error(`Unexpected action: ${matchedRule.rule.action.type}`);
|
|
}
|
|
}
|
|
|
|
let requestEvaluators = [];
|
|
let finalMatch;
|
|
let finalAllowAllRequestsMatches = [];
|
|
for (let ruleManager of ruleManagers) {
|
|
// Evaluate request with findMatchingRules():
|
|
const requestEvaluator = new RequestEvaluator(request, ruleManager);
|
|
// RequestEvaluator may be used after the loop when the request is
|
|
// accepted, to collect modifyHeaders/allow/allowAllRequests actions.
|
|
requestEvaluators.push(requestEvaluator);
|
|
let matchedRule = requestEvaluator.matchedRule;
|
|
if (matchedRule) {
|
|
if (matchedRule.rule.action.type === "allowAllRequests") {
|
|
// Even if a different extension wins the final match, an extension
|
|
// may want to record the "allowAllRequests" action for the future.
|
|
finalAllowAllRequestsMatches.push(matchedRule);
|
|
}
|
|
if (!finalMatch || precedence(matchedRule) < precedence(finalMatch)) {
|
|
finalMatch = matchedRule;
|
|
if (finalMatch.rule.action.type === "block") {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (finalMatch && !finalMatch.rule.isAllowOrAllowAllRequestsAction()) {
|
|
// Found block/redirect/upgradeScheme, request will be replaced.
|
|
return [finalMatch];
|
|
}
|
|
// Request not canceled, collect all modifyHeaders actions:
|
|
let matchedRules = requestEvaluators
|
|
.map(re => re.getMatchingModifyHeadersRules())
|
|
.flat(1);
|
|
|
|
// ... and collect the allowAllRequests actions:
|
|
if (finalAllowAllRequestsMatches.length) {
|
|
matchedRules = finalAllowAllRequestsMatches.concat(matchedRules);
|
|
}
|
|
|
|
// ... and collect the "allow" action. At this point, finalMatch could also
|
|
// be a modifyHeaders or allowAllRequests action, but these would already
|
|
// have been added to the matchedRules result before.
|
|
if (finalMatch && finalMatch.rule.action.type === "allow") {
|
|
matchedRules.unshift(finalMatch);
|
|
}
|
|
return matchedRules;
|
|
}
|
|
|
|
/**
|
|
* Finds the matching rules, as documented in the comment before the class.
|
|
*/
|
|
findMatchingRules() {
|
|
if (!this.canModify && !this.ruleManager.hasBlockPermission) {
|
|
// If the extension cannot apply any action, don't bother.
|
|
return;
|
|
}
|
|
|
|
this.#collectMatchInRuleset(this.ruleManager.sessionRules);
|
|
this.#collectMatchInRuleset(this.ruleManager.dynamicRules);
|
|
for (let ruleset of this.ruleManager.enabledStaticRules) {
|
|
this.#collectMatchInRuleset(ruleset);
|
|
}
|
|
|
|
if (this.matchedRule && !this.#isRuleActionAllowed(this.matchedRule.rule)) {
|
|
this.matchedRule = null;
|
|
// Note: this.matchedModifyHeadersRules is [] because canModify access is
|
|
// checked before populating the list.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves the list of matched modifyHeaders rules that should apply.
|
|
*
|
|
* @returns {MatchedRule[]}
|
|
*/
|
|
getMatchingModifyHeadersRules() {
|
|
// The minimum priority is 1. Defaulting to 0 = include all.
|
|
let priorityThreshold = 0;
|
|
if (this.matchedRule?.rule.isAllowOrAllowAllRequestsAction()) {
|
|
priorityThreshold = this.matchedRule.rule.priority;
|
|
}
|
|
// Note: the result cannot be non-empty if this.matchedRule is a non-allow
|
|
// action, because if that were to be the case, then the request would have
|
|
// been canceled, and therefore there would not be any header to modify.
|
|
// Even if another extension were to override the action, it could only be
|
|
// any other non-allow action, which would still cancel the request.
|
|
let matchedRules = this.matchedModifyHeadersRules.filter(matchedRule => {
|
|
return matchedRule.rule.priority > priorityThreshold;
|
|
});
|
|
// Sort output for a deterministic order.
|
|
// NOTE: Sorting rules at registration (in RuleManagers) would avoid the
|
|
// need to sort here. Since the number of matched modifyHeaders rules are
|
|
// expected to be small, we don't bother optimizing.
|
|
matchedRules.sort((a, b) => {
|
|
return compareRule(a.rule, b.rule, a.ruleset, b.ruleset);
|
|
});
|
|
return matchedRules;
|
|
}
|
|
|
|
#collectMatchInRuleset(ruleset) {
|
|
for (let rule of ruleset.rules) {
|
|
if (!this.#matchesRuleCondition(rule.condition)) {
|
|
continue;
|
|
}
|
|
if (rule.action.type === "modifyHeaders") {
|
|
if (this.canModify) {
|
|
this.matchedModifyHeadersRules.push(new MatchedRule(rule, ruleset));
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
this.matchedRule &&
|
|
compareRule(
|
|
this.matchedRule.rule,
|
|
rule,
|
|
this.matchedRule.ruleset,
|
|
ruleset
|
|
) <= 0
|
|
) {
|
|
continue;
|
|
}
|
|
this.matchedRule = new MatchedRule(rule, ruleset);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {RuleCondition} cond
|
|
* @returns {boolean} Whether the condition matched.
|
|
*/
|
|
#matchesRuleCondition(cond) {
|
|
if (cond.resourceTypes) {
|
|
if (!cond.resourceTypes.includes(this.req.type)) {
|
|
return false;
|
|
}
|
|
} else if (cond.excludedResourceTypes) {
|
|
if (cond.excludedResourceTypes.includes(this.req.type)) {
|
|
return false;
|
|
}
|
|
} else if (this.req.type === "main_frame") {
|
|
// When resourceTypes/excludedResourceTypes are not specified, the
|
|
// documented behavior is to ignore main_frame requests.
|
|
return false;
|
|
}
|
|
|
|
// Check this.req.requestURI:
|
|
if (cond.urlFilter) {
|
|
if (!cond.urlFilterMatches(this.req.requestDataForUrlFilter)) {
|
|
return false;
|
|
}
|
|
} else if (cond.regexFilter) {
|
|
// TODO bug 1745760: check cond.regexFilter + isUrlFilterCaseSensitive
|
|
}
|
|
if (
|
|
cond.excludedRequestDomains &&
|
|
this.#matchesDomains(cond.excludedRequestDomains, this.req.requestDomain)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
cond.requestDomains &&
|
|
!this.#matchesDomains(cond.requestDomains, this.req.requestDomain)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
cond.excludedInitiatorDomains &&
|
|
// Note: unable to only match null principals (bug 1798225).
|
|
this.req.initiatorDomain &&
|
|
this.#matchesDomains(
|
|
cond.excludedInitiatorDomains,
|
|
this.req.initiatorDomain
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
if (
|
|
cond.initiatorDomains &&
|
|
// Note: unable to only match null principals (bug 1798225).
|
|
(!this.req.initiatorDomain ||
|
|
!this.#matchesDomains(cond.initiatorDomains, this.req.initiatorDomain))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// TODO bug 1797408: domainType
|
|
|
|
if (cond.requestMethods) {
|
|
if (!cond.requestMethods.includes(this.req.method)) {
|
|
return false;
|
|
}
|
|
} else if (cond.excludedRequestMethods?.includes(this.req.method)) {
|
|
return false;
|
|
}
|
|
|
|
if (cond.tabIds) {
|
|
if (!cond.tabIds.includes(this.req.tabId)) {
|
|
return false;
|
|
}
|
|
} else if (cond.excludedTabIds?.includes(this.req.tabId)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} domains - A list of canonicalized domain patterns.
|
|
* Canonical means punycode, no ports, and IPv6 without brackets, and not
|
|
* starting with a dot. May end with a dot if it is a FQDN.
|
|
* @param {string} host - The canonical representation of the host of a URL.
|
|
* @returns {boolean} Whether the given host is a (sub)domain of any of the
|
|
* given domains.
|
|
*/
|
|
#matchesDomains(domains, host) {
|
|
return domains.some(domain => {
|
|
return (
|
|
host.endsWith(domain) &&
|
|
// either host === domain
|
|
(host.length === domain.length ||
|
|
// or host = "something." + domain (WITH a domain separator).
|
|
host.charAt(host.length - domain.length - 1) === ".")
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {Rule} rule - The final rule from the first pass.
|
|
* @returns {boolean} Whether the extension is allowed to execute the rule.
|
|
*/
|
|
#isRuleActionAllowed(rule) {
|
|
if (this.canModify) {
|
|
return true;
|
|
}
|
|
switch (rule.action.type) {
|
|
case "allow":
|
|
case "allowAllRequests":
|
|
case "block":
|
|
case "upgradeScheme":
|
|
return this.ruleManager.hasBlockPermission;
|
|
case "redirect":
|
|
return false;
|
|
// case "modifyHeaders" is never an action for this.matchedRule.
|
|
default:
|
|
throw new Error(`Unexpected action type: ${rule.action.type}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const NetworkIntegration = {
|
|
register() {
|
|
// We register via WebRequest.jsm to ensure predictable ordering of DNR and
|
|
// WebRequest behavior.
|
|
lazy.WebRequest.setDNRHandlingEnabled(true);
|
|
},
|
|
unregister() {
|
|
lazy.WebRequest.setDNRHandlingEnabled(false);
|
|
},
|
|
maybeUpdateTabIdChecker() {
|
|
gHasAnyTabIdConditions = gRuleManagers.some(rm => rm.hasRulesWithTabIds);
|
|
},
|
|
|
|
startDNREvaluation(channel) {
|
|
let ruleManagers = gRuleManagers;
|
|
if (!channel.canModify) {
|
|
ruleManagers = [];
|
|
}
|
|
if (channel.loadInfo.originAttributes.privateBrowsingId > 0) {
|
|
ruleManagers = ruleManagers.filter(
|
|
rm => rm.extension.privateBrowsingAllowed
|
|
);
|
|
}
|
|
let matchedRules;
|
|
if (ruleManagers.length) {
|
|
const request = RequestDetails.fromChannelWrapper(channel);
|
|
matchedRules = RequestEvaluator.evaluateRequest(request, ruleManagers);
|
|
}
|
|
// Cache for later. In case of redirects, _dnrMatchedRules may exist for
|
|
// the pre-redirect HTTP channel, and is overwritten here again.
|
|
channel._dnrMatchedRules = matchedRules;
|
|
},
|
|
|
|
/**
|
|
* Applies the actions of the DNR rules.
|
|
*
|
|
* @param {ChannelWrapper} channel
|
|
* @returns {boolean} Whether to ignore any responses from the webRequest API.
|
|
*/
|
|
onBeforeRequest(channel) {
|
|
let matchedRules = channel._dnrMatchedRules;
|
|
if (!matchedRules?.length) {
|
|
return false;
|
|
}
|
|
// If a matched rule closes the channel, it is the sole match.
|
|
const finalMatch = matchedRules[0];
|
|
switch (finalMatch.rule.action.type) {
|
|
case "block":
|
|
this.applyBlock(channel, finalMatch);
|
|
return true;
|
|
case "redirect":
|
|
this.applyRedirect(channel, finalMatch);
|
|
return true;
|
|
case "upgradeScheme":
|
|
this.applyUpgradeScheme(channel, finalMatch);
|
|
return true;
|
|
}
|
|
// If there are multiple rules, then it may be a combination of allow,
|
|
// allowAllRequests and/or modifyHeaders.
|
|
|
|
// TODO bug 1797403: Apply allowAllRequests actions.
|
|
|
|
return false;
|
|
},
|
|
|
|
onBeforeSendHeaders(channel) {
|
|
let matchedRules = channel._dnrMatchedRules;
|
|
if (!matchedRules?.length) {
|
|
return;
|
|
}
|
|
ModifyRequestHeaders.maybeApplyModifyHeaders(channel, matchedRules);
|
|
},
|
|
|
|
onHeadersReceived(channel) {
|
|
let matchedRules = channel._dnrMatchedRules;
|
|
if (!matchedRules?.length) {
|
|
return;
|
|
}
|
|
ModifyResponseHeaders.maybeApplyModifyHeaders(channel, matchedRules);
|
|
},
|
|
|
|
applyBlock(channel, matchedRule) {
|
|
// TODO bug 1802259: Consider a DNR-specific reason.
|
|
channel.cancel(
|
|
Cr.NS_ERROR_ABORT,
|
|
Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
|
|
);
|
|
const addonId = matchedRule.ruleManager.extension.id;
|
|
let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag);
|
|
properties.setProperty("cancelledByExtension", addonId);
|
|
},
|
|
|
|
applyUpgradeScheme(channel, matchedRule) {
|
|
// Request upgrade. No-op if already secure (i.e. https).
|
|
channel.upgradeToSecure();
|
|
},
|
|
|
|
applyRedirect(channel, matchedRule) {
|
|
// Ambiguity resolution order of redirect dict keys, consistent with Chrome:
|
|
// - url > extensionPath > transform > regexSubstitution
|
|
const redirect = matchedRule.rule.action.redirect;
|
|
const extension = matchedRule.ruleManager.extension;
|
|
let redirectUri;
|
|
if (redirect.url) {
|
|
// redirect.url already validated by checkActionRedirect.
|
|
redirectUri = Services.io.newURI(redirect.url);
|
|
} else if (redirect.extensionPath) {
|
|
redirectUri = extension.baseURI
|
|
.mutate()
|
|
.setPathQueryRef(redirect.extensionPath)
|
|
.finalize();
|
|
} else if (redirect.transform) {
|
|
redirectUri = applyURLTransform(channel.finalURI, redirect.transform);
|
|
} else if (redirect.regexSubstitution) {
|
|
// TODO bug 1745760: Implement along with regexFilter support.
|
|
throw new Error("regexSubstitution not implemented");
|
|
} else {
|
|
// #checkActionRedirect ensures that the redirect action is non-empty.
|
|
}
|
|
|
|
channel.redirectTo(redirectUri);
|
|
|
|
let properties = channel.channel.QueryInterface(Ci.nsIWritablePropertyBag);
|
|
properties.setProperty("redirectedByExtension", extension.id);
|
|
|
|
let origin = channel.getRequestHeader("Origin");
|
|
if (origin) {
|
|
channel.setResponseHeader("Access-Control-Allow-Origin", origin);
|
|
channel.setResponseHeader("Access-Control-Allow-Credentials", "true");
|
|
channel.setResponseHeader("Access-Control-Max-Age", "0");
|
|
}
|
|
},
|
|
};
|
|
|
|
class RuleManager {
|
|
constructor(extension) {
|
|
this.extension = extension;
|
|
this.sessionRules = this.makeRuleset(
|
|
"_session",
|
|
PRECEDENCE_SESSION_RULESET
|
|
);
|
|
// TODO bug 1745764: support registration of (persistent) dynamic rules.
|
|
this.dynamicRules = this.makeRuleset(
|
|
"_dynamic",
|
|
PRECEDENCE_DYNAMIC_RULESET
|
|
);
|
|
this.enabledStaticRules = [];
|
|
|
|
this.hasBlockPermission = extension.hasPermission("declarativeNetRequest");
|
|
this.hasRulesWithTabIds = false;
|
|
}
|
|
|
|
get availableStaticRuleCount() {
|
|
return Math.max(
|
|
GUARANTEED_MINIMUM_STATIC_RULES -
|
|
this.enabledStaticRules.reduce(
|
|
(acc, ruleset) => acc + ruleset.rules.length,
|
|
0
|
|
),
|
|
0
|
|
);
|
|
}
|
|
|
|
get enabledStaticRulesetIds() {
|
|
return this.enabledStaticRules.map(ruleset => ruleset.id);
|
|
}
|
|
|
|
makeRuleset(rulesetId, rulesetPrecedence, rules = []) {
|
|
return new Ruleset(rulesetId, rulesetPrecedence, rules, this);
|
|
}
|
|
|
|
setSessionRules(validatedSessionRules) {
|
|
this.sessionRules.rules = validatedSessionRules;
|
|
this.hasRulesWithTabIds = !!this.sessionRules.rules.find(rule => {
|
|
return rule.condition.tabIds || rule.condition.excludedTabIds;
|
|
});
|
|
NetworkIntegration.maybeUpdateTabIdChecker();
|
|
}
|
|
|
|
/**
|
|
* Set the enabled static rulesets.
|
|
*
|
|
* @param {Array<{ id, rules }>} enabledStaticRulesets
|
|
* Array of objects including the ruleset id and rules.
|
|
* The order of the rulesets in the Array is expected to
|
|
* match the order of the rulesets in the extension manifest.
|
|
*/
|
|
setEnabledStaticRulesets(enabledStaticRulesets) {
|
|
const rulesets = [];
|
|
for (const [idx, { id, rules }] of enabledStaticRulesets.entries()) {
|
|
rulesets.push(
|
|
this.makeRuleset(id, idx + PRECEDENCE_STATIC_RULESETS_BASE, rules)
|
|
);
|
|
}
|
|
this.enabledStaticRules = rulesets;
|
|
}
|
|
|
|
getSessionRules() {
|
|
return this.sessionRules.rules;
|
|
}
|
|
}
|
|
|
|
function getRuleManager(extension, createIfMissing = true) {
|
|
let ruleManager = gRuleManagers.find(rm => rm.extension === extension);
|
|
if (!ruleManager && createIfMissing) {
|
|
if (extension.hasShutdown) {
|
|
throw new Error(
|
|
`Error on creating new DNR RuleManager after extension shutdown: ${extension.id}`
|
|
);
|
|
}
|
|
ruleManager = new RuleManager(extension);
|
|
// The most recently installed extension gets priority, i.e. appears at the
|
|
// start of the gRuleManagers list. It is not yet possible to determine the
|
|
// installation time of a given Extension, so currently the last to
|
|
// instantiate a RuleManager claims the highest priority.
|
|
// TODO bug 1786059: order extensions by "installation time".
|
|
gRuleManagers.unshift(ruleManager);
|
|
if (gRuleManagers.length === 1) {
|
|
// The first DNR registration.
|
|
NetworkIntegration.register();
|
|
}
|
|
}
|
|
return ruleManager;
|
|
}
|
|
|
|
function clearRuleManager(extension) {
|
|
let i = gRuleManagers.findIndex(rm => rm.extension === extension);
|
|
if (i !== -1) {
|
|
gRuleManagers.splice(i, 1);
|
|
NetworkIntegration.maybeUpdateTabIdChecker();
|
|
if (gRuleManagers.length === 0) {
|
|
// The last DNR registration.
|
|
NetworkIntegration.unregister();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds all matching rules for a request, optionally restricted to one
|
|
* extension.
|
|
*
|
|
* @param {object|RequestDetails} request
|
|
* @param {Extension} [extension]
|
|
* @returns {MatchedRule[]}
|
|
*/
|
|
function getMatchedRulesForRequest(request, extension) {
|
|
let requestDetails = new RequestDetails(request);
|
|
let ruleManagers = gRuleManagers;
|
|
if (extension) {
|
|
ruleManagers = ruleManagers.filter(rm => rm.extension === extension);
|
|
}
|
|
return RequestEvaluator.evaluateRequest(requestDetails, ruleManagers);
|
|
}
|
|
|
|
/**
|
|
* Runs before any webRequest event is notified. Headers may be modified, but
|
|
* the request should not be canceled (see handleRequest instead).
|
|
*
|
|
* @param {ChannelWrapper} channel
|
|
* @param {string} kind - The name of the webRequest event.
|
|
*/
|
|
function beforeWebRequestEvent(channel, kind) {
|
|
try {
|
|
switch (kind) {
|
|
case "onBeforeRequest":
|
|
NetworkIntegration.startDNREvaluation(channel);
|
|
break;
|
|
case "onBeforeSendHeaders":
|
|
NetworkIntegration.onBeforeSendHeaders(channel);
|
|
break;
|
|
case "onHeadersReceived":
|
|
NetworkIntegration.onHeadersReceived(channel);
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
Cu.reportError(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applies matching DNR rules, some of which may potentially cancel the request.
|
|
*
|
|
* @param {ChannelWrapper} channel
|
|
* @param {string} kind - The name of the webRequest event.
|
|
* @returns {boolean} Whether to ignore any responses from the webRequest API.
|
|
*/
|
|
function handleRequest(channel, kind) {
|
|
try {
|
|
if (kind === "onBeforeRequest") {
|
|
return NetworkIntegration.onBeforeRequest(channel);
|
|
}
|
|
} catch (e) {
|
|
Cu.reportError(e);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function initExtension(extension) {
|
|
// These permissions are NOT an OptionalPermission, so their status can be
|
|
// assumed to be constant for the lifetime of the extension.
|
|
if (
|
|
extension.hasPermission("declarativeNetRequest") ||
|
|
extension.hasPermission("declarativeNetRequestWithHostAccess")
|
|
) {
|
|
if (extension.hasShutdown) {
|
|
throw new Error(
|
|
`Aborted ExtensionDNR.initExtension call, extension "${extension.id}" is not active anymore`
|
|
);
|
|
}
|
|
await lazy.ExtensionDNRStore.initExtension(extension);
|
|
}
|
|
}
|
|
|
|
function ensureInitialized(extension) {
|
|
return (extension._dnrReady ??= initExtension(extension));
|
|
}
|
|
|
|
function validateManifestEntry(extension) {
|
|
const ruleResourcesArray =
|
|
extension.manifest.declarative_net_request.rule_resources;
|
|
|
|
const getWarningMessage = msg =>
|
|
`Warning processing declarative_net_request: ${msg}`;
|
|
|
|
if (ruleResourcesArray.length > MAX_NUMBER_OF_STATIC_RULESETS) {
|
|
extension.manifestWarning(
|
|
getWarningMessage(
|
|
`Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit (${MAX_NUMBER_OF_STATIC_RULESETS}).`
|
|
)
|
|
);
|
|
}
|
|
|
|
const seenRulesetIds = new Set();
|
|
const seenRulesetPaths = new Set();
|
|
const duplicatedRulesetIds = [];
|
|
const duplicatedRulesetPaths = [];
|
|
for (const [idx, { id, path }] of ruleResourcesArray.entries()) {
|
|
if (seenRulesetIds.has(id)) {
|
|
duplicatedRulesetIds.push({ idx, id });
|
|
}
|
|
if (seenRulesetPaths.has(path)) {
|
|
duplicatedRulesetPaths.push({ idx, path });
|
|
}
|
|
seenRulesetIds.add(id);
|
|
seenRulesetPaths.add(path);
|
|
}
|
|
|
|
if (duplicatedRulesetIds.length) {
|
|
const errorDetails = duplicatedRulesetIds
|
|
.map(({ idx, id }) => `"${id}" at index ${idx}`)
|
|
.join(", ");
|
|
extension.manifestWarning(
|
|
getWarningMessage(
|
|
`Static ruleset ids should be unique, duplicated ruleset ids: ${errorDetails}.`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (duplicatedRulesetPaths.length) {
|
|
// NOTE: technically Chrome allows duplicated paths without any manifest
|
|
// validation warnings or errors, but if this happens it not unlikely to be
|
|
// actually a mistake in the manifest that may have been missed.
|
|
//
|
|
// In Firefox we decided to allow the same behavior to avoid introducing a chrome
|
|
// incompatibility, but we still warn about it to avoid extension developers
|
|
// to investigate more easily issue that may be due to duplicated rulesets
|
|
// paths.
|
|
const errorDetails = duplicatedRulesetPaths
|
|
.map(({ idx, path }) => `"${path}" at index ${idx}`)
|
|
.join(", ");
|
|
extension.manifestWarning(
|
|
getWarningMessage(
|
|
`Static rulesets paths are not unique, duplicated ruleset paths: ${errorDetails}.`
|
|
)
|
|
);
|
|
}
|
|
|
|
const enabledRulesets = ruleResourcesArray.filter(rs => rs.enabled);
|
|
if (enabledRulesets.length > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) {
|
|
const exceedingRulesetIds = enabledRulesets
|
|
.slice(MAX_NUMBER_OF_ENABLED_STATIC_RULESETS)
|
|
.map(ruleset => `"${ruleset.id}"`)
|
|
.join(", ");
|
|
extension.manifestWarning(
|
|
getWarningMessage(
|
|
`Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ${exceedingRulesetIds}.`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
async function updateEnabledStaticRulesets(extension, updateRulesetOptions) {
|
|
await ensureInitialized(extension);
|
|
await lazy.ExtensionDNRStore.updateEnabledStaticRulesets(
|
|
extension,
|
|
updateRulesetOptions
|
|
);
|
|
}
|
|
|
|
// exports used by the DNR API implementation.
|
|
export const ExtensionDNR = {
|
|
RuleValidator,
|
|
clearRuleManager,
|
|
ensureInitialized,
|
|
getMatchedRulesForRequest,
|
|
getRuleManager,
|
|
updateEnabledStaticRulesets,
|
|
validateManifestEntry,
|
|
// TODO(Bug 1803370): consider allowing changing DNR limits through about:config prefs).
|
|
limits: {
|
|
GUARANTEED_MINIMUM_STATIC_RULES,
|
|
MAX_NUMBER_OF_STATIC_RULESETS,
|
|
MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
|
|
},
|
|
|
|
beforeWebRequestEvent,
|
|
handleRequest,
|
|
};
|