Bug 1745762 - Add registration of DNR session rules r=rpl

This patch offers the Rule type and updateSessionRules and
getSessionRules to register rules. The actual evaluation of rules and
most of the associated validation is not part of this patch.

Differential Revision: https://phabricator.services.mozilla.com/D154801
This commit is contained in:
Rob Wu 2022-08-22 20:44:26 +00:00
Родитель d19f353924
Коммит 3494ccd426
6 изменённых файлов: 571 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,116 @@
/* 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 = [];
// 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 {
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;
}
}
class Rule {
constructor(rule) {
this.id = rule.id;
this.priority = rule.priority;
this.condition = new RuleCondition(rule.condition);
this.action = rule.action;
}
}
class RuleValidator {
constructor(alreadyValidatedRules) {
this.rulesMap = new Map(alreadyValidatedRules.map(r => [r.id, r]));
this.failures = [];
}
removeRuleIds(ruleIds) {
for (const ruleId of ruleIds) {
this.rulesMap.delete(ruleId);
}
}
addRules(rules) {
for (const rule of rules) {
if (this.rulesMap.has(rule.id)) {
this.#collectInvalidRule(rule, `Duplicate rule ID: ${rule.id}`);
continue;
}
const newRule = new Rule(rule);
// TODO bug 1745758: Need more validation, before rules are evaluated.
this.rulesMap.set(rule.id, newRule);
}
}
#collectInvalidRule(rule, message) {
this.failures.push({ rule, message });
}
getValidatedRules() {
return Array.from(this.rulesMap.values());
}
getFailures() {
return this.failures;
}
}
class RuleManager {
constructor(extension) {
this.extension = extension;
this.sessionRules = [];
}
setSessionRules(validatedSessionRules) {
this.sessionRules = validatedSessionRules;
}
getSessionRules() {
return this.sessionRules;
}
}
function getRuleManager(extension, createIfMissing = true) {
let ruleManager = gRuleManagers.find(rm => rm.extension === extension);
if (!ruleManager && createIfMissing) {
ruleManager = new RuleManager(extension);
// TODO bug 1786059: order extensions by "installation time".
gRuleManagers.push(ruleManager);
}
return ruleManager;
}
function clearRuleManager(extension) {
let i = gRuleManagers.findIndex(rm => rm.extension === extension);
if (i !== -1) {
gRuleManagers.splice(i, 1);
}
}
export const ExtensionDNR = {
RuleValidator,
getRuleManager,
clearRuleManager,
};

Просмотреть файл

@ -18,6 +18,7 @@ EXTRA_JS_MODULES += [
"ExtensionChildDevToolsUtils.jsm",
"ExtensionCommon.jsm",
"ExtensionContent.jsm",
"ExtensionDNR.sys.mjs",
"ExtensionPageChild.jsm",
"ExtensionParent.jsm",
"ExtensionPermissions.jsm",

Просмотреть файл

@ -6,10 +6,47 @@
"use strict";
ChromeUtils.defineESModuleGetters(this, {
ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
});
var { ExtensionError } = ExtensionUtils;
this.declarativeNetRequest = class extends ExtensionAPI {
onShutdown() {
ExtensionDNR.clearRuleManager(this.extension);
}
getAPI(context) {
const { extension } = this;
return {
declarativeNetRequest: {
updateSessionRules({ removeRuleIds, addRules }) {
const ruleManager = ExtensionDNR.getRuleManager(extension);
let ruleValidator = new ExtensionDNR.RuleValidator(
ruleManager.getSessionRules()
);
if (removeRuleIds) {
ruleValidator.removeRuleIds(removeRuleIds);
}
if (addRules) {
ruleValidator.addRules(addRules);
}
let failures = ruleValidator.getFailures();
if (failures.length) {
throw new ExtensionError(failures[0].message);
}
ruleManager.setSessionRules(ruleValidator.getValidatedRules());
},
getSessionRules() {
// ruleManager.getSessionRules() returns an array of Rule instances.
// When these are structurally cloned (to send them to the child),
// the enumerable public fields of the class instances are copied to
// plain objects, as desired.
return ExtensionDNR.getRuleManager(extension).getSessionRules();
},
async testMatchOutcome(request) {
// TODO bug 1745758: Implement rule evaluation engine.
// Since rule registration has not been implemented yet, the result

Просмотреть файл

@ -69,9 +69,280 @@
"description": "ID of the Ruleset this rule belongs to."
}
}
},
{
"id": "Rule",
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "An id which uniquely identifies a rule. Mandatory and should be >= 1.",
"minimum": 1
},
"priority": {
"type": "integer",
"optional": true,
"description": "Rule priority. Defaults to 1. When specified, should be >= 1",
"minimum": 1,
"default": 1
},
"condition": {
"type": "object",
"description": "The condition under which this rule is triggered.",
"properties": {
"urlFilter": {
"type": "string",
"optional": true,
"description": "TODO: link to doc explaining supported pattern. The pattern which is matched against the network request url. Only one of 'urlFilter' or 'regexFilter' can be specified."
},
"regexFilter": {
"type": "string",
"optional": true,
"description": "Regular expression to match against the network request url. Only one of 'urlFilter' or 'regexFilter' can be specified."
},
"isUrlFilterCaseSensitive": {
"type": "boolean",
"optional": true,
"description": "Whether 'urlFilter' or 'regexFilter' is case-sensitive. Defaults to true."
},
"initiatorDomains": {
"type": "array",
"optional": true,
"description": "The rule will only match network requests originating from the list of 'initiatorDomains'. If the list is omitted, the rule is applied to requests from all domains.",
"minItems": 1,
"items": {
"type": "string",
"description": "TODO: describe domain format."
}
},
"excludedInitiatorDomains": {
"type": "array",
"optional": true,
"description": "The rule will not match network requests originating from the list of 'initiatorDomains'. If the list is empty or omitted, no domains are excluded. This takes precedence over 'initiatorDomains'.",
"items": {
"type": "string",
"description": "TODO: describe domain format."
}
},
"requestDomains": {
"type": "array",
"optional": true,
"description": "The rule will only match network requests when the domain matches one from the list of 'requestDomains'. If the list is omitted, the rule is applied to requests from all domains.",
"minItems": 1,
"items": {
"type": "string",
"description": "TODO: describe domain format."
}
},
"excludedRequestDomains": {
"type": "array",
"optional": true,
"description": "The rule will not match network requests when the domains matches one from the list of 'excludedRequestDomains'. If the list is empty or omitted, no domains are excluded. This takes precedence over 'requestDomains'.",
"items": {
"type": "string",
"description": "TODO: describe domain format."
}
},
"resourceTypes": {
"type": "array",
"optional": true,
"description": "List of resource types which the rule can match. When the rule action is 'allowAllRequests', this must be specified and may only contain 'main_frame' or 'sub_frame'. Cannot be specified if 'excludedResourceTypes' is specified. If neither of them is specified, all resource types except 'main_frame' are matched.",
"minItems": 1,
"items": {
"$ref": "ResourceType"
}
},
"excludedResourceTypes": {
"type": "array",
"optional": true,
"description": "List of resource types which the rule won't match. Cannot be specified if 'resourceTypes' is specified. If neither of them is specified, all resource types except 'main_frame' are matched.",
"minItems": 1,
"items": {
"$ref": "ResourceType"
}
},
"requestMethods": {
"type": "array",
"optional": true,
"description": "List of HTTP request methods which the rule can match. Should be a lower-case method such as 'connect', 'delete', 'get', 'head', 'options', 'patch', 'post', 'put'.'",
"minItems": 1,
"items": {
"type": "string"
}
},
"excludedRequestMethods": {
"type": "array",
"optional": true,
"description": "List of request methods which the rule won't match. Cannot be specified if 'requestMethods' is specified. If neither of them is specified, all request methods are matched.",
"minItems": 1,
"items": {
"type": "string"
}
},
"domainType": {
"type": "string",
"optional": true,
"description": "Specifies whether the network request is first-party or third-party to the domain from which it originated. If omitted, all requests are matched.",
"enum": ["firstParty", "thirdParty"]
},
"tabIds": {
"type": "array",
"optional": true,
"description": "List of tabIds which the rule should match. An ID of -1 matches requests which don't originate from a tab. Only supported for session-scoped rules.",
"minItems": 1,
"items": {
"type": "integer"
}
},
"excludedTabIds": {
"type": "array",
"optional": true,
"description": "List of tabIds which the rule should not match. An ID of -1 excludes requests which don't originate from a tab. Only supported for session-scoped rules.",
"items": {
"type": "integer"
}
}
}
},
"action": {
"type": "object",
"description": "The action to take if this rule is matched.",
"properties": {
"type": {
"type": "string",
"enum": ["block", "redirect", "allow", "upgradeScheme", "modifyHeaders", "allowAllRequests"]
},
"redirect": {
"type": "object",
"optional": true,
"description": "Describes how the redirect should be performed. Only valid when type is 'redirect'.",
"properties": {
"extensionPath": {
"type": "string",
"optional": true,
"description": "Path relative to the extension directory. Should start with '/'."
},
"transform": {
"type": "object",
"optional": true,
"description": "TODO: URLTransform - Url transformations to perform."
},
"url": {
"type": "string",
"optional": true,
"description": "The redirect url. Redirects to JavaScript urls are not allowed."
},
"regexSubstitution": {
"type": "string",
"optional": true,
"description": "TODO with regexFilter + Substitution pattern for rules which specify a 'regexFilter'."
}
}
},
"requestHeaders": {
"type": "object",
"optional": true,
"description": "The request headers to modify for the request. Only valid when type is 'modifyHeaders'.",
"properties": {
"header": {
"type": "string",
"description": "The name of the request header to be modified."
},
"operation": {
"type": "string",
"description": "The operation to be performed on a header. The 'append' operation is not supported for request headers.",
"enum": ["set", "remove"]
},
"value": {
"type": "string",
"optional": true,
"description": "The new value for the header. Must be specified for the 'set' operation."
}
}
},
"responseHeaders": {
"type": "object",
"optional": true,
"description": "The response headers to modify for the request. Only valid when type is 'modifyHeaders'.",
"properties": {
"header": {
"type": "string",
"description": "The name of the response header to be modified."
},
"operation": {
"type": "string",
"description": "The operation to be performed on a header.",
"enum": ["append", "set", "remove"]
},
"value": {
"type": "string",
"optional": true,
"description": "The new value for the header. Must be specified for the 'append' and 'set' operations."
}
}
}
}
}
}
}
],
"functions": [
{
"name": "updateSessionRules",
"type": "function",
"description": "Modifies the current set of session scoped rules for the extension. The rules with IDs listed in options.removeRuleIds are first removed, and then the rules given in options.addRules are added. These rules are not persisted across sessions and are backed in memory.",
"async": "callback",
"parameters": [
{
"name": "options",
"type": "object",
"properties": {
"removeRuleIds": {
"type": "array",
"optional": true,
"description": "IDs of the rules to remove. Any invalid IDs will be ignored.",
"items": {
"type": "integer"
}
},
"addRules": {
"type": "array",
"optional": true,
"description": "Rules to add.",
"items": {
"$ref": "Rule"
}
}
}
},
{
"name": "callback",
"type": "function",
"description": "Called when the session rules have been updated",
"parameters": []
}
]
},
{
"name": "getSessionRules",
"type": "function",
"description": "Returns the current set of session scoped rules for the extension.",
"async": "callback",
"parameters": [
{
"name": "callback",
"type": "function",
"parameters": [
{
"type": "array",
"items": {
"$ref": "Rule"
}
}
]
}
]
},
{
"name": "testMatchOutcome",
"type": "function",

Просмотреть файл

@ -0,0 +1,145 @@
"use strict";
ChromeUtils.defineESModuleGetters(this, {
ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
});
add_setup(() => {
Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
Services.prefs.setBoolPref("extensions.dnr.enabled", true);
});
// This function is serialized and called in the context of the test extension's
// background page. dnrTestUtils is passed to the background function.
function makeDnrTestUtils() {
const dnrTestUtils = {};
dnrTestUtils.makeRuleInput = id => {
return {
id,
condition: {},
action: { type: "block" },
};
};
dnrTestUtils.makeRuleOutput = id => {
return {
id,
condition: {
urlFilter: null,
regexFilter: null,
isUrlFilterCaseSensitive: null,
initiatorDomains: null,
excludedInitiatorDomains: null,
requestDomains: null,
excludedRequestDomains: null,
resourceTypes: null,
excludedResourceTypes: null,
requestMethods: null,
excludedRequestMethods: null,
domainType: null,
tabIds: null,
excludedTabIds: null,
},
action: {
type: "block",
redirect: null,
requestHeaders: null,
responseHeaders: null,
},
priority: 1,
};
};
return dnrTestUtils;
}
async function runAsDNRExtension({ background, unloadTestAtEnd = true }) {
let extension = ExtensionTestUtils.loadExtension({
background: `(${background})((${makeDnrTestUtils})())`,
manifest: {
manifest_version: 3,
permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
},
});
await extension.startup();
await extension.awaitFinish();
if (unloadTestAtEnd) {
await extension.unload();
}
return extension;
}
add_task(async function register_and_retrieve_session_rules() {
let extension = await runAsDNRExtension({
background: async dnrTestUtils => {
const dnr = browser.declarativeNetRequest;
// Rules input to updateSessionRules:
const RULE_1234_IN = dnrTestUtils.makeRuleInput(1234);
const RULE_4321_IN = dnrTestUtils.makeRuleInput(4321);
const RULE_9001_IN = dnrTestUtils.makeRuleInput(9001);
// Rules expected to be returned by getSessionRules:
const RULE_1234_OUT = dnrTestUtils.makeRuleOutput(1234);
const RULE_4321_OUT = dnrTestUtils.makeRuleOutput(4321);
const RULE_9001_OUT = dnrTestUtils.makeRuleOutput(9001);
await dnr.updateSessionRules({
// Deliberately rule 4321 before 1234, see next getSessionRules test.
addRules: [RULE_4321_IN, RULE_1234_IN],
removeRuleIds: [1234567890], // Invalid rules should be ignored.
});
browser.test.assertDeepEq(
// Order is same as the original input.
[RULE_4321_OUT, RULE_1234_OUT],
await dnr.getSessionRules(),
"getSessionRules() returns all registered session rules"
);
await browser.test.assertRejects(
dnr.updateSessionRules({
addRules: [RULE_9001_IN, RULE_1234_IN],
removeRuleIds: [RULE_4321_IN.id],
}),
"Duplicate rule ID: 1234",
"updateSessionRules of existing rule without removeRuleIds should fail"
);
browser.test.assertDeepEq(
[RULE_4321_OUT, RULE_1234_OUT],
await dnr.getSessionRules(),
"session rules should not be changed if an error has occurred"
);
// From [4321,1234] to [1234,9001,4321]; 4321 moves to the end because
// the rule is deleted before inserted, NOT updated in-place.
await dnr.updateSessionRules({
addRules: [RULE_9001_IN, RULE_4321_IN],
removeRuleIds: [RULE_4321_IN.id],
});
browser.test.assertDeepEq(
[RULE_1234_OUT, RULE_9001_OUT, RULE_4321_OUT],
await dnr.getSessionRules(),
"existing session rule ID can be re-used for a new rule"
);
await dnr.updateSessionRules({
removeRuleIds: [RULE_1234_IN.id, RULE_4321_IN.id, RULE_9001_IN.id],
});
browser.test.assertDeepEq(
[],
await dnr.getSessionRules(),
"deleted all rules"
);
browser.test.notifyPass();
},
unloadTestAtEnd: false,
});
const realExtension = extension.extension;
Assert.ok(
ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false),
"Rule manager exists before unload"
);
await extension.unload();
Assert.ok(
!ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false),
"Rule manager erased after unload"
);
});

Просмотреть файл

@ -102,6 +102,7 @@ skip-if = appname == "thunderbird" || os == "android" # Containers are not expos
[test_ext_csp_frame_ancestors.js]
[test_ext_debugging_utils.js]
[test_ext_dnr_api.js]
[test_ext_dnr_session_rules.js]
[test_ext_dns.js]
skip-if = os == "android" # Android needs alternative for proxy.settings - bug 1723523
[test_ext_downloads.js]