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_allowAllRequests.js | 1231 ++++++++++++++++++++ 1 file changed, 1231 insertions(+) create mode 100644 toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js') diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js new file mode 100644 index 0000000000..ccb380180f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js @@ -0,0 +1,1231 @@ +"use strict"; + +// This file tests whether the "allowAllRequests" action is correctly applied +// to subresource requests. The relative precedence to other actions/extensions +// is tested in test_ext_dnr_testMatchOutcome.js, specifically by test tasks +// rule_priority_and_action_type_precedence and +// action_precedence_between_extensions. + +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org"], +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); +}); +server.registerPathHandler("/allowed", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + // Any test that is able to check the response body will be able to assert + // the response body's value. Let's use "fetchAllowed" so that the compared + // values are obvious when assertEq/assertDeepEq are used. + res.write("fetchAllowed"); +}); +server.registerPathHandler("/", (req, res) => { + res.write("Dummy page"); +}); +server.registerPathHandler("/echo_html", (req, res) => { + let code = decodeURIComponent(req.queryString); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + if (req.hasHeader("prependhtml")) { + code = req.getHeader("prependhtml") + code; + } + res.write(`${code}`); +}); +server.registerPathHandler("/bfcache_test", (req, res) => { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.write(``); +}); + +async function waitForRequestAtServer(path) { + return new Promise(resolve => { + let callCount = 0; + server.registerPathHandler(path, (req, res) => { + Assert.equal(++callCount, 1, `Got one request for: ${path}`); + res.processAsync(); + resolve({ req, res }); + }); + }); +} + +// Several tests expect fetch() to fail due to the request being blocked. +// They can use testLoadInFrame({ ..., expectedError: FETCH_BLOCKED }). +const FETCH_BLOCKED = + "TypeError: NetworkError when attempting to fetch resource."; + +function urlEchoHtml(domain, html) { + return `http://${domain}/echo_html?${encodeURIComponent(html)}`; +} + +function htmlEscape(html) { + return html + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +// Values for domains in testLoadInFrame. +const ABOUT_SRCDOC_SAME_ORIGIN = "about:srcdoc (same-origin)"; +const ABOUT_SRCDOC_CROSS_ORIGIN = "about:srcdoc (cross-origin)"; + +async function testLoadInFrame({ + description, + // domains[0] = main frame, every extra item is a child frame. + domains = ["example.com"], + htmlPrependedToEachFrame = "", + // jsForFrame will be serialized and run in the deepest frame. + jsForFrame, + // The expected (potentially async) return value of jsForFrame. + expectedResult, + // The expected (potentially async) error thrown from jsForFrame. + expectedError, +}) { + const frameJs = async jsForFrame => { + let result = {}; + try { + result.returnValue = await jsForFrame(); + } catch (e) { + result.error = String(e); + } + // jsForFrame may return "delay_postMessage" to postpone the resolution of + // the promise. When the test is ready to resume, `top.postMessage()` can + // be called with the result, from any frame. This would also happen if the + // URL generated by this testLoadInFrame helper are re-used, e.g. by a new + // navigation to the URL that triggers a return value from jsForFrame that + // differs from "delay_postMessage". + if (result.returnValue !== "delay_postMessage") { + top.postMessage(result, "*"); + } + }; + const frameHtml = ``; + + // Construct the frame tree so that domains[0] is the main frame, and + // domains[domains.length - 1] is the deepest level frame (if any). + + const [mainFrameDomain, ...subFramesDomains] = domains; + + // The loop below generates the HTML for the deepest frame first, so we have + // to reverse the list of domains. + subFramesDomains.reverse(); + + let html = frameHtml; + for (let domain of subFramesDomains) { + html = htmlPrependedToEachFrame + html; + if (domain === ABOUT_SRCDOC_SAME_ORIGIN) { + html = ``; + } else if (domain === ABOUT_SRCDOC_CROSS_ORIGIN) { + html = ``; + } else { + html = ``; + } + } + + const mainFrameJs = () => { + window.resultPromise = new Promise(resolve => { + window.onmessage = e => resolve(e.data); + }); + }; + const mainFrameHtml = `${html}`; + const mainFrameUrl = urlEchoHtml(mainFrameDomain, mainFrameHtml); + + let contentPage = await ExtensionTestUtils.loadContentPage(mainFrameUrl); + let result = await contentPage.spawn([], () => { + return content.wrappedJSObject.resultPromise; + }); + await contentPage.close(); + if (expectedError) { + Assert.deepEqual(result, { error: expectedError }, description); + } else { + Assert.deepEqual(result, { returnValue: expectedResult }, description); + } +} + +async function loadExtensionWithDNRRules( + rules, + { + // host_permissions is only required for modifyHeaders/redirect, or when + // "declarativeNetRequestWithHostAccess" is used. + host_permissions = [], + permissions = ["declarativeNetRequest"], + } = {} +) { + async function background(rules) { + try { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: rules, + }); + } catch (e) { + browser.test.fail(`Failed to register DNR rules: ${e} :: ${e.stack}`); + } + browser.test.sendMessage("dnr_registered"); + } + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(rules)})`, + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions, + permissions, + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + return extension; +} + +add_task(async function allowAllRequests_allows_request() { + let extension = await loadExtensionWithDNRRules([ + // allowAllRequests should take precedence over block. + { + id: 1, + condition: { resourceTypes: ["main_frame", "xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + priority: 2, + // Note: when not specified, main_frame is excluded by default. So + // when a main_frame request is triggered, only rules 1 and 2 match. + condition: { requestDomains: ["example.com"] }, + action: { type: "block" }, + }, + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/" + ); + Assert.equal( + await contentPage.spawn([], () => content.document.URL), + "http://example.com/", + "main_frame request should have been allowed by allowAllRequests" + ); + + async function checkCanFetch(url) { + return contentPage.spawn([url], async url => { + try { + return await (await content.fetch(url)).text(); + } catch (e) { + return e.toString(); + } + }); + } + + Assert.equal( + await checkCanFetch("http://example.com/never_reached"), + FETCH_BLOCKED, + "should be blocked by DNR rule 3" + ); + Assert.equal( + await checkCanFetch("http://example.net/allowed"), + "fetchAllowed", + "should not be blocked by block rule due to allowAllRequests rule" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function allowAllRequests_in_sub_frame() { + const extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { + requestDomains: ["example.com"], + resourceTypes: ["main_frame", "sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + ]); + + const testFetch = async () => { + // Should be able to read, unless blocked by DNR rule 1 above. + return (await fetch("http://example.com/allowed")).text(); + }; + + // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED) + // when the "allowAllRequests" rule (rule ID 2) is not matched. + await testLoadInFrame({ + description: "allowAllRequests was not matched anywhere, req in subframe", + domains: ["example.net", "example.org"], + jsForFrame: testFetch, + expectedError: FETCH_BLOCKED, + }); + + // allowAllRequests applied to domains[0], i.e. "main_frame". + await testLoadInFrame({ + description: "allowAllRequests for main frame, req in main frame", + domains: ["example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for main frame, req in same-origin frame", + domains: ["example.com", "example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for main frame, req in cross-origin frame", + domains: ["example.com", "example.net"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + + // allowAllRequests applied to domains[1], i.e. "sub_frame". + await testLoadInFrame({ + description: "allowAllRequests for subframe, req in same subframe", + domains: ["example.net", "example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for subframe, req in same-origin subframe", + domains: ["example.net", "example.com", "example.com"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "allowAllRequests for subframe, req in cross-origin subframe", + domains: ["example.net", "example.com", "example.org"], + jsForFrame: testFetch, + expectedResult: "fetchAllowed", + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_does_not_affect_other_extension() { + const extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]); + const otherExtension = await loadExtensionWithDNRRules([ + { + id: 2, + condition: { resourceTypes: ["main_frame", "sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + ]); + + const testFetch = async () => { + return (await fetch("http://example.com/allowed")).text(); + }; + + // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED) + // when the "allowAllRequests" rule (rule ID 2) is not matched. + await testLoadInFrame({ + description: "block rule from extension not superseded by otherExtension", + domains: ["example.net", "example.org"], + jsForFrame: testFetch, + expectedError: FETCH_BLOCKED, + }); + + await extension.unload(); + await otherExtension.unload(); +}); + +// When there are multiple frames and matching allowAllRequests, we need to +// use the highest-priority allowAllRequests rule. The selected rule can be +// observed through interleaved modifyHeaders rules. +add_task(async function allowAllRequests_multiple_frames_and_modifyHeaders() { + const domains = ["example.com", "example.com", "example.net", "example.org"]; + const rules = [ + { + id: 1, + priority: 3, + condition: { requestDomains: [domains[1]], resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + priority: 7, + condition: { requestDomains: [domains[2]], resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + priority: 5, + condition: { requestDomains: [domains[3]], resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + // The loop below will add modifyHeaders rules with priorities 1 - 9. + ]; + for (let i = 1; i <= 9; ++i) { + rules.push({ + id: 10 + i, // not overlapping with any rule in |rules|. + priority: i, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + // Expose the header via CORS to allow fetch() to read the header. + operation: "set", + header: "Access-Control-Expose-Headers", + value: "addedByDnr", + }, + { operation: "append", header: "addedByDnr", value: `${i}` }, + ], + }, + }); + } + + const extension = await loadExtensionWithDNRRules(rules, { + // host_permissions required for "modifyHeaders" action. + host_permissions: [""], + }); + + await testLoadInFrame({ + description: "Should select highest-prio allowAllRequests among ancestors", + domains, + jsForFrame: async () => { + let res = await fetch("http://example.com/allowed"); + return res.headers.get("addedByDnr"); + }, + // The fetch request matches all xmlhttprequest rules, which would append + // the numbers 1...9 to the results via "modifyHeaders". + // + // But every frame also has one matching "allowAllRequests" rule. Among + // these, we should not select an arbitrary rule, but the one with the + // highest priority, i.e. priority 7 (matches domains[2]). + // + // Given the "allowAllRequests" of priority 7, all rules of lower-or-equal + // priority are ignored, so only "modifyHeaders" remain with priority 8 & 9. + // + // modifyHeaders are applied in the order of priority: "9, 8", not "8, 9". + expectedResult: "9, 8", + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_initiatorDomains() { + const rules = [ + { + id: 1, + condition: { + initiatorDomains: ["example.com"], // Note: in host_permissions below. + resourceTypes: ["main_frame", "sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { + initiatorDomains: ["example.net"], // Note: NOT in host_permissions. + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + // host_permissions matches initiatorDomains from rule 1 (allowAllRequests) + // and the origin of the frame that calls testCanFetch. + host_permissions: ["*://example.com/*", "*://example.org/*"], + }); + + const testCanFetch = async () => { + return (await fetch("http://example.com/allowed")).text(); + }; + + await testLoadInFrame({ + description: "main_frame request does not have an initiator", + domains: ["example.com"], + jsForFrame: testCanFetch, + // Rule 1 (initiatorDomains: ["example.com"]) should not match. + expectedError: FETCH_BLOCKED, + }); + await testLoadInFrame({ + description: "sub_frame loaded by initiator in host_permissions", + domains: ["example.com", "example.org"], + jsForFrame: testCanFetch, + // Matched by rule 1 (initiatorDomains: ["example.com"]) + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "sub_frame loaded by initiator not in host_permissions", + domains: ["example.net", "example.org"], + jsForFrame: testCanFetch, + // Matched by rule 2 (initiatorDomains: ["example.net"]). While example.net + // is not in host_permissions, the "allowAllRequests" rule can apply because + // the extension does have the "declarativeNetRequest" permission (opposed + // to just "declarativeNetRequestWithHostAccess", which is covered by the + // allowAllRequests_initiatorDomains_dnrWithHostAccess test task below). + expectedResult: "fetchAllowed", + }); + + // about:srcdoc inherits parent origin. + await testLoadInFrame({ + description: "about:srcdoc with matching initiator", + domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN], + jsForFrame: testCanFetch, + // While the "about:srcdoc" frame's initiator is matched by rule 1 + // (initiatorDomains: ["example.com"]), the frame's URL itself is + // "about:srcdoc" and consequently ignored in the matcher. + expectedError: FETCH_BLOCKED, + }); + await testLoadInFrame({ + description: "subframe in about:srcdoc with matching initiator", + domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN, "example.org"], + jsForFrame: testCanFetch, + // The parent URL is "about:srcdoc", but its principal is inherit from its + // parent, i.e. "example.com". Therefore it matches rule 1. + expectedResult: "fetchAllowed", + }); + await testLoadInFrame({ + description: "subframe in opaque about:srcdoc despite matching initiator", + domains: ["example.com", ABOUT_SRCDOC_CROSS_ORIGIN, "example.org"], + jsForFrame: testCanFetch, + // The parent URL is "about:srcdoc". Because it is sandboxed, it has an + // opaque origin and therefore none of the allowAllRequests rules match, + // even not rule 1 even though the "about:srcdoc" frame was created by + // "example.com". + expectedError: FETCH_BLOCKED, + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_initiatorDomains_dnrWithHostAccess() { + const rules = [ + { + id: 1, + condition: { + // This test shows that it does not matter whether initiatorDomains is + // in host_permissions; it only matters if the frame's URL is matched + // by host_permissions. + initiatorDomains: ["example.net"], // Not in host_permissions. + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + host_permissions: ["*://example.org/*"], + permissions: ["declarativeNetRequestWithHostAccess"], + }); + + const testCanFetch = async () => { + // example.org is in host_permissions above so "xmlhttprequest" rule is + // always expected to match this, unless "allowAllRequests" applied. + // If "allowAllRequests" applies, then expectedResult: "fetchAllowed". + // If "allowAllRequests" did not apply, then expectedError: FETCH_BLOCKED. + return (await fetch("http://example.org/allowed")).text(); + }; + + await testLoadInFrame({ + description: + "frame URL in host_permissions despite initiator not in host_permissions", + domains: ["example.com", "example.net", "example.org"], + jsForFrame: testCanFetch, + // The "xmlhttprequest" block rule applies because the request URL + // (example.org) and initiator (example.org) are part of host_permissions. + // + // The "allowAllRequests" rule applies and overrides the block because the + // "example.org" frame has "example.net" as initiator (as specified in the + // initiatorDomains DNR rule). Despite the lack of host_permissions for + // "example.net", the DNR rule is matched because navigation requests do + // not require host permissions. + expectedResult: "fetchAllowed", + }); + + await testLoadInFrame({ + description: "frame URL and initiator not in host_permissions", + domains: ["example.net", "example.com", "example.org"], + jsForFrame: testCanFetch, + // The "xmlhttprequest" block rule applies because the request URL + // (example.org) and initiator (example.org) are part of host_permissions. + // + // The "allowAllRequests" rule does not apply because it would only apply + // to the "example.com" frame (that frame has "example.net" as initiator), + // but the DNR extension does not have host permissions for example.com. + expectedError: FETCH_BLOCKED, + }); + + await extension.unload(); +}); + +add_task(async function allowAllRequests_initiator_is_parent() { + // The actual initiator of a request is the principal (origin) that triggered + // the request. Navigations of subframes are usually triggered by the parent, + // except in case of cross-frame/window navigations. + // + // There are some limits on cross-frame navigations, specified by: + // https://html.spec.whatwg.org/multipage/browsing-the-web.html#allowed-to-navigate + // An ancestor can always navigate a descendant, so we do that here. + // + // - example.com (main frame) + // - example.net (sub frame 1) + // - example.org (sub frame 2) + // - example.com (sub frame 3) - will be navigated by sub frame 1. + // + // "initiatorDomains" is usually matched against the actual initiator of a + // request. Since the actual initiator (triggering principal) is not always + // known nor obvious, the parent principal (origin) is used instead, when the + // conditions for "allowAllRequests" are retroactively checked for a document. + const domains = ["example.com", "example.net", "example.org", "example.com"]; + const rules = [ + { + id: 1, + condition: { + // Note: restrict to example.org, so that we can verify that the + // "allowAllRequests" rule applies to subresource requests within any + // child frame of "example.org" (i.e. that rule 3 is ignored). + // + // Side note: the ultimate navigation request for the child frame + // itself has actual initiator "example.net" and does not match this + // rule, which we verify by confirming that rule 2 matches. + initiatorDomains: ["example.org"], + requestDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + // The modifyHeaders rules below are not affected by the "allowAllRequests" + // rule above, but are part of the test to serve as a sanity check that the + // "initiatorDomains" field of sub_frame navigations are compared against + // the actual initiator. + { + id: 3, + priority: 2, // To not be ignored by allowAllRequests (rule 1). + condition: { + // The initial sub_frame navigation request is initiated by its parent, + // i.e. example.org. + initiatorDomains: ["example.org"], + requestDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { + operation: "append", + header: "prependhtml", + value: "DNR rule 3 for initiator example.org", + }, + ], + }, + }, + { + id: 4, + condition: { + // The final sub_frame navigation request is initiated by a frame other + // than the parent (i.e. example.net). + initiatorDomains: ["example.net"], + requestDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { + operation: "append", + header: "prependhtml", + value: "DNR rule 4 for initiator example.net", + }, + ], + }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + // host_permissions needed for allowAllRequests of ancestors + // (initiatorDomains & requestDomains) and modifyHeaders. + host_permissions: [""], + }); + + const jsNavigateOnMessage = () => { + window.onmessage = e => { + dump(`\nReceived message at ${origin} from ${e.origin}: ${e.data}\n`); + e.source.location = e.data; + }; + }; + const htmlNavigateOnMessage = ``; + + // First: sanity check that the actual initiators are as expected, which we + // verify through the modifyHeaders+initiatorDomains rules, observed through + // document.title (/echo_html prepends the "prependhtml" header's value). + await testLoadInFrame({ + description: "Sanity check: navigation matches actual initiator (parent)", + domains, + jsForFrame: () => document.title, + expectedResult: "DNR rule 3 for initiator example.org", + }); + + await testLoadInFrame({ + description: "Sanity check: navigation matches actual initiator (ancestor)", + domains, + htmlPrependedToEachFrame: htmlNavigateOnMessage, + jsForFrame: () => { + if (location.hash !== "#End") { + dump("Sanity: Trying to navigate with initiator set to example.net\n"); + parent.parent.postMessage(document.URL + ".#End", "http://example.net"); + return "delay_postMessage"; + } + return document.title; + }, + expectedResult: "DNR rule 4 for initiator example.net", + }); + + // Now the actual test: when fetch() is called, "allowAllRequests" should use + // the parent origin for each frame in the frame tree. + + await testLoadInFrame({ + description: "allowAllRequests matches parent (which is the initiator)", + domains, + jsForFrame: async () => { + return (await fetch("http://example.com/allowed")).text(); + }, + expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2. + }); + + // This is where the result differs from what one may expect from + // "initiatorDomains". This is consistent with Chrome's behavior, + // https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/declarative_net_request/request_params.cc;l=123-130;drc=8a27797c643fb0f2d9ae835f8d8b509e027c97e9 + await testLoadInFrame({ + description: "allowAllRequests matches parent (not actual initiator)", + domains, + htmlPrependedToEachFrame: htmlNavigateOnMessage, + jsForFrame: async () => { + if (location.hash !== "#End") { + dump("Final: Trying to navigate with initiator set to example.net\n"); + parent.parent.postMessage(document.URL + ".#End", "http://example.net"); + return "delay_postMessage"; + } + return (await fetch("http://example.com/allowed")).text(); + }, + expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2. + }); + + await extension.unload(); +}); + +// Tests how initiatorDomains applies to document and non-document (fetch) +// requests triggered from content scripts. +add_task(async function allowAllRequests_initiatorDomains_content_script() { + const rules = [ + { + id: 1, + condition: { + initiatorDomains: ["example.com"], + resourceTypes: ["sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 3, + condition: { + resourceTypes: ["sub_frame"], + requestDomains: ["example.com"], + }, + action: { + type: "redirect", + redirect: { transform: { host: "example.net" } }, + }, + }, + ]; + + const extension = await loadExtensionWithDNRRules(rules, { + host_permissions: ["*://example.com/*", "*://example.net/*"], + }); + + let contentScriptExtension = ExtensionTestUtils.loadExtension({ + manifest: { + // Intentionally MV2 because its fetch() is tied to the content script + // sandbox, and thus potentially more likely to trigger bugs than the MV3 + // fetch (fetch in MV3 is the same as the web page due to bug 1578405). + manifest_version: 2, + content_scripts: [ + { + run_at: "document_end", + js: ["contentscript_load_frame.js"], + matches: ["http://*/?test_contentscript_load_frame"], + }, + { + all_frames: true, + run_at: "document_end", + js: ["contentscript_in_iframe.js"], + matches: ["http://example.net/?test_contentscript_triggered_frame"], + }, + ], + }, + files: { + "contentscript_load_frame.js": () => { + browser.test.log("Waiting for frame, then contentscript_in_iframe.js"); + // Created by content script; initiatorDomains should match the page's + // domain (and not somehow be confused by the content script principal). + // let document = window.document.wrappedJSObject; + let f = document.createElement("iframe"); + f.src = "http://example.com/?test_contentscript_triggered_frame"; + document.body.append(f); + }, + "contentscript_in_iframe.js": async () => { + // When the iframe request was generated by the content script, its + // initiator is void because the content script has an ExpandedPrincipal + // that is treated as void when the request initiator is computed: + // https://searchfox.org/mozilla-central/rev/d85572c1963f72e8bef2787d900e0a8ffd8e6728/toolkit/components/extensions/webrequest/ChannelWrapper.cpp#551 + // Therefore the initiatorDomains condition of rule 1 (allowAllRequests) + // does not match, so rule 3 (redirect to example.net) applies. + browser.test.assertEq( + "example.net", // instead of the pre-redirect URL (example.com). + location.host, + "redirect rule matched because initiator is void for content-script-triggered navigation" + ); + async function isFetchOk(fetchPromise) { + try { + await fetchPromise; + return true; // allowAllRequests matched. + } catch (e) { + await browser.test.assertRejects(fetchPromise, /NetworkError/); + return false; // block rule matched because allowAllRequests didn't. + } + } + browser.test.assertTrue( + await isFetchOk(content.fetch("http://example.net/allowed")), + "frame's parent origin matches initiatorDomains (content script fetch)" + ); + // fetch() in MV2 content script is associated with the content script + // sandbox, not the frame, so there are no allowAllRequests rules to + // apply. For equivalent request details, see bug 1444729. + browser.test.assertFalse( + await isFetchOk(fetch("http://example.net/allowed")), + "MV2 content script fetch() is not associated with the document" + ); + browser.test.sendMessage("contentscript_initiator"); + }, + }, + }); + await contentScriptExtension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?test_contentscript_load_frame" + ); + info("Waiting for page load, will continue at contentscript_load_frame.js"); + await contentScriptExtension.awaitMessage("contentscript_initiator"); + await contentScriptExtension.unload(); + await contentPage.close(); + await extension.unload(); +}); + +// Verifies that allowAllRequests is evaluated against the currently committed +// document, even if another document load has been initiated. +add_task(async function allowAllRequests_during_and_after_navigation() { + let extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { urlFilter: "WITH_AAR", resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }, + ]); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?dummy_see_iframe_for_interesting_stuff" + ); + await contentPage.spawn([], async () => { + let f = content.document.createElement("iframe"); + f.id = "frame_to_navigate"; + f.src = "/?init_WITH_AAR"; // allowAllRequests initially applies. + await new Promise(resolve => { + f.onload = resolve; + content.document.body.append(f); + }); + }); + async function navigateIframe(url) { + await contentPage.spawn([url], url => { + let f = content.document.getElementById("frame_to_navigate"); + content.frameLoadedPromise = new Promise(resolve => { + f.addEventListener("load", resolve, { once: true }); + }); + f.contentWindow.location.href = url; + }); + } + async function waitForNavigationCompleted(expectLoad = true) { + await contentPage.spawn([expectLoad], async expectLoad => { + if (expectLoad) { + info("Waiting for frame load - if stuck the load never happened\n"); + return content.frameLoadedPromise.then(() => {}); + } + // When HTTP 204 No Content is used, onload is not fired. + // Here we load another frame, and assume that once this completes, that + // any previous load of navigateIframe() would have completed by now. + let f = content.document.createElement("iframe"); + f.src = "/?dummy_no_dnr_matched_" + Math.random(); + await new Promise(resolve => { + f.onload = resolve; + content.document.body.append(f); + }); + f.remove(); + }); + } + async function assertIframePath(expectedPath, description) { + let actualPath = await contentPage.spawn([], () => { + return content.frames[0].location.pathname; + }); + Assert.equal(actualPath, expectedPath, description); + } + async function assertHasAAR(expected, description) { + let actual = await contentPage.spawn([], async () => { + try { + await (await content.frames[0].fetch("/allowed")).text(); + return true; // allowAllRequests overrides block rule. + } catch (e) { + // Sanity check: NetworkError from fetch(), not a random other error. + Assert.equal( + e.toString(), + "TypeError: NetworkError when attempting to fetch resource.", + "Got error for failed fetch" + ); + return false; // blocked by xmlhttprequest block rule. + } + }); + Assert.equal(actual, expected, description); + } + await assertHasAAR(true, "Initial allowAllRequests overrides block rule"); + + const PATH_1_NO_AAR = "/delayed/PATH_1_NO_AAR"; + const PATH_2_WITH_AAR = "/delayed/PATH_2_WITH_AAR"; + const PATH_3_NO_AAR = "/delayed/PATH_3_NO_AAR"; + info("First: transition from /?init_WITH_AAR to PATH_NOT_MATCHED_BY_DNR."); + { + let promisedServerReq = waitForRequestAtServer(PATH_1_NO_AAR); + await navigateIframe(PATH_1_NO_AAR); + let serverReq = await promisedServerReq; + await assertHasAAR( + true, + "Initial allowAllRequests still applies despite pending navigation" + ); + await assertIframePath("/", "Frame has not navigated yet"); + serverReq.res.finish(); + await waitForNavigationCompleted(); + await assertIframePath(PATH_1_NO_AAR, "Navigated to PATH_1_NO_AAR"); + + await assertHasAAR( + false, + "Old allowAllRequests should no longer apply after navigation to PATH_1_NO_AAR" + ); + } + + info("Second: transition from PATH_1_NO_AAR to PATH_2_WITH_AAR."); + { + let promisedServerReq = waitForRequestAtServer(PATH_2_WITH_AAR); + await navigateIframe(PATH_2_WITH_AAR); + let serverReq = await promisedServerReq; + await assertHasAAR( + false, + "No allowAllRequests yet despite pending navigation to PATH_2_WITH_AAR" + ); + await assertIframePath(PATH_1_NO_AAR, "Frame has not navigated yet"); + serverReq.res.finish(); + await waitForNavigationCompleted(); + await assertIframePath(PATH_2_WITH_AAR, "Navigated to PATH_2_WITH_AAR"); + + await assertHasAAR( + true, + "allowAllRequests should apply after navigation to PATH_2_WITH_AAR" + ); + } + + info("Third: AAR still applies after canceling navigation to PATH_3_NO_AAR."); + { + let promisedServerReq = waitForRequestAtServer(PATH_3_NO_AAR); + await navigateIframe(PATH_3_NO_AAR); + let serverReq = await promisedServerReq; + serverReq.res.setStatusLine(serverReq.req.httpVersion, 204, "No Content"); + serverReq.res.finish(); + await waitForNavigationCompleted(/* expectLoad */ false); + await assertIframePath(PATH_2_WITH_AAR, "HTTP 204 does not navigate away"); + + await assertHasAAR( + true, + "allowAllRequests still applied after aborted navigation to PATH_3_NO_AAR" + ); + } + + await contentPage.close(); + await extension.unload(); +}); + +add_task( + { + // Ensure that there is room for at least 2 non-evicted bfcache entries. + // Note: this pref is ignored (i.e forced 0) when configured (non-default) + // with bfcacheInParent=false while SHIP is enabled: + // https://searchfox.org/mozilla-central/rev/00ea1649b59d5f427979e2d6ba42be96f62d6e82/docshell/shistory/nsSHistory.cpp#360-363 + // ... we mainly care about the bfcache here because it triggers interesting + // behavior. DNR evaluation is correct regardless of bfcache. + pref_set: [["browser.sessionhistory.max_total_viewers", 3]], + }, + async function allowAllRequests_and_bfcache_navigation() { + let extension = await loadExtensionWithDNRRules([ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { urlFilter: "aar_yes", resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }, + ]); + + info("Navigating to initial URL: 1_aar_no"); + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/bfcache_test?1_aar_no" + ); + async function navigateBackInHistory(expectedUrl) { + await contentPage.spawn([], () => { + content.history.back(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.spec === expectedUrl, + `Waiting for history.back() to trigger navigation to ${expectedUrl}` + ); + await contentPage.spawn([expectedUrl], async expectedUrl => { + Assert.equal(content.location.href, expectedUrl, "URL after back"); + Assert.equal(content.document.body.textContent, "true", "from bfcache"); + }); + } + async function checkCanFetch(url) { + return contentPage.spawn([url], async url => { + try { + return await (await content.fetch(url)).text(); + } catch (e) { + return e.toString(); + } + }); + } + + info("Navigating from initial URL to: 2_aar_yes"); + await contentPage.loadURL("http://example.com/bfcache_test?2_aar_yes"); + info("Navigating from 2_aar_yes to: 3_aar_no"); + await contentPage.loadURL("http://example.com/bfcache_test?3_aar_no"); + + info("Going back in history (from 3_aar_no to 2_aar_yes)"); + await navigateBackInHistory("http://example.com/bfcache_test?2_aar_yes"); + Assert.equal( + await checkCanFetch("http://example.com/allowed"), + "fetchAllowed", + "after history.back(), allowAllRequests should apply from 2_aar_yes" + ); + + info("Going back in history (from 2_aar_yes to 1_aar_no)"); + await navigateBackInHistory("http://example.com/bfcache_test?1_aar_no"); + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "after history.back(), no allowAllRequests action applied at 1_aar_no" + ); + + await contentPage.close(); + await extension.unload(); + } +); + +add_task( + { + // Usually, back/forward navigation to a POST form requires the user to + // confirm the form resubmission. Set pref to approve without prompting. + pref_set: [["dom.confirm_repost.testing.always_accept", true]], + }, + async function allowAllRequests_navigate_with_http_method_POST() { + const rules = [ + { + id: 1, + condition: { + requestMethods: ["post"], + resourceTypes: ["main_frame", "sub_frame"], + }, + action: { type: "allowAllRequests" }, + }, + { + id: 2, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { type: "block" }, + }, + ]; + + if (!Services.appinfo.sessionHistoryInParent) { + // POST detection relies on SHIP being enabled. This is true by default, + // but there are some test configurations with SHIP disabled. When SHIP + // is disabled, all methods are interpreted as GET instead of POST. + // Rewrite the rule to specifically match the POST requests that are + // misinterpreted as GET, to verify that the request evaluation by DNR is + // functional (opposed to throwing errors). + rules[0].condition.requestMethods = ["get"]; + rules[0].condition.urlFilter = "do_post|"; + info(`WARNING: SHIP is disabled. POST will be misinterpreted as GET`); + } + + const extension = await loadExtensionWithDNRRules(rules); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?do_get" + ); + async function checkCanFetch(url) { + return contentPage.spawn([url], async url => { + try { + return await (await content.fetch(url)).text(); + } catch (e) { + return e.toString(); + } + }); + } + + // Check fetch() with regular GET navigation in main_frame. + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "main_frame: non-POST not matched by requestMethods:['post']" + ); + + // Check fetch() after POST navigation in main_frame. + await contentPage.spawn([], () => { + let form = content.document.createElement("form"); + form.action = "/?do_post"; + form.method = "POST"; + content.document.body.append(form); + form.submit(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post", + "Waiting for navigation with POST to complete" + ); + Assert.equal( + await checkCanFetch("http://example.net/allowed"), + "fetchAllowed", + "main_frame: requestMethods:['post'] applies to POST" + ); + + // Navigate back to the beginning and verify that allowAllRequests does not + // match any more. + await contentPage.spawn([], () => { + content.history.back(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_get", + "Waiting for (back) navigation to initial GET page to complete" + ); + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "main_frame: back to non-POST not matched by requestMethods:['post']" + ); + + // Now navigate forwards to verify that the POST method is still seen. + await contentPage.spawn([], () => { + content.history.forward(); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post", + "Waiting for (forward) navigation to POST page to complete" + ); + + Assert.equal( + await checkCanFetch("http://example.net/allowed"), + "fetchAllowed", + "main_frame: requestMethods:['post'] detects POST after history.forward()" + ); + + // Now check that adding a new history entry drops the POST method. + await contentPage.spawn([], () => { + content.history.pushState(null, null, "/?hist_p"); + }); + await TestUtils.waitForCondition( + () => contentPage.browsingContext.currentURI.pathQueryRef === "/?hist_p", + "Waiting for history.pushState to have changed the URL" + ); + Assert.equal( + await checkCanFetch("http://example.net/never_reached"), + FETCH_BLOCKED, + "history.pushState drops POST, not matched by requestMethods:['post']" + ); + + await contentPage.close(); + + // Finally, check that POST detection also works for child frames. + await testLoadInFrame({ + description: "sub_frame: non-POST not matched by requestMethods:['post']", + domains: ["example.com", "example.com"], + jsForFrame: async () => { + return (await fetch("http://example.com/allowed")).text(); + }, + expectedError: FETCH_BLOCKED, + }); + + await testLoadInFrame({ + description: "sub_frame: requestMethods:['post'] applies to POST", + domains: ["example.com", "example.com"], + jsForFrame: async () => { + if (!location.href.endsWith("?do_post")) { + dump("Triggering navigation with POST\n"); + let form = document.createElement("form"); + form.action = location.href + "?do_post"; + form.method = "POST"; + document.body.append(form); + form.submit(); + return "delay_postMessage"; + } + dump("Navigation with POST completed; testing fetch()...\n"); + return (await fetch("http://example.com/allowed")).text(); + }, + expectedResult: "fetchAllowed", + }); + await extension.unload(); + } +); -- cgit v1.2.3