Commit 313c469d authored by Rob Wu's avatar Rob Wu
Browse files

Bug 1865689 - Clarify access checks in devtools.inspectedWindow.eval...

Bug 1865689 - Clarify access checks in devtools.inspectedWindow.eval r=rpl,devtools-reviewers,ochameau, a=dmeehan

and report a static error instead of including the URL in the message.

Differential Revision: https://phabricator.services.mozilla.com/D196133
parent 0b9e7c70
Loading
Loading
Loading
Loading
+54 −45
Original line number Diff line number Diff line
@@ -103,6 +103,50 @@ function logAccessDeniedWarning(window, callerInfo, extensionPolicy) {
  Services.console.logMessage(error);
}

function extensionAllowedToInspectPrincipal(extensionPolicy, principal) {
  if (principal.isNullPrincipal) {
    // data: and sandboxed documents.
    //
    // Rather than returning true unconditionally, we go through additional
    // checks to prevent execution in sandboxed documents created by principals
    // that extensions cannot access otherwise.
    principal = principal.precursorPrincipal;
    if (!principal) {
      // Top-level about:blank, etc.
      return true;
    }
  }
  if (!principal.isContentPrincipal) {
    return false;
  }
  const principalURI = principal.URI;
  if (principalURI.schemeIs("https") || principalURI.schemeIs("http")) {
    if (WebExtensionPolicy.isRestrictedURI(principalURI)) {
      return false;
    }
    if (extensionPolicy.quarantinedFromURI(principalURI)) {
      return false;
    }
    // Common case: http(s) allowed.
    return true;
  }

  if (principalURI.schemeIs("moz-extension")) {
    // Ordinarily, we don't allow extensions to execute arbitrary code in
    // their own context. The devtools.inspectedWindow.eval API is a special
    // case - this can only be used through the devtools_page feature, which
    // requires the user to open the developer tools first. If an extension
    // really wants to debug itself, we let it do so.
    return extensionPolicy.id === principal.addonId;
  }

  if (principalURI.schemeIs("file")) {
    return true;
  }

  return false;
}

class CustomizedReload {
  constructor(params) {
    this.docShell = params.targetActor.window.docShell;
@@ -508,57 +552,22 @@ class WebExtensionInspectedWindowActor extends Actor {
      });
    }

    // Log the error for the user to know that the extension request has been denied
    // (the extension may not warn the user at all).
    const logEvalDenied = () => {
      logAccessDeniedWarning(window, callerInfo, extensionPolicy);
    };

    if (isSystemPrincipalWindow(window)) {
      logEvalDenied();

      // On denied JS evaluation, report it to the extension using the same data format
      // used in the corresponding chrome API method to report issues that are
      // not exceptions raised in the evaluated javascript code.
      return createExceptionInfoResult({
        description: "Inspector protocol error: %s",
        details: [
          "This target has a system principal. inspectedWindow.eval denied.",
        ],
      });
    }

    const docPrincipalURI = window.document.nodePrincipal.URI;

    // Deny on document principals listed as restricted or
    // related to the about: pages (only about:blank and about:srcdoc are
    // allowed and their are expected to not have their about URI associated
    // to the principal).
    if (
      WebExtensionPolicy.isRestrictedURI(docPrincipalURI) ||
      docPrincipalURI.schemeIs("about")
      !extensionAllowedToInspectPrincipal(
        extensionPolicy,
        window.document.nodePrincipal
      )
    ) {
      logEvalDenied();
      // Log the error for the user to know that the extension request has been
      // denied (the extension may not warn the user at all).
      logAccessDeniedWarning(window, callerInfo, extensionPolicy);

      // The error message is generic here. If access is disallowed, we do not
      // expose the URL either.
      return createExceptionInfoResult({
        description: "Inspector protocol error: %s %s",
        description: "Inspector protocol error: %s",
        details: [
          "This extension is not allowed on the current inspected window origin",
          docPrincipalURI.spec,
        ],
      });
    }

    const windowAddonId = window.document.nodePrincipal.addonId;

    if (windowAddonId && extensionPolicy.id !== windowAddonId) {
      logEvalDenied();

      return createExceptionInfoResult({
        description: "Inspector protocol error: %s on %s",
        details: [
          "This extension is not allowed to access this extension page.",
          window.document.location.origin,
        ],
      });
    }
+8 −0
Original line number Diff line number Diff line
@@ -8,4 +8,12 @@ support-files =
  head.js
  inspectedwindow-reload-target.sjs

prefs =
  # restrictedDomains must be set as early as possible, before the first use of
  # the preference. browser_webextension_inspected_window_access.js relies on
  # this pref to be set. We cannot use "prefs =" at the individual file, because
  # another test in this manifest may already have resulted in browser startup.
  extensions.webextensions.restrictedDomains=test2.example.com

[browser_webextension_inspected_window.js]
[browser_webextension_inspected_window_access.js]
 No newline at end of file
+0 −42
Original line number Diff line number Diff line
@@ -233,48 +233,6 @@ add_task(async function test_error_inspectedWindowEval_result() {
  await teardown({ commands, extension });
});

add_task(
  async function test_system_principal_denied_error_inspectedWindowEval_result() {
    const { commands, extension, fakeExtCallerInfo } = await setup(
      "about:addons"
    );

    const result = await commands.inspectedWindowCommand.eval(
      fakeExtCallerInfo,
      "window",
      {}
    );

    ok(!result.value, "Got a null result from inspectedWindow eval");
    ok(
      result.exceptionInfo.isError,
      "Got an API Error result from inspectedWindow eval on a system principal page"
    );
    is(
      result.exceptionInfo.code,
      "E_PROTOCOLERROR",
      "Got the expected 'code' property in the error result"
    );
    is(
      result.exceptionInfo.description,
      "Inspector protocol error: %s",
      "Got the expected 'description' property in the error result"
    );
    is(
      result.exceptionInfo.details.length,
      1,
      "The 'details' array property should contains 1 element"
    );
    is(
      result.exceptionInfo.details[0],
      "This target has a system principal. inspectedWindow.eval denied.",
      "Got the expected content in the error results's details"
    );

    await teardown({ commands, extension });
  }
);

add_task(async function test_exception_inspectedWindowEval_result() {
  const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL);

+315 −0
Original line number Diff line number Diff line
/* Any copyright is dedicated to the Public Domain.
 http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

async function run_inspectedWindow_eval({ tab, codeToEval, extension }) {
  const fakeExtCallerInfo = {
    url: `moz-extension://${extension.uuid}/another/fake-caller-script.js`,
    lineNumber: 1,
    addonId: extension.id,
  };
  const commands = await CommandsFactory.forTab(tab, { isWebExtension: true });
  await commands.targetCommand.startListening();
  const result = await commands.inspectedWindowCommand.eval(
    fakeExtCallerInfo,
    codeToEval,
    {}
  );
  await commands.destroy();
  return result;
}

async function openAboutBlankTabWithExtensionOrigin(extension) {
  const tab = await BrowserTestUtils.openNewForegroundTab(
    gBrowser,
    `moz-extension://${extension.uuid}/manifest.json`
  );
  const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
  await ContentTask.spawn(tab.linkedBrowser, null, () => {
    // about:blank inherits the principal when opened from content.
    content.wrappedJSObject.location.assign("about:blank");
  });
  await loaded;
  // Sanity checks:
  is(tab.linkedBrowser.currentURI.spec, "about:blank", "expected tab");
  is(
    tab.linkedBrowser.contentPrincipal.originNoSuffix,
    `moz-extension://${extension.uuid}`,
    "about:blank should be at the extension origin"
  );
  return tab;
}

async function checkEvalResult({
  extension,
  description,
  url,
  createTab = () => BrowserTestUtils.openNewForegroundTab(gBrowser, url),
  expectedResult,
}) {
  const tab = await createTab();
  is(tab.linkedBrowser.currentURI.spec, url, "Sanity check: tab URL");
  const result = await run_inspectedWindow_eval({
    tab,
    codeToEval: "'code executed at ' + location.href",
    extension,
  });
  BrowserTestUtils.removeTab(tab);
  SimpleTest.isDeeply(
    result,
    expectedResult,
    `eval result for devtools.inspectedWindow.eval at ${url} (${description})`
  );
}

async function checkEvalAllowed({ extension, description, url, createTab }) {
  info(`checkEvalAllowed: ${description} (at URL: ${url})`);
  await checkEvalResult({
    extension,
    description,
    url,
    createTab,
    expectedResult: { value: `code executed at ${url}` },
  });
}
async function checkEvalDenied({ extension, description, url, createTab }) {
  info(`checkEvalDenied: ${description} (at URL: ${url})`);
  await checkEvalResult({
    extension,
    description,
    url,
    createTab,
    expectedResult: {
      exceptionInfo: {
        isError: true,
        code: "E_PROTOCOLERROR",
        details: [
          "This extension is not allowed on the current inspected window origin",
        ],
        description: "Inspector protocol error: %s",
      },
    },
  });
}

add_task(async function test_eval_at_http() {
  await SpecialPowers.pushPrefEnv({
    set: [["dom.security.https_first", false]],
  });

  // eslint-disable-next-line @microsoft/sdl/no-insecure-url
  const httpUrl = "http://example.com/";

  // When running with --use-http3-server, http:-URLs cannot be loaded.
  try {
    await fetch(httpUrl);
  } catch {
    info("Skipping test_eval_at_http because http:-URL cannot be loaded");
    return;
  }

  const extension = ExtensionTestUtils.loadExtension({});
  await extension.startup();

  await checkEvalAllowed({
    extension,
    description: "http:-URL",
    url: httpUrl,
  });
  await extension.unload();

  await SpecialPowers.popPrefEnv();
});

add_task(async function test_eval_at_https() {
  const extension = ExtensionTestUtils.loadExtension({});
  await extension.startup();

  const privilegedExtension = ExtensionTestUtils.loadExtension({
    isPrivileged: true,
  });
  await privilegedExtension.startup();

  await checkEvalAllowed({
    extension,
    description: "https:-URL",
    url: "https://example.com/",
  });

  await checkEvalDenied({
    extension,
    description: "a restricted domain",
    // Domain in extensions.webextensions.restrictedDomains by browser.toml.
    url: "https://test2.example.com/",
  });

  await SpecialPowers.pushPrefEnv({
    set: [["extensions.quarantinedDomains.list", "example.com"]],
  });

  await checkEvalDenied({
    extension,
    description: "a quarantined domain",
    url: "https://example.com/",
  });

  await checkEvalAllowed({
    extension: privilegedExtension,
    description: "a quarantined domain",
    url: "https://example.com/",
  });

  await SpecialPowers.popPrefEnv();

  await extension.unload();
  await privilegedExtension.unload();
});

add_task(async function test_eval_at_sandboxed_page() {
  const extension = ExtensionTestUtils.loadExtension({});
  await extension.startup();

  await checkEvalAllowed({
    extension,
    description: "page with CSP sandbox",
    url: "https://example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x",
  });
  await checkEvalDenied({
    extension,
    description: "restricted domain with CSP sandbox",
    url: "https://test2.example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x",
  });

  await extension.unload();
});

add_task(async function test_eval_at_own_extension_origin_allowed() {
  const extension = ExtensionTestUtils.loadExtension({
    background() {
      // eslint-disable-next-line no-undef
      browser.test.sendMessage(
        "blob_url",
        URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }]))
      );
    },
    files: {
      "mozext.html": `<!DOCTYPE html>moz-extension: here`,
    },
  });
  await extension.startup();
  const blobUrl = await extension.awaitMessage("blob_url");

  await checkEvalAllowed({
    extension,
    description: "moz-extension:-URL from own extension",
    url: `moz-extension://${extension.uuid}/mozext.html`,
  });
  await checkEvalAllowed({
    extension,
    description: "blob:-URL from own extension",
    url: blobUrl,
  });
  await checkEvalAllowed({
    extension,
    description: "about:blank with origin from own extension",
    url: "about:blank",
    createTab: () => openAboutBlankTabWithExtensionOrigin(extension),
  });

  await extension.unload();
});

add_task(async function test_eval_at_other_extension_denied() {
  // The extension for which we simulate devtools_page, chosen as caller of
  // devtools.inspectedWindow.eval API calls.
  const extension = ExtensionTestUtils.loadExtension({});
  await extension.startup();

  // The other extension, that |extension| should not be able to access:
  const otherExt = ExtensionTestUtils.loadExtension({
    background() {
      // eslint-disable-next-line no-undef
      browser.test.sendMessage(
        "blob_url",
        URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }]))
      );
    },
    files: {
      "mozext.html": `<!DOCTYPE html>moz-extension: here`,
    },
  });
  await otherExt.startup();
  const otherExtBlobUrl = await otherExt.awaitMessage("blob_url");

  await checkEvalDenied({
    extension,
    description: "moz-extension:-URL from another extension",
    url: `moz-extension://${otherExt.uuid}/mozext.html`,
  });
  await checkEvalDenied({
    extension,
    description: "blob:-URL from another extension",
    url: otherExtBlobUrl,
  });
  await checkEvalDenied({
    extension,
    description: "about:blank with origin from another extension",
    url: "about:blank",
    createTab: () => openAboutBlankTabWithExtensionOrigin(otherExt),
  });

  await otherExt.unload();
  await extension.unload();
});

add_task(async function test_eval_at_about() {
  const extension = ExtensionTestUtils.loadExtension({});
  await extension.startup();
  await checkEvalAllowed({
    extension,
    description: "about:blank (null principal)",
    url: "about:blank",
  });
  await checkEvalDenied({
    extension,
    description: "about:addons (system principal)",
    url: "about:addons",
  });
  await checkEvalDenied({
    extension,
    description: "about:robots (about page)",
    url: "about:robots",
  });
  await extension.unload();
});

add_task(async function test_eval_at_file() {
  // FYI: There is also an equivalent test case with a full end-to-end test at:
  // browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js

  const extension = ExtensionTestUtils.loadExtension({});
  await extension.startup();

  // A dummy file URL that can be loaded in a tab.
  const fileUrl =
    "file://" +
    getTestFilePath("browser_webextension_inspected_window_access.js");

  // checkEvalAllowed test helper cannot be used, because the file:-URL may
  // redirect elsewhere, so the comparison with the full URL fails.
  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, fileUrl);
  const result = await run_inspectedWindow_eval({
    tab,
    codeToEval: "'code executed at ' + location.protocol",
    extension,
  });
  BrowserTestUtils.removeTab(tab);
  SimpleTest.isDeeply(
    result,
    { value: "code executed at file:" },
    `eval result for devtools.inspectedWindow.eval at ${fileUrl}`
  );

  await extension.unload();
});