Source code

Revision control

Copy as Markdown

Other Tools

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