From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../test/xpcshell/test_ext_dnr_regexFilter.js | 590 +++++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js') diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js new file mode 100644 index 0000000000..0ee1bff815 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js @@ -0,0 +1,590 @@ +"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: [""], + 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 +// documented at https://webkit.org/blog/3476/content-blockers-first-look/, +// 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 +// https://github.com/w3c/webextensions/issues/344 +// +// 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: [ + "http://example.com/", + // dot is wildcard. + "http://example-com/", + // Without ^ anchor, matches substring elsewhere. + "http://from/http://example.com/", + ], + urlsNonMatching: [ + "http://dest/http://example.com-no-slash-after-.com", + // Does not match reference fragment. + "http://dest/#http://example.com/", + ], + }); + + 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: [ + "http://from/ad", + "http://from/abcd", + "http://from/aaabbcd", + ], + 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 + // https://github.com/w3c/webextensions/issues/344#issuecomment-1430358116 + + 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,}$", + urlsMatching: ["http://from/aa", "http://from/aaa", "http://from/aaaa"], + 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 + // https://github.com/w3c/webextensions/issues/344#issuecomment-1430127543 + 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: + // https://github.com/w3c/webextensions/issues/344#issuecomment-1424527627 + 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)], + urlsNonMatching: ["http://from/http://104.154.0.0/too-short"], + }); + + 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: [], + urlsNonMatching: ["http://FROM/canonical_host_is_lowercase"], + }); + + 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( + "http://dest/\\x20", + "redirect.regexSubstitution only allows digit or \\ after \\.", + "\\x should not be allowed in regexSubstitution" + ); + + await testInvalidRegexSubstitution( + "http://dest/?\\", + "redirect.regexSubstitution only allows digit or \\ after \\.", + "\\ should not be allowed in regexSubstitution" + ); + + await testRejectedRedirectAtRuntime({ + regexSubstitution: "not-URL", + url: "http://from/should_not_be_directed_invalid_url", + }); + + await testRejectedRedirectAtRuntime({ + regexSubstitution: "javascript://-URL", + url: "http://from/should_not_be_directed_javascript_url", + }); + + // May be allowed once bug 1622986 is fixed. + await testRejectedRedirectAtRuntime({ + regexSubstitution: "data:,redirect-from-dnr", + url: "http://from/should_not_be_directed_disallowed_url", + }); + + await testRejectedRedirectAtRuntime({ + regexSubstitution: "resource://gre/", + url: "http://from/should_not_be_directed_resource_url", + }); + + 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/(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)(.)", + regexSubstitution: "http://dest/\\9\\8\\7\\6\\5\\4\\3\\2\\1", + inputUrl: "http://from/abcdef?gh-ignoredsuffix", + // ^ 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)", + regexSubstitution: "http://dest/[\\1]=only_captured_group", + inputUrl: "http://from/non-captured", + expectedRedirectUrl: "http://dest/[captured]=only_captured_group", + }); + + 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(); + }, + }); +}); -- cgit v1.2.3