Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

"use strict";
// This file provides test coverage for regexFilter and regexSubstitution.
//
// The validate_actions task of test_ext_dnr_session_rules.js checks that the
// basic requirements of regexFilter + regexSubstitution are met.
//
// The match_regexFilter task of test_ext_dnr_testMatchOutcome.js verifies that
// regexFilter is evaluated correctly in testMatchOutcome.
//
// The quota on regexFilter is verified in test_ext_dnr_regexFilter_limits.js.
add_setup(() => {
Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
Services.prefs.setBoolPref("extensions.dnr.enabled", true);
Services.prefs.setBoolPref("extensions.dnr.feedback", true);
});
const server = createHttpServer({
hosts: ["example.com", "example-com", "from", "dest"],
});
server.registerPrefixHandler("/", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.write("GOOD_RESPONSE");
});
// 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 = {};
const dnr = browser.declarativeNetRequest;
async function testFetch(from, to, description) {
let res = await fetch(from);
browser.test.assertEq(to, res.url, description);
browser.test.assertEq("GOOD_RESPONSE", await res.text(), "expected body");
}
async function _testRegexFilterOrRedirect({
description,
regexFilter,
isUrlFilterCaseSensitive,
expectedRedirectUrl = "http://dest/",
regexSubstitution = expectedRedirectUrl,
urlsMatching,
urlsNonMatching,
}) {
browser.test.log(`Test description: ${description}`);
await dnr.updateSessionRules({
addRules: [
{
id: 12345,
condition: { regexFilter, isUrlFilterCaseSensitive },
action: { type: "redirect", redirect: { regexSubstitution } },
},
],
});
for (let url of urlsMatching) {
const description = `regexFilter ${regexFilter} should match: ${url}`;
await testFetch(url, expectedRedirectUrl, description);
}
for (let url of urlsNonMatching) {
const description = `regexFilter ${regexFilter} should not match: ${url}`;
let expectedUrl = new URL(url);
expectedUrl.hash = "";
await testFetch(url, expectedUrl.href, description);
}
await dnr.updateSessionRules({ removeRuleIds: [12345] });
}
async function testValidRegexFilter({
description,
regexFilter,
isUrlFilterCaseSensitive,
urlsMatching,
urlsNonMatching,
}) {
browser.test.assertDeepEq(
{ isSupported: true },
await dnr.isRegexSupported({
regex: regexFilter,
isCaseSensitive: isUrlFilterCaseSensitive,
}),
`isRegexSupported should detect support for: ${regexFilter}`
);
await _testRegexFilterOrRedirect({
description,
regexFilter,
isUrlFilterCaseSensitive,
expectedRedirectUrl: "http://dest/",
regexSubstitution: "http://dest/",
urlsMatching,
urlsNonMatching,
});
}
async function testValidRegexSubstitution({
description,
regexFilter,
regexSubstitution,
inputUrl,
expectedRedirectUrl,
}) {
browser.test.assertDeepEq(
{ isSupported: true },
await dnr.isRegexSupported({
regex: regexFilter,
// requireCapturing option not strictly needed, but included to verify
// that the method can take the option without issues.
requireCapturing: true,
}),
`isRegexSupported should accept regexFilter: ${regexFilter}`
);
await _testRegexFilterOrRedirect({
description,
regexFilter,
regexSubstitution,
urlsMatching: [inputUrl],
urlsNonMatching: [],
expectedRedirectUrl,
});
}
async function testInvalidRegexFilter(regexFilter, expectedError, msg) {
browser.test.assertDeepEq(
{ isSupported: false, reason: "syntaxError" },
await dnr.isRegexSupported({ regex: regexFilter }),
`isRegexSupported should detect unsupported regex: ${regexFilter}`
);
await browser.test.assertRejects(
dnr.updateSessionRules({
addRules: [
{ id: 123, condition: { regexFilter }, action: { type: "block" } },
],
}),
expectedError,
`Should reject invalid regexFilter (${regexFilter}) - ${msg}`
);
}
async function testInvalidRegexSubstitution(
regexSubstitution,
expectedError,
msg
) {
await browser.test.assertRejects(
_testRegexFilterOrRedirect({
description: `testInvalidRegexSubstitution: "${regexSubstitution}"`,
regexFilter: ".",
regexSubstitution,
urlsMatching: [],
urlsNonMatching: [],
}),
expectedError,
msg
);
}
async function testRejectedRedirectAtRuntime({ regexSubstitution, url }) {
// Some regexSubstitution rules pass validation but the generated redirect
// URL is rejected at runtime. That is validated here.
await _testRegexFilterOrRedirect({
description: `testRejectedRedirectAtRuntime for URL: ${url}`,
regexFilter: "http://from/.*",
regexSubstitution,
// When regexSubstitution is invalid, it should not be redirected:
expectedRedirectUrl: url,
urlsMatching: [url],
urlsNonMatching: [],
});
}
Object.assign(dnrTestUtils, {
testValidRegexFilter,
testValidRegexSubstitution,
testInvalidRegexFilter,
testInvalidRegexSubstitution,
testRejectedRedirectAtRuntime,
});
return dnrTestUtils;
}
async function runAsDNRExtension({ background, manifest }) {
let extension = ExtensionTestUtils.loadExtension({
background: `(${background})((${makeDnrTestUtils})())`,
allowInsecureRequests: true,
manifest: {
manifest_version: 3,
permissions: ["declarativeNetRequest"],
// host_permissions are needed for the redirect action.
host_permissions: ["<all_urls>"],
granted_host_permissions: true,
...manifest,
},
temporarilyInstalled: true, // <-- for granted_host_permissions
});
await extension.startup();
await extension.awaitFinish();
await extension.unload();
}
// The least common denominator across Chrome, Safari and Firefox is Safari, at
// the time of writing, the supported syntax in Safari's regexFilter is
// section "The Regular expression format":
//
// - Matching any character with “.”.
// - Matching ranges with the range syntax [a-b].
// - Quantifying expressions with “?”, “+” and “*”.
// - Groups with parenthesis.
// - ... beginning of line (“^”) and end of line (“$”) marker ...
//
// The above syntax is very limited, as expressed at
//
// The tests continue in regexFilter_more_than_basic.
add_task(async function regexFilter_basic() {
await runAsDNRExtension({
background: async dnrTestUtils => {
const { testValidRegexFilter } = dnrTestUtils;
await testValidRegexFilter({
description: "URL as regexFilter is sometimes a valid regexp",
regexFilter: "http://example.com/",
urlsMatching: [
// dot is wildcard.
// Without ^ anchor, matches substring elsewhere.
],
urlsNonMatching: [
// Does not match reference fragment.
],
});
await testValidRegexFilter({
description: "\\. is literal dot",
regexFilter: "http://example\\.com/",
urlsMatching: ["http://example.com/"],
urlsNonMatching: ["http://example-com/"],
});
await testValidRegexFilter({
description: "[a-b] range is supported",
regexFilter: "http://from/[a-b]",
urlsMatching: ["http://from/a", "http://from/b"],
urlsNonMatching: ["http://from/c", "http://from/"],
});
await testValidRegexFilter({
description: "groups with parenthesis are supported",
regexFilter: "http://from/(a)",
urlsMatching: ["http://from/a", "http://from/aa"],
urlsNonMatching: ["http://from/b", "http://from/ba"],
});
await testValidRegexFilter({
description: "+, * and ? are quantifiers",
regexFilter: "a+b*c?d",
urlsMatching: [
],
urlsNonMatching: [
"http://from/bcd", // "a+" requires "a" to be specified.
"http://from/abccd", // "c?" matches only one c, but got two.
],
});
await testValidRegexFilter({
description: ".* matches anything",
regexFilter: "a.*b",
urlsMatching: ["http://from/ab/", "http://from/aANYTHINGb"],
urlsNonMatching: ["http://from/a"],
});
await testValidRegexFilter({
description: "^ is start-of-string anchor",
regexFilter: "^http://from/",
urlsMatching: ["http://from/", "http://from/path"],
urlsNonMatching: ["http://dest/^http://from/"],
});
await testValidRegexFilter({
description: "$ is end-of-string anchor",
regexFilter: "http://from/$",
urlsMatching: ["http://from/", "http://dest/http://from/"],
urlsNonMatching: ["http://from/path", "http://from/$"],
});
browser.test.notifyPass();
},
});
});
// regexFilter_basic lists the bare minimum, this tests more useful features.
add_task(async function regexFilter_more_than_basic() {
await runAsDNRExtension({
background: async dnrTestUtils => {
const { testValidRegexFilter } = dnrTestUtils;
// Use cases listed at
await testValidRegexFilter({
description: "{n,m} quantifier",
regexFilter: "http://from/a{2,3}b",
urlsMatching: ["http://from/aab", "http://from/aaab"],
urlsNonMatching: ["http://from/ab", "http://from/aaaab"],
});
await testValidRegexFilter({
description: "{n,} quantifier",
regexFilter: "http://from/a{2,}$",
urlsNonMatching: ["http://from/a"],
});
await testValidRegexFilter({
description: "| disjunction and within groups",
regexFilter: "from/a|from/b$|c$",
urlsMatching: ["http://from/a", "http://from/b", "http://from/c"],
urlsNonMatching: ["http://from/b$|c$"],
});
await testValidRegexFilter({
description: "(?!) negative look-ahead",
regexFilter: "http://from/a(?!notme|$)",
urlsMatching: ["http://from/aOK"],
urlsNonMatching: ["http://from/anotme", "http://from/a"],
});
// Features based on
await testValidRegexFilter({
description: "Negated character class",
regexFilter: "http://from/[^a-z]",
urlsMatching: ["http://from/1"],
urlsNonMatching: ["http://from/a", "http://from/y", "http://from/"],
});
await testValidRegexFilter({
description: "Word character class (\\w)",
regexFilter: "http://from/\\w",
urlsMatching: ["http://from/1", "http://from/a", "http://from/_"],
urlsNonMatching: ["http://from/-", "http://from/%20"],
});
// Rule that leads to "memoryLimitExceeded" in Chrome:
await testValidRegexFilter({
description: "regexFilter that triggers memoryLimitExceeded in Chrome",
regexFilter: "(https?://)104\\.154\\..{100,}",
urlsMatching: ["http://from/http://104.154.0.0/" + "x".repeat(100)],
});
browser.test.notifyPass();
},
});
});
// Adds more coverage in addition to what was tested by validate_regexFilter in
// test_ext_dnr_session_rules.js.
add_task(async function regexFilter_invalid() {
await runAsDNRExtension({
background: async dnrTestUtils => {
const { testInvalidRegexFilter } = dnrTestUtils;
await testInvalidRegexFilter(
"(",
"regexFilter is not a valid regular expression",
"( opens a group and should be closed"
);
await testInvalidRegexFilter(
"straß.d",
"regexFilter should not contain non-ASCII characters",
"regexFilter matches the canonical URL which does not contain non-ASCII"
);
browser.test.notifyPass();
},
});
});
add_task(async function regexFilter_isUrlFilterCaseSensitive() {
await runAsDNRExtension({
background: async dnrTestUtils => {
const { testValidRegexFilter } = dnrTestUtils;
await testValidRegexFilter({
description: "isUrlFilterCaseSensitive omitted (= false by default)",
// isUrlFilterCaseSensitive = false by default.
regexFilter: "from/Pa",
urlsMatching: ["http://from/Pa", "http://from/pa", "http://from/PA"],
urlsNonMatching: [],
});
await testValidRegexFilter({
description: "isUrlFilterCaseSensitive: false",
isUrlFilterCaseSensitive: false,
regexFilter: "from/Pa",
urlsMatching: ["http://from/Pa", "http://from/pa", "http://from/PA"],
urlsNonMatching: [],
});
await testValidRegexFilter({
description: "isUrlFilterCaseSensitive: true",
isUrlFilterCaseSensitive: true,
regexFilter: "from/Pa",
urlsMatching: ["http://from/Pa"],
urlsNonMatching: ["http://from/pa", "http://from/PA"],
});
await testValidRegexFilter({
description: "Case-sensitive uppercase regexFilter cannot match HOST",
isUrlFilterCaseSensitive: true,
regexFilter: "FROM",
urlsMatching: [],
});
browser.test.notifyPass();
},
});
});
add_task(async function regexSubstitution_invalid() {
let { messages } = await promiseConsoleOutput(async () => {
await runAsDNRExtension({
manifest: { browser_specific_settings: { gecko: { id: "@dnr" } } },
background: async dnrTestUtils => {
const { testRejectedRedirectAtRuntime, testInvalidRegexSubstitution } =
dnrTestUtils;
await testInvalidRegexSubstitution(
"redirect.regexSubstitution only allows digit or \\ after \\.",
"\\x should not be allowed in regexSubstitution"
);
await testInvalidRegexSubstitution(
"redirect.regexSubstitution only allows digit or \\ after \\.",
"\\<end> should not be allowed in regexSubstitution"
);
await testRejectedRedirectAtRuntime({
regexSubstitution: "not-URL",
});
await testRejectedRedirectAtRuntime({
regexSubstitution: "javascript://-URL",
});
// May be allowed once bug 1622986 is fixed.
await testRejectedRedirectAtRuntime({
regexSubstitution: "data:,redirect-from-dnr",
});
await testRejectedRedirectAtRuntime({
regexSubstitution: "resource://gre/",
});
browser.test.notifyPass();
},
});
});
AddonTestUtils.checkMessages(messages, {
expected: [
{
message: /Extension @dnr tried to redirect to an invalid URL: not-URL/,
},
{
message: /Extension @dnr may not redirect to: javascript:\/\/-URL/,
},
{
message: /Extension @dnr may not redirect to: data:,redirect-from-dnr/,
},
{
message: /Extension @dnr may not redirect to: resource:\/\/gre\//,
},
],
});
});
add_task(async function regexSubstitution_valid() {
await runAsDNRExtension({
background: async dnrTestUtils => {
const { testValidRegexSubstitution } = dnrTestUtils;
await testValidRegexSubstitution({
description: "All captured groups can be accessed by \\1 - \\9",
regexFilter: "from/(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)",
// ^ captured groups: 123456789
expectedRedirectUrl: "http://dest/hg?fedcba",
});
await testValidRegexSubstitution({
description: "\\0 captures the full match",
regexFilter: "from/$",
regexSubstitution: "http://dest/\\0/end",
inputUrl: "http://from/",
expectedRedirectUrl: "http://dest/from//end",
});
await testValidRegexSubstitution({
description: "\\10 means: captured group 1 + literal 0",
regexFilter: "/(captured)$",
regexSubstitution: "http://dest/\\10",
inputUrl: "http://from/captured",
expectedRedirectUrl: "http://dest/captured0",
});
await testValidRegexSubstitution({
description: "\\\\ is an escaped backslash",
regexFilter: "/(XXX)",
regexSubstitution: "http://dest/?\\1\\\\1\\\\\\\\1\\1",
inputUrl: "http://from/XXX",
expectedRedirectUrl: "http://dest/?XXX\\1\\\\1XXX",
});
await testValidRegexSubstitution({
description: "Captured groups can be repeated",
regexFilter: "/(captured)$",
regexSubstitution: "http://dest/\\1+\\1",
inputUrl: "http://from/captured",
expectedRedirectUrl: "http://dest/captured+captured",
});
await testValidRegexSubstitution({
description: "Non-matching optional group is an empty string",
regexFilter: "(doesnotmatch)?suffix",
regexSubstitution: "http://dest/[\\1]=group1_is_optional",
inputUrl: "http://from/suffix",
expectedRedirectUrl: "http://dest/[]=group1_is_optional",
});
await testValidRegexSubstitution({
description: "Non-existing capturing group is an empty string",
regexFilter: "(captured)",
regexSubstitution: "http://dest/[\\2]=missing_group_2",
inputUrl: "http://from/captured",
expectedRedirectUrl: "http://dest/[]=missing_group_2",
});
await testValidRegexSubstitution({
description: "Non-capturing group is not captured",
regexFilter: "(?:non-)(captured)",
});
browser.test.notifyPass();
},
});
});
add_task(async function regexSubstitution_redirect_chain() {
await runAsDNRExtension({
background: async dnrTestUtils => {
const { testValidRegexSubstitution } = dnrTestUtils;
await testValidRegexSubstitution({
description: "regexFilter matches intermediate redirect URLs",
regexFilter: "^(http://from/)(a|b|c)(.+)",
regexSubstitution: "\\1\\3",
inputUrl: "http://from/abcdef",
// After redirecting three times, we end up here:
expectedRedirectUrl: "http://from/def",
});
browser.test.notifyPass();
},
});
});