"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(); } );