Bug 1322235: Part 4 - Add initial native WebExtensionPolicy bindings. r?billm,aswan,zombie draft
authorKris Maglione <maglione.k@gmail.com>
Wed, 24 May 2017 15:58:19 -0700
changeset 584187 d5f2a4c762e229101573d4459ba3600688bd877e
parent 584186 9187814cf2727960322a6401e718864dbb31b42b
child 584188 4d660303f98a642d3af3759e5b952989161053a3
push id60645
push usermaglione.k@gmail.com
push dateThu, 25 May 2017 00:12:26 +0000
reviewersbillm, aswan, zombie
bugs1322235
milestone55.0a1
Bug 1322235: Part 4 - Add initial native WebExtensionPolicy bindings. r?billm,aswan,zombie Bill, can you please review the binding code and the general sanity of the platform code? Andrew and zombie, can you please review the policy logic and tests? As in part 1, this aims to reduce the overhead of our extension policy logic by making it directly available to native code with as little JS and XPConnect overhead as possible. MozReview-Commit-ID: 40m1wSEYtBo
dom/bindings/Bindings.conf
dom/webidl/WebExtensionPolicy.webidl
dom/webidl/moz.build
toolkit/components/extensions/WebExtensionPolicy.cpp
toolkit/components/extensions/WebExtensionPolicy.h
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
toolkit/components/extensions/test/xpcshell/xpcshell.ini
--- a/dom/bindings/Bindings.conf
+++ b/dom/bindings/Bindings.conf
@@ -1100,16 +1100,20 @@ DOMInterfaces = {
 'VTTRegion': {
   'nativeType': 'mozilla::dom::TextTrackRegion',
 },
 
 'WebAuthentication': {
     'implicitJSContext': 'makeCredential',
 },
 
+'WebExtensionPolicy': {
+    'nativeType': 'mozilla::extensions::WebExtensionPolicy',
+},
+
 'WindowClient': {
     'nativeType': 'mozilla::dom::workers::ServiceWorkerWindowClient',
     'headerFile': 'mozilla/dom/workers/bindings/ServiceWorkerWindowClient.h',
 },
 
 'WebGLActiveInfo': {
     'nativeType': 'mozilla::WebGLActiveInfo',
     'headerFile': 'WebGLActiveInfo.h'
new file mode 100644
--- /dev/null
+++ b/dom/webidl/WebExtensionPolicy.webidl
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+interface URI;
+interface WindowProxy;
+
+callback WebExtensionLocalizeCallback = DOMString (DOMString unlocalizedText);
+
+/**
+ * Defines the platform-level policies for a WebExtension, including its
+ * permissions and the characteristics of its moz-extension: URLs.
+ */
+[Constructor(WebExtensionInit options), ChromeOnly, Exposed=System]
+interface WebExtensionPolicy {
+  /**
+   * The add-on's internal ID, as specified in its manifest.json file or its
+   * XPI signature.
+   */
+  [Constant, StoreInSlot]
+  readonly attribute DOMString id;
+
+  /**
+   * The hostname part of the extension's moz-extension: URLs. This value is
+   * generated randomly at install time.
+   */
+  [Constant, StoreInSlot]
+  readonly attribute ByteString mozExtensionHostname;
+
+  /**
+   * The file: or jar: URL to use for the base of the extension's
+   * moz-extension: URL root.
+   */
+  [Constant]
+  readonly attribute ByteString baseURL;
+
+  /**
+   * The content security policy string to apply to all pages loaded from the
+   * extension's moz-extension: protocol.
+   */
+  [Constant]
+  readonly attribute DOMString contentSecurityPolicy;
+
+
+  /**
+   * The list of currently-active permissions for the extension, as specified
+   * in its manifest.json file. May be updated to reflect changes in the
+   * extension's optional permissions.
+   */
+  [Cached, Frozen, Pure]
+  attribute sequence<DOMString> permissions;
+
+  /**
+   * Match patterns for the set of web origins to which the extension is
+   * currently allowed access. May be updated to reflect changes in the
+   * extension's optional permissions.
+   */
+  [Pure]
+  attribute MatchPatternSet allowedOrigins;
+
+  /**
+   * True if the extension is currently active, false otherwise. When active,
+   * the extension's moz-extension: protocol will point to the given baseURI,
+   * and the set of policies for this object will be active for its ID.
+   *
+   * Only one extension policy with a given ID or hostname may be active at a
+   * time. Attempting to activate a policy while a conflicting policy is
+   * active will raise an error.
+   */
+  [Affects=Everything, SetterThrows]
+  attribute boolean active;
+
+
+  /**
+   * Returns true if the extension has cross-origin access to the given URI.
+   */
+  boolean canAccessURI(URI uri);
+
+  /**
+   * Returns true if the extension currently has the given permission.
+   */
+  boolean hasPermission(DOMString permission);
+
+  /**
+   * Returns true the given path relative to the extension's moz-extension:
+   * URL root may be accessed by web content.
+   */
+  boolean isPathWebAccessible(DOMString pathname);
+
+  /**
+   * Replaces localization placeholders in the given string with localized
+   * text from the extensin's currently active locale.
+   */
+  DOMString localize(DOMString unlocalizedText);
+
+  /**
+   * Returns the moz-extension: URL for the given path.
+   */
+  [Throws]
+  DOMString getURL(optional DOMString path = "");
+};
+
+dictionary WebExtensionInit {
+  required DOMString id;
+
+  required ByteString mozExtensionHostname;
+
+  required DOMString baseURL;
+
+  required WebExtensionLocalizeCallback localizeCallback;
+
+  required MatchPatternSet allowedOrigins;
+
+  sequence<DOMString> permissions = [];
+
+  sequence<MatchGlob> webAccessibleResources = [];
+
+  DOMString? contentSecurityPolicy = null;
+
+  sequence<DOMString>? backgroundScripts = null;
+};
--- a/dom/webidl/moz.build
+++ b/dom/webidl/moz.build
@@ -938,16 +938,17 @@ WEBIDL_FILES = [
     'VRDisplay.webidl',
     'VRDisplayEvent.webidl',
     'VRServiceTest.webidl',
     'VTTCue.webidl',
     'VTTRegion.webidl',
     'WaveShaperNode.webidl',
     'WebAuthentication.webidl',
     'WebComponents.webidl',
+    'WebExtensionPolicy.webidl',
     'WebGL2RenderingContext.webidl',
     'WebGLRenderingContext.webidl',
     'WebKitCSSMatrix.webidl',
     'WebSocket.webidl',
     'WheelEvent.webidl',
     'WidevineCDMManifest.webidl',
     'WindowOrWorkerGlobalScope.webidl',
     'WindowRoot.webidl',
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/WebExtensionPolicy.cpp
@@ -0,0 +1,234 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/ExtensionPolicyService.h"
+#include "mozilla/extensions/WebExtensionPolicy.h"
+
+#include "nsEscape.h"
+#include "nsISubstitutingProtocolHandler.h"
+#include "nsNetUtil.h"
+#include "nsPrintfCString.h"
+
+namespace mozilla {
+namespace extensions {
+
+using namespace dom;
+
+static inline Result<Ok, nsresult>
+WrapNSResult(PRStatus aRv)
+{
+    if (aRv != PR_SUCCESS) {
+        return Err(NS_ERROR_FAILURE);
+    }
+    return Ok();
+}
+
+static inline Result<Ok, nsresult>
+WrapNSResult(nsresult aRv)
+{
+    if (NS_FAILED(aRv)) {
+        return Err(aRv);
+    }
+    return Ok();
+}
+
+#define NS_TRY(expr) MOZ_TRY(WrapNSResult(expr))
+
+#define PROTO "moz-extension"
+
+#define BACKGROUND_PAGE_HTML_START "<!DOCTYPE html>\n\
+<html>\n\
+  <head><meta charset=\"utf-8\"></head>\n\
+  <body>"
+
+#define BACKGROUND_PAGE_HTML_SCRIPT "\n\
+    <script type=\"text/javascript\" src=\"%s\"></script>"
+
+#define BACKGROUND_PAGE_HTML_END "\n\
+  <body>\n\
+</html>"
+
+class EscapeHTML final : public nsAdoptingCString
+{
+public:
+  EscapeHTML(const nsACString& str)
+    : nsAdoptingCString(nsEscapeHTML(str.BeginReading()))
+  {}
+};
+
+
+static nsISubstitutingProtocolHandler*
+Proto()
+{
+  static nsCOMPtr<nsISubstitutingProtocolHandler> sHandler;
+
+  if (MOZ_UNLIKELY(!sHandler)) {
+    nsCOMPtr<nsIIOService> ios = do_GetIOService();
+    MOZ_RELEASE_ASSERT(ios);
+
+    nsCOMPtr<nsIProtocolHandler> handler;
+    ios->GetProtocolHandler(PROTO, getter_AddRefs(handler));
+
+    sHandler = do_QueryInterface(handler);
+    MOZ_RELEASE_ASSERT(sHandler);
+
+    ClearOnShutdown(&sHandler);
+  }
+
+  return sHandler;
+}
+
+
+/*****************************************************************************
+ * WebExtensionPolicy
+ *****************************************************************************/
+
+WebExtensionPolicy::WebExtensionPolicy(GlobalObject& aGlobal,
+                                       const WebExtensionInit& aInit,
+                                       ErrorResult& aRv)
+  : mId(NS_AtomizeMainThread(aInit.mId))
+  , mHostname(aInit.mMozExtensionHostname)
+  , mContentSecurityPolicy(aInit.mContentSecurityPolicy)
+  , mLocalizeCallback(aInit.mLocalizeCallback)
+  , mPermissions(new AtomSet(aInit.mPermissions))
+  , mHostPermissions(aInit.mAllowedOrigins)
+{
+  mWebAccessiblePaths.AppendElements(aInit.mWebAccessibleResources);
+
+  if (!aInit.mBackgroundScripts.IsNull()) {
+    mBackgroundScripts.SetValue().AppendElements(aInit.mBackgroundScripts.Value());
+  }
+
+  nsresult rv = NS_NewURI(getter_AddRefs(mBaseURI), aInit.mBaseURL);
+  if (NS_FAILED(rv)) {
+    aRv.Throw(rv);
+  }
+}
+
+already_AddRefed<WebExtensionPolicy>
+WebExtensionPolicy::Constructor(GlobalObject& aGlobal,
+                                const WebExtensionInit& aInit,
+                                ErrorResult& aRv)
+{
+  RefPtr<WebExtensionPolicy> policy = new WebExtensionPolicy(aGlobal, aInit, aRv);
+  if (aRv.Failed()) {
+    return nullptr;
+  }
+  return policy.forget();
+}
+
+
+void
+WebExtensionPolicy::SetActive(bool aActive, ErrorResult& aRv)
+{
+  if (aActive == mActive) {
+    return;
+  }
+
+  bool ok = aActive ? Enable() : Disable();
+
+  if (!ok) {
+    aRv.Throw(NS_ERROR_UNEXPECTED);
+  }
+}
+
+bool
+WebExtensionPolicy::Enable()
+{
+  MOZ_ASSERT(!mActive);
+
+  Unused << Proto()->SetSubstitution(MozExtensionHostname(), mBaseURI);
+
+  mActive = true;
+  return true;
+}
+
+bool
+WebExtensionPolicy::Disable()
+{
+  MOZ_ASSERT(mActive);
+
+  Unused << Proto()->SetSubstitution(MozExtensionHostname(), nullptr);
+
+  mActive = false;
+  return true;
+}
+
+void
+WebExtensionPolicy::GetURL(const nsAString& aPath,
+                           nsAString& aResult,
+                           ErrorResult& aRv) const
+{
+  auto result = GetURL(aPath);
+  if (result.isOk()) {
+    aResult = result.unwrap();
+  } else {
+    aRv.Throw(result.unwrapErr());
+  }
+}
+
+Result<nsString, nsresult>
+WebExtensionPolicy::GetURL(const nsAString& aPath) const
+{
+  nsPrintfCString spec("%s://%s/", PROTO, mHostname.get());
+
+  nsCOMPtr<nsIURI> uri;
+  NS_TRY(NS_NewURI(getter_AddRefs(uri), spec));
+
+  NS_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec));
+
+  return NS_ConvertUTF8toUTF16(spec);
+}
+
+nsCString
+WebExtensionPolicy::BackgroundPageHTML() const
+{
+  nsAutoCString result;
+
+  if (mBackgroundScripts.IsNull()) {
+    result.SetIsVoid(true);
+    return result;
+  }
+
+  result.AppendLiteral(BACKGROUND_PAGE_HTML_START);
+
+  for (auto& script : mBackgroundScripts.Value()) {
+    EscapeHTML escaped{NS_ConvertUTF16toUTF8(script)};
+
+    result.AppendPrintf(BACKGROUND_PAGE_HTML_SCRIPT, escaped.get());
+  }
+
+  result.AppendLiteral(BACKGROUND_PAGE_HTML_END);
+  return result;
+}
+
+void
+WebExtensionPolicy::Localize(const nsAString& aInput, nsString& aOutput) const
+{
+  mLocalizeCallback->Call(aInput, aOutput);
+}
+
+
+JSObject*
+WebExtensionPolicy::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto)
+{
+  return WebExtensionPolicyBinding::Wrap(aCx, this, aGivenProto);
+}
+
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(WebExtensionPolicy, mParent,
+                                      mLocalizeCallback,
+                                      mHostPermissions,
+                                      mWebAccessiblePaths)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(WebExtensionPolicy)
+  NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+  NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(WebExtensionPolicy)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(WebExtensionPolicy)
+
+} // namespace extensions
+} // namespace mozilla
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/WebExtensionPolicy.h
@@ -0,0 +1,149 @@
+/* -*-  Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_extensions_WebExtensionPolicy_h
+#define mozilla_extensions_WebExtensionPolicy_h
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/WebExtensionPolicyBinding.h"
+#include "mozilla/extensions/MatchPattern.h"
+
+#include "jsapi.h"
+
+#include "mozilla/Result.h"
+#include "mozilla/WeakPtr.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsISupports.h"
+#include "nsWrapperCache.h"
+
+namespace mozilla {
+namespace extensions {
+
+using dom::WebExtensionInit;
+using dom::WebExtensionLocalizeCallback;
+
+class WebExtensionPolicy final : public nsISupports
+                               , public nsWrapperCache
+                               , public SupportsWeakPtr<WebExtensionPolicy>
+{
+public:
+  NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+  NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(WebExtensionPolicy)
+
+  static already_AddRefed<WebExtensionPolicy>
+  Constructor(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv);
+
+  nsIAtom* Id() const { return mId; }
+  void GetId(nsAString& aId) const { aId = nsDependentAtomString(mId); };
+
+  const nsCString& MozExtensionHostname() const { return mHostname; }
+  void GetMozExtensionHostname(nsACString& aHostname) const
+  {
+    aHostname = MozExtensionHostname();
+  }
+
+  void GetBaseURL(nsACString& aBaseURL) const
+  {
+    MOZ_ALWAYS_SUCCEEDS(mBaseURI->GetSpec(aBaseURL));
+  }
+
+  void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const;
+
+  Result<nsString, nsresult> GetURL(const nsAString& aPath) const;
+
+  bool CanAccessURI(nsIURI* aURI, bool aExplicit = false) const
+  {
+    return mHostPermissions && mHostPermissions->Matches(aURI, aExplicit);
+  }
+
+  bool IsPathWebAccessible(const nsAString& aPath) const
+  {
+    return mWebAccessiblePaths.Matches(aPath);
+  }
+
+  bool HasPermission(const nsIAtom* aPermission) const
+  {
+    return mPermissions->Contains(aPermission);
+  }
+  bool HasPermission(const nsAString& aPermission) const
+  {
+    return mPermissions->Contains(aPermission);
+  }
+
+  nsCString BackgroundPageHTML() const;
+
+  void Localize(const nsAString& aInput, nsString& aResult) const;
+
+  const nsString& ContentSecurityPolicy() const
+  {
+    return mContentSecurityPolicy;
+  }
+  void GetContentSecurityPolicy(nsAString& aCSP) const
+  {
+    aCSP = mContentSecurityPolicy;
+  }
+
+
+  already_AddRefed<MatchPatternSet> AllowedOrigins()
+  {
+    RefPtr<MatchPatternSet> result = mHostPermissions;
+    return result.forget();
+  }
+  void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins)
+  {
+    mHostPermissions = &aAllowedOrigins;
+  }
+
+  void GetPermissions(nsTArray<nsString>& aResult) const
+  {
+    mPermissions->Get(aResult);
+  }
+  void SetPermissions(const nsTArray<nsString>& aPermissions)
+  {
+    mPermissions = new AtomSet(aPermissions);
+  }
+
+
+  bool Active() const { return mActive; }
+  void SetActive(bool aActive, ErrorResult& aRv);
+
+
+  nsISupports* GetParentObject() const { return mParent; }
+
+  virtual JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override;
+
+protected:
+  virtual ~WebExtensionPolicy() = default;
+
+private:
+  WebExtensionPolicy(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv);
+
+  bool Enable();
+  bool Disable();
+
+  nsCOMPtr<nsISupports> mParent;
+
+  nsCOMPtr<nsIAtom> mId;
+  nsCString mHostname;
+  nsCOMPtr<nsIURI> mBaseURI;
+
+  nsString mContentSecurityPolicy;
+
+  bool mActive = false;
+
+  RefPtr<WebExtensionLocalizeCallback> mLocalizeCallback;
+
+  RefPtr<AtomSet> mPermissions;
+  RefPtr<MatchPatternSet> mHostPermissions;
+  MatchGlobSet mWebAccessiblePaths;
+
+  Nullable<nsTArray<nsString>> mBackgroundScripts;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_WebExtensionPolicy_h
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -42,20 +42,22 @@ TESTING_JS_MODULES += [
 DIRS += [
     'schemas',
     'webrequest',
 ]
 
 EXPORTS.mozilla.extensions = [
     'MatchGlob.h',
     'MatchPattern.h',
+    'WebExtensionPolicy.h',
 ]
 
 UNIFIED_SOURCES += [
     'MatchPattern.cpp',
+    'WebExtensionPolicy.cpp',
 ]
 
 FINAL_LIBRARY = 'xul'
 
 
 JAR_MANIFESTS += ['jar.mn']
 
 BROWSER_CHROME_MANIFESTS += [
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
@@ -0,0 +1,137 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const {newURI} = Services.io;
+
+add_task(async function test_WebExtensinonPolicy() {
+  const id = "foo@bar.baz";
+  const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610";
+
+  const baseURL = "file:///foo/";
+  const mozExtURL = `moz-extension://${uuid}/`;
+  const mozExtURI = newURI(mozExtURL);
+
+  let policy = new WebExtensionPolicy({
+    id,
+    mozExtensionHostname: uuid,
+    baseURL,
+
+    localizeCallback(str) {
+      return `<${str}>`;
+    },
+
+    allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], {ignorePath: true}),
+    permissions: ["<all_urls>"],
+    webAccessibleResources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)),
+  });
+
+  equal(policy.active, false, "Active attribute should initially be false");
+
+  // GetURL
+
+  equal(policy.getURL(), mozExtURL, "getURL() should return the correct root URL");
+  equal(policy.getURL("path/foo.html"), `${mozExtURL}path/foo.html`, "getURL(path) should return the correct URL");
+
+
+  // Permissions
+
+  deepEqual(policy.permissions, ["<all_urls>"], "Initial permissions should be correct");
+
+  ok(policy.hasPermission("<all_urls>"), "hasPermission should match existing permission");
+  ok(!policy.hasPermission("history"), "hasPermission should not match nonexistent permission");
+
+  Assert.throws(() => { policy.permissions[0] = "foo"; },
+                TypeError,
+                "Permissions array should be frozen");
+
+  policy.permissions = ["history"];
+  deepEqual(policy.permissions, ["history"], "Permissions should be updateable as a set");
+
+  ok(policy.hasPermission("history"), "hasPermission should match existing permission");
+  ok(!policy.hasPermission("<all_urls>"), "hasPermission should not match nonexistent permission");
+
+
+  // Origins
+
+  ok(policy.canAccessURI(newURI("http://foo.bar/quux")), "Should be able to access whitelisted URI");
+  ok(policy.canAccessURI(newURI("https://x.baz/foo")), "Should be able to access whitelisted URI");
+
+  ok(!policy.canAccessURI(newURI("https://foo.bar/quux")), "Should not be able to access non-whitelisted URI");
+
+  policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], {ignorePath: true});
+
+  ok(policy.canAccessURI(newURI("https://foo.bar/quux")), "Should be able to access updated whitelisted URI");
+  ok(!policy.canAccessURI(newURI("https://x.baz/foo")), "Should not be able to access removed whitelisted URI");
+
+
+  // Web-accessible resources
+
+  ok(policy.isPathWebAccessible("/foo/bar"), "Web-accessible glob should be web-accessible");
+  ok(policy.isPathWebAccessible("/bar.baz"), "Web-accessible path should be web-accessible");
+  ok(!policy.isPathWebAccessible("/bar.baz/quux"), "Non-web-accessible path should not be web-accessible");
+
+
+  // Localization
+
+  equal(policy.localize("foo"), "<foo>", "Localization callback should work as expected");
+
+
+  // Protocol and lookups.
+
+  let proto = Services.io.getProtocolHandler("moz-extension", uuid).QueryInterface(Ci.nsISubstitutingProtocolHandler);
+
+  deepEqual(WebExtensionPolicy.getActiveExtensions(), [], "Should have no active extensions");
+  equal(WebExtensionPolicy.getByID(id), null, "ID lookup should not return extension when not active");
+  equal(WebExtensionPolicy.getByHostname(uuid), null, "Hostname lookup should not return extension when not active");
+  Assert.throws(() => proto.resolveURI(mozExtURI), /NS_ERROR_NOT_AVAILABLE/,
+                "URL should not resolve when not active");
+
+  policy.active = true;
+  equal(policy.active, true, "Active attribute should be updated");
+
+  let exts = WebExtensionPolicy.getActiveExtensions();
+  equal(exts.length, 1, "Should have one active extension");
+  equal(exts[0], policy, "Should have the correct active extension");
+
+  equal(WebExtensionPolicy.getByID(id), policy, "ID lookup should return extension when active");
+  equal(WebExtensionPolicy.getByHostname(uuid), policy, "Hostname lookup should return extension when active");
+
+  equal(proto.resolveURI(mozExtURI), baseURL, "URL should resolve correctly while active");
+
+  policy.active = false;
+  equal(policy.active, false, "Active attribute should be updated");
+
+  deepEqual(WebExtensionPolicy.getActiveExtensions(), [], "Should have no active extensions");
+  equal(WebExtensionPolicy.getByID(id), null, "ID lookup should not return extension when not active");
+  equal(WebExtensionPolicy.getByHostname(uuid), null, "Hostname lookup should not return extension when not active");
+  Assert.throws(() => proto.resolveURI(mozExtURI), /NS_ERROR_NOT_AVAILABLE/,
+                "URL should not resolve when not active");
+
+
+  // Conflicting policies.
+
+  policy.active = true;
+
+  let attrs = [{id, uuid},
+               {id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5"},
+               {id: "foo@quux", uuid}];
+
+  // eslint-disable-next-line no-shadow
+  for (let {id, uuid} of attrs) {
+    let policy2 = new WebExtensionPolicy({
+      id,
+      mozExtensionHostname: uuid,
+      baseURL: "file://bar/",
+
+      localizeCallback() {},
+
+      allowedOrigins: new MatchPatternSet([]),
+    });
+
+    Assert.throws(() => { policy2.active = true; }, /NS_ERROR_UNEXPECTED/,
+                  `Should not be able to activate conflicting policy: ${id} ${uuid}`);
+  }
+
+  policy.active = false;
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -5,16 +5,17 @@ skip-if = appname == "thunderbird"
 dupe-manifest =
 support-files =
   data/**
   head_sync.js
   xpcshell-content.ini
 tags = webextensions
 
 [test_MatchPattern.js]
+[test_WebExtensionPolicy.js]
 
 [test_csp_custom_policies.js]
 [test_csp_validator.js]
 [test_ext_alarms.js]
 [test_ext_alarms_does_not_fire.js]
 [test_ext_alarms_periodic.js]
 [test_ext_alarms_replaces.js]
 [test_ext_api_permissions.js]