summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js')
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js1231
1 files changed, 1231 insertions, 0 deletions
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(`<!DOCTYPE html>${code}`);
+});
+server.registerPathHandler("/bfcache_test", (req, res) => {
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ res.write(`<body><script>
+ // false at initial load, true when loaded from bfcache.
+ onpageshow = e => document.body.textContent = e.persisted;
+ </script>`);
+});
+
+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("&", "&amp;")
+ .replaceAll('"', "&quot;")
+ .replaceAll("'", "&#39;")
+ .replaceAll("<", "&lt;")
+ .replaceAll(">", "&gt;");
+}
+
+// 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 = `<body><script>(${frameJs})(${jsForFrame})</script>`;
+
+ // 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 = `<iframe srcdoc="${htmlEscape(html)}"></iframe>`;
+ } else if (domain === ABOUT_SRCDOC_CROSS_ORIGIN) {
+ html = `<iframe srcdoc="${htmlEscape(
+ html
+ )}" sandbox="allow-scripts"></iframe>`;
+ } else {
+ html = `<iframe src="${urlEchoHtml(domain, html)}"></iframe>`;
+ }
+ }
+
+ const mainFrameJs = () => {
+ window.resultPromise = new Promise(resolve => {
+ window.onmessage = e => resolve(e.data);
+ });
+ };
+ const mainFrameHtml = `<script>(${mainFrameJs})()</script>${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: ["<all_urls>"],
+ });
+
+ 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: "<title>DNR rule 3 for initiator example.org</title>",
+ },
+ ],
+ },
+ },
+ {
+ 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: "<title>DNR rule 4 for initiator example.net</title>",
+ },
+ ],
+ },
+ },
+ ];
+
+ const extension = await loadExtensionWithDNRRules(rules, {
+ // host_permissions needed for allowAllRequests of ancestors
+ // (initiatorDomains & requestDomains) and modifyHeaders.
+ host_permissions: ["<all_urls>"],
+ });
+
+ const jsNavigateOnMessage = () => {
+ window.onmessage = e => {
+ dump(`\nReceived message at ${origin} from ${e.origin}: ${e.data}\n`);
+ e.source.location = e.data;
+ };
+ };
+ const htmlNavigateOnMessage = `<script>(${jsNavigateOnMessage})()</script>`;
+
+ // 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();
+ }
+);