diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js')
-rw-r--r-- | toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js | 723 |
1 files changed, 723 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js new file mode 100644 index 0000000000..de01169dea --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js @@ -0,0 +1,723 @@ +"use strict"; + +// The validate_action_redirect_transform task of test_ext_dnr_session_rules.js +// confirms that redirect transform rules meet some minimum bar of validation. +// Despite passing validation, there are still interesting cases to explore, +// ranging from verifying that special characters appear as expected, to +// verifying that an invalid URL (e.g. too long after the transform) is handled +// reasonably well. + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // Allow navigation to URLs with embedded credentials, without prompt. + Services.prefs.setBoolPref("network.auth.confirmAuth.enabled", false); +}); + +const server = createHttpServer({ + hosts: ["from", "dest", "127.0.0.127", "[::1]", "xn--stra-yna.de", "fqdn."], +}); +server.identity.add("http", "dest", 443); // test_redirect_transform_port +server.identity.add("http", "dest", 700); // test_redirect_transform_port +server.identity.add("http", "dest", 777); // Dummy port in test cases. + +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; + function makeRedirectTransformRule(transform) { + return { + id: 1, + condition: { requestDomains: ["from"] }, + action: { + type: "redirect", + // redirect to "dest" by default, different from "from", to avoid an + // infinite redirect loop. + redirect: { transform: { host: "dest", ...transform } }, + }, + }; + } + async function setRedirectTransform(transform) { + await dnr.updateSessionRules({ + removeRuleIds: [1], + addRules: [makeRedirectTransformRule(transform)], + }); + } + // testFetch is simple/fast, but cannot always be used: + // - when the request URL contains embedded credentials. + // - when the final URL is supposed to contain a reference fragment. + 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"); + } + // testNavigate is the slower, complex version of testFetch. It should be + // used in tests where the username, password or fragment components of a URL + // are significant. + async function testNavigate(from, to, description) { + let resultPromise = new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg, result) { + if (msg === "test_navigate_result") { + browser.test.onMessage.removeListener(listener); + // resolve only resolves on the first call, which is ideal because + // browser.test.onMessage.removeListener does not work (bug 1428213). + resolve(result); + } + }); + }); + browser.test.sendMessage("test_navigate", from); + browser.test.assertDeepEq({ from, to }, await resultPromise, description); + } + Object.assign(dnrTestUtils, { + makeRedirectTransformRule, + setRedirectTransform, + testFetch, + testNavigate, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, manifest }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + web_accessible_resources: [ + { resources: ["war.txt"], matches: ["http://from/*"] }, + ], + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + files: { + "war.txt": "GOOD_RESPONSE", + "nowar.txt": "nowar.txt is not in web_accessible_resources", + }, + }); + extension.onMessage("test_navigate", async url => { + // The DNR rule does not redirect the main frame. + let contentPage = await ExtensionTestUtils.loadContentPage("http://from/"); + info(`Loading ${url}`); + await contentPage.spawn([url], async url => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = url; + await new Promise(resolve => { + frame.onload = resolve; + document.body.appendChild(frame); + }); + }); + let finalURL = contentPage.browsingContext.children[0].currentURI.spec; + await contentPage.close(); + extension.sendMessage("test_navigate_result", { from: url, to: finalURL }); + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +} + +add_task(async function test_redirect_transform_all_at_once() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ + scheme: "http", + username: "a", + password: "b", + host: "dest", + port: "777", + path: "/d", + query: "?e", + queryTransform: null, + fragment: "#f", + }); + await testFetch( + "https://from", + "http://a:b@dest:777/d?e", // note: fetch cannot see '#f'. + "Adds components to minimal URL (fetch)" + ); + await testNavigate( + "https://from", + "http://a:b@dest:777/d?e#f", + "Adds components to minimal URL (navigation)" + ); + + await browser.test.assertRejects( + testFetch("https://user:pass@from:777/path?query#ref"), + "Window.fetch: https://user:pass@from:777/path?query#ref is an url with embedded credentials.", + "fetch does not work with embedded credentials" + ); + await testNavigate( + "https://user:pass@from:777/path?query#ref", + "http://a:b@dest:777/d?e#f", + "Replaces all components in existing URL (navigation)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_scheme() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ scheme: "http" }); + await testFetch("https://from/", "http://dest/", "scheme change"); + await testNavigate( + "https://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query#ref", + "scheme change in complex URL with embedded credentials" + ); + + await setRedirectTransform({ + scheme: "moz-extension", + host: location.hostname, + }); + await testFetch( + "http://from/war.txt", + browser.runtime.getURL("war.txt"), + "Scheme change to moz-extension:-URL" + ); + await testNavigate( + "http://from/war.txt", + browser.runtime.getURL("war.txt"), + "Scheme change to moz-extension:-URL (navigation)" + ); + // While the initiator (extension) would be allowed to read the resource + // due to it being same-origin, the pre-redirect URL (http://from) is not + // matching web_accessible_resources[].matches, so the load is rejected. + // This scenario is also tested in test_ext_dnr_without_webrequest.js, at + // the redirect_request_with_dnr_to_extensionPath task. + await browser.test.assertRejects( + testFetch("http://from/nowar.txt"), + "NetworkError when attempting to fetch resource.", + "Cannot load redirect to moz-extension: not in web_accessible_resources" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_username() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ username: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://:pass@dest:777/path?query#ref", + "username cleared" + ); + + await setRedirectTransform({ username: "new" }); + // Cannot pass credentials to fetch, but can read from response.url: + await testFetch("http://from/", "http://new@dest/", "username added"); + await testNavigate("http://from/", "http://new@dest/", "username added"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://new:pass@dest:777/path?query#ref", + "username changed" + ); + + await setRedirectTransform({ username: "new User:name@%%20/" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://new%20User%3Aname%40%%20%2F:pass@dest:777/path?query#ref", + "username changed to complex value" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_password() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ password: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user@dest:777/path?query#ref", + "password cleared" + ); + + await setRedirectTransform({ password: "new" }); + // Cannot pass credentials to fetch, but can read from response.url: + await testFetch("http://from/", "http://:new@dest/", "password added"); + await testNavigate("http://from/", "http://:new@dest/", "password added"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:new@dest:777/path?query#ref", + "password changed" + ); + + await setRedirectTransform({ password: "new Pass:@%%20/" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:new%20Pass%3A%40%%20%2F@dest:777/path?query#ref", + "password changed to complex value" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_host() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ host: "dest" }); + await testFetch( + "http://from:777/path?query", + "http://dest:777/path?query", + "host changed" + ); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query#ref", + "host changed without affecting embedded credentials" + ); + + await setRedirectTransform({ host: "DEST" }); + await testFetch( + "http://from/", + "http://dest/", + "host changed (non-canonical, upper case)" + ); + + await setRedirectTransform({ host: "%44%65%73%54" }); // "DesT", escaped. + await testFetch( + "http://from:777/", + "http://dest:777/", + "host changed (non-canonical, percent-escaped)" + ); + + await setRedirectTransform({ host: "127.0.0.127" }); + await testFetch( + "http://from/", + "http://127.0.0.127/", + "host change to IPv4" + ); + + await setRedirectTransform({ host: "[::1]" }); + await testFetch("http://from/", "http://[::1]/", "host change to IPv6"); + + await setRedirectTransform({ host: "xn--stra-yna.de" }); + await testFetch( + "http://from/", + "http://xn--stra-yna.de/", + "host change to IDN (internationalized domain name, in punycode)" + ); + + await setRedirectTransform({ host: "straß.de" }); + await testFetch( + "http://from/", + "http://xn--stra-yna.de/", + "host change to IDN (not punycode-encoded)" + ); + + await setRedirectTransform({ host: "fqdn." }); + await testFetch( + "http://from/", + "http://fqdn./", + "host change to FQDN (fully-qualified domain name)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_port() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ port: "" }); + await testFetch("http://from:777/", "http://dest/", "port cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest/path?query#ref", + "port cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ port: "700" }); + await testFetch("http://from/", "http://dest:700/", "port added"); + await testFetch("http://from:777/", "http://dest:700/", "port changed"); + + // 0-padded should not be misinterpreted as an octal number. + await setRedirectTransform({ port: "0700" }); + await testFetch( + "http://from:777/", + "http://dest:700/", + "port changed (non-canonical, 0-padded port)" + ); + + await setRedirectTransform({ port: "80" }); + await testFetch( + "http://from:777/", + "http://dest/", + "port cleared if default protocol" + ); + + await setRedirectTransform({ scheme: "http", port: "443" }); + await testFetch( + "https://from/", + "http://dest:443/", + "port added if new port is not default port of new protocol" + ); + + await setRedirectTransform({ scheme: "http", port: "80" }); + await testFetch( + "https://from:777/", + "http://dest/", + "port cleared if new port is default port of new protocol" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_path() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ path: "" }); + await testFetch("http://from/path", "http://dest/", "path cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/?query#ref", + "path cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ path: "/new" }); + await testFetch("http://from/", "http://dest/new", "path added"); + await testFetch("http://from/path", "http://dest/new", "path changed"); + + await setRedirectTransform({ path: "///" }); + await testFetch("http://from/", "http://dest///", "path added (///)"); + + await setRedirectTransform({ path: "path" }); + await testFetch( + "http://from/", + "http://dest/path", + "path added (non-canonical, missing slash)" + ); + + // " " -> "%20" (space) + // "\x00" -> "%00" (null byte) + // "<>" -> "%3C%3E" (URL encoding of angle brackets) + // "%", "%20", "%3A", "%3a" -> not changed (%-encoding kept as-is). + await setRedirectTransform({ path: "/Path_%_ _%20_?_#_\x00_<>_%3A%3a" }); + await testFetch( + "http://from/", + "http://dest/Path_%_%20_%20_%3F_%23_%00_%3C%3E_%3A%3a", + "path added (non-canonical, partial percent encoding)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_query() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ query: "" }); + await testFetch("http://from/?query", "http://dest/", "query cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path#ref", + "query cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ query: "?new" }); + await testFetch("http://from/", "http://dest/?new", "query added"); + await testFetch( + "http://from/?query", + "http://dest/?new", + "query changed" + ); + + await setRedirectTransform({ query: "?" }); + await testFetch("http://from/", "http://dest/?", "query set to just '?'"); + + await setRedirectTransform({ query: "?Query_#_ _%20_%3a%3A_<>_\x00" }); + await testFetch( + "http://from/", + "http://dest/?Query_%23_%20_%20_%3a%3A_%3C%3E_%00", + "query added (non-canonical, partial percent encoding)" + ); + + // Now rule.action.redirect.transform.queryTransform: + await setRedirectTransform({ + queryTransform: { + removeParams: ["query"], + }, + }); + await testFetch( + "http://from/?query", + "http://dest/", + "queryTransform removed query" + ); + await testFetch( + "http://from/?prefix&query&suffix", + "http://dest/?prefix&suffix", + "queryTransform removed part of query" + ); + await testFetch( + "http://from/?query&aquery&queryb&query=withvalue¬=query&QUERY&", + "http://dest/?aquery&queryb¬=query&QUERY&", + "queryTransform removed all occurrences of 'query' key" + ); + await testFetch( + "http://from/??query", + "http://dest/??query", + "queryTransform does not match param when it starts with '??'" + ); + + await setRedirectTransform({ + queryTransform: { + removeParams: ["query"], + addOrReplaceParams: [{ key: "query", value: "newvalue" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=newvalue", + "queryTransform appended query despite new param being in removeParams" + ); + await testFetch( + "http://from/?prefix&query&suffix", + "http://dest/?prefix&suffix&query=newvalue", + "queryTransform removed query, and appended new value" + ); + await testFetch( + "http://from/??query", + "http://dest/??query&query=newvalue", + "queryTransform ignores existing param starting with '??', and appends" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "query", value: "newvalue" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=newvalue", + "queryTransform appended query" + ); + await testFetch( + "http://from/?prefix&query=oldvalue&query=2&query=3", + "http://dest/?prefix&query=newvalue&query=2&query=3", + "queryTransform replaced the first occurrence and kept the others" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [ + { key: "r", value: "default" }, // default:false + { key: "r", value: "false", replaceOnly: false }, + { key: "r", value: "true", replaceOnly: true }, + { key: "r", value: "false2", replaceOnly: false }, + { key: "r", value: "true2", replaceOnly: true }, + ], + }, + }); + // r=true and r=true2 are missing because there are no matching "r". + await testFetch( + "http://from/", + "http://dest/?r=default&r=false&r=false2", + "queryTransform appends all except replaceOnly=true" + ); + // r=true2 should be missing because there is no matching "r". + await testFetch( + "http://from/?r=1&r=2&r=3&___", + "http://dest/?r=default&r=false&r=true&___&r=false2", + "queryTransform replaced in order and ignores last replaceOnly=true" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [ + { key: "a", value: "appenda" }, + { key: "b", value: "b1" }, + { key: "c", value: "c1" }, + { key: "c", value: "c2" }, + { key: "c", value: "appendc" }, + { key: "d", value: "d1" }, + ], + }, + }); + // Test case has: b c c d. + // Rule only has: appenda b1 c2 appendc d1. + // Expected out : b1 c2 d1 appenda appendc. + await testFetch( + "http://from/?b=01&c=02&c=03&d=06", + "http://dest/?b=b1&c=c1&c=c2&d=d1&a=appenda&c=appendc", + "queryTransform replaces matched queries and appends the rest, in order" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "query", value: " _+_%00_#" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=+_%2B_%2500_%23", + "queryTransform urlencodes values" + ); + + // This part tests how param names with non-alphanumeric characters can be + // (and not be) matched and replaced. This follows Chrome's behavior, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1801870#c1 + await setRedirectTransform({ + queryTransform: { + removeParams: ["?x", "%3Fx", "&x", "%26x"], + addOrReplaceParams: [ + // Internally interpreted as: %3Fp: + { key: "?p", value: "rawq", replaceOnly: true }, + // Internally interpreted as: %253Fp: + { key: "%3Fp", value: "escape_upper_q", replaceOnly: true }, + // Internally interpreted as: %253fp: + { key: "%3fp", value: "escape_lower_q", replaceOnly: true }, + // Internally interpreted as: %26p: + { key: "&p", value: "rawa", replaceOnly: true }, + // Internally interpreted as: %2526p: + { key: "%26p", value: "escape_a", replaceOnly: true }, + ], + }, + }); + await testFetch( + "http://from/?x&x&?x", + "http://dest/?x&x&?x", + "queryTransform does not match the '?' or '&' separators" + ); + await testFetch( + "http://from/??p&&p&?p", + "http://dest/??p&&p&?p", + "queryTransform cannot match literal '?p' because it is not urlencoded" + ); + await testFetch( + "http://from/?%3Fp", + "http://dest/?%3Fp=rawq", + "queryTransform matches already-urlencoded '%3Fp' with raw '?p'" + ); + await testFetch( + "http://from/?%3fp", + "http://dest/?%3fp", + "queryTransform cannot match non-canonical percent encoding (lowercase)" + ); + await testFetch( + "http://from/?%253fp&%253Fp", + "http://dest/?%253fp=escape_lower_q&%253Fp=escape_upper_q", + "queryTransform matches double-urlencoded '?p' with single-encoded '?p'" + ); + await testFetch( + "http://from/?%26p", + "http://dest/?%26p=rawa", + "queryTransform matches already-urlencoded '%26p' with raw '&p'" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_fragment() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + // Note: not using testFetch because it cannot see fragment changes. + const { setRedirectTransform, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ fragment: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query", + "fragment cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ fragment: "#new" }); + await testNavigate("http://from/", "http://dest/#new", "fragment added"); + await testNavigate( + "http://from/#ref", + "http://dest/#new", + "fragment changed" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_failed_at_runtime() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform } = dnrTestUtils; + + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + const network_standard_url_max_length = 1048576; + // updateSessionRules does some validation on the limit (as seen by + // validate_action_redirect_transform in test_ext_dnr_session_rules.js), + // but it is still possible to pass validation and fail in practice when + // the existing URL + new component exceeds the limit. + const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20); + + // Like testFetch, except truncates URLs in log messages to avoid logspam. + async function testFetchPossiblyLongUrl(from, to, body, description) { + let res = await fetch(from); + const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + browser.test.assertEq(shortx(to), shortx(res.url), description); + browser.test.assertEq(body, await res.text(), "expected body"); + } + + await setRedirectTransform({ query: "?" + VERY_LONG_STRING }); + await testFetchPossiblyLongUrl( + "http://from/short", + `http://dest/short?${VERY_LONG_STRING}`, + // Somehow the httpd server raises NS_ERROR_MALFORMED_URI when it tries + // to use newURI to parse the received URL. But the server responding + // with that implies that the redirect was successful, so for the + // purpose of this test, that response is acceptable. + "Bad request\n", + "Can redirect to URL near (but not over) url max-length" + ); + + // This check confirms that not only does the request not redirect to + // an invalid URL, but also that the request does not somehow end up in + // an infinite redirect loop. + await testFetchPossiblyLongUrl( + "http://from/1234567890_1234567890", + "http://from/1234567890_1234567890", + "GOOD_RESPONSE", + "Redirect to URL over max length is ignored; request continues" + ); + + browser.test.notifyPass(); + }, + }); +}); |