Bug 1406278: Part 2b - Use subject principal as triggering principal in <img> "src" attribute. r?bz draft
authorKris Maglione <maglione.k@gmail.com>
Mon, 02 Oct 2017 20:28:32 -0700
changeset 676241 94412199f7731a045617f5384e02c001bc82eb35
parent 676195 fc2db4c7a4c2fa83133ad8627c6aff2d0e929d15
child 676242 4c1e5eb2185739de83b4c5405bda874d2f1eca87
push id83446
push usermaglione.k@gmail.com
push dateFri, 06 Oct 2017 22:14:09 +0000
reviewersbz
bugs1406278
milestone58.0a1
Bug 1406278: Part 2b - Use subject principal as triggering principal in <img> "src" attribute. r?bz MozReview-Commit-ID: DrblTjP99WJ
dom/base/nsContentUtils.cpp
dom/base/nsContentUtils.h
dom/base/nsImageLoadingContent.cpp
dom/base/nsImageLoadingContent.h
dom/html/HTMLImageElement.cpp
dom/html/HTMLImageElement.h
dom/webidl/HTMLImageElement.webidl
image/test/browser/browser_docshell_type_editor.js
toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html
toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
--- a/dom/base/nsContentUtils.cpp
+++ b/dom/base/nsContentUtils.cpp
@@ -197,16 +197,17 @@
 #include "nsScriptSecurityManager.h"
 #include "nsSerializationHelper.h"
 #include "nsStreamUtils.h"
 #include "nsTextEditorState.h"
 #include "nsTextFragment.h"
 #include "nsTextNode.h"
 #include "nsThreadUtils.h"
 #include "nsUnicodeProperties.h"
+#include "nsURLHelper.h"
 #include "nsViewManager.h"
 #include "nsViewportInfo.h"
 #include "nsWidgetsCID.h"
 #include "nsIWindowProvider.h"
 #include "nsWrapperCacheInlines.h"
 #include "nsXULPopupManager.h"
 #include "xpcprivate.h" // nsXPConnect
 #include "HTMLSplitOnSpacesTokenizer.h"
@@ -2328,16 +2329,65 @@ nsContentUtils::PrincipalHasPermission(n
 
 // static
 bool
 nsContentUtils::CallerHasPermission(JSContext* aCx, const nsIAtom* aPerm)
 {
   return PrincipalHasPermission(SubjectPrincipal(aCx), aPerm);
 }
 
+// static
+nsIPrincipal*
+nsContentUtils::GetAttrTriggeringPrincipal(nsIContent* aContent, const nsAString& aAttrValue,
+                                           nsIPrincipal* aSubjectPrincipal)
+{
+  nsIPrincipal* contentPrin = aContent ? aContent->NodePrincipal() : nullptr;
+
+  // If the subject principal is the same as the content principal, or no
+  // explicit subject principal was provided, we don't need to do any further
+  // checks. Just return the content principal.
+  if (contentPrin == aSubjectPrincipal || !aSubjectPrincipal) {
+    return contentPrin;
+  }
+
+  // If the attribute value is empty, it's not an absolute URL, so don't bother
+  // with more expensive checks.
+  if (!aAttrValue.IsEmpty() &&
+      IsAbsoluteURL(NS_ConvertUTF16toUTF8(aAttrValue))) {
+    return aSubjectPrincipal;
+  }
+
+  return contentPrin;
+}
+
+// static
+bool
+nsContentUtils::IsAbsoluteURL(const nsACString& aURL)
+{
+  nsAutoCString scheme;
+  if (NS_FAILED(net_ExtractURLScheme(aURL, scheme))) {
+    // If we can't extract a scheme, it's not an absolute URL.
+    return false;
+  }
+
+  // If it parses as an absolute StandardURL, it's definitely an absolute URL,
+  // so no need to check with the IO service.
+  if (net_IsAbsoluteURL(aURL)) {
+    return true;
+  }
+
+  nsCOMPtr<nsIIOService> ios = services::GetIOService();
+  uint32_t flags;
+  if (ios && NS_SUCCEEDED(ios->GetProtocolFlags(scheme.get(), &flags))) {
+    return flags & nsIProtocolHandler::URI_NORELATIVE;
+  }
+
+  return false;
+}
+
 //static
 bool
 nsContentUtils::InProlog(nsINode *aNode)
 {
   NS_PRECONDITION(aNode, "missing node to nsContentUtils::InProlog");
 
   nsINode* parent = aNode->GetParentNode();
   if (!parent || !parent->IsNodeOfType(nsINode::eDOCUMENT)) {
@@ -10403,23 +10453,27 @@ nsContentUtils::AppendNativeAnonymousChi
   if (!(aFlags & nsIContent::eSkipDocumentLevelNativeAnonymousContent) &&
       aContent == aContent->OwnerDoc()->GetRootElement()) {
     AppendDocumentLevelNativeAnonymousContentTo(aContent->OwnerDoc(), aKids);
   }
 }
 
 /* static */ bool
 nsContentUtils::GetLoadingPrincipalForXULNode(nsIContent* aLoadingNode,
+                                              nsIPrincipal* aDefaultPrincipal,
                                               nsIPrincipal** aLoadingPrincipal)
 {
   MOZ_ASSERT(aLoadingNode);
   MOZ_ASSERT(aLoadingPrincipal);
 
   bool result = false;
-  nsCOMPtr<nsIPrincipal> loadingPrincipal = aLoadingNode->NodePrincipal();
+  nsCOMPtr<nsIPrincipal> loadingPrincipal = aDefaultPrincipal;
+  if (!loadingPrincipal) {
+    loadingPrincipal = aLoadingNode->NodePrincipal();
+  }
   nsAutoString loadingStr;
   aLoadingNode->GetAttr(kNameSpaceID_None, nsGkAtoms::loadingprincipal,
                         loadingStr);
   if (loadingStr.IsEmpty()) {
     // Fall back to mContent's principal (SystemPrincipal) if 'loadingprincipal'
     // isn't specified.
     loadingPrincipal.forget(aLoadingPrincipal);
     return result;
--- a/dom/base/nsContentUtils.h
+++ b/dom/base/nsContentUtils.h
@@ -596,16 +596,46 @@ public:
 
   // Check if the principal is chrome or an addon with the permission.
   static bool PrincipalHasPermission(nsIPrincipal* aPrincipal, const nsIAtom* aPerm);
 
   // Check if the JS caller is chrome or an addon with the permission.
   static bool CallerHasPermission(JSContext* aCx, const nsIAtom* aPerm);
 
   /**
+   * Returns the triggering principal which should be used for the given URL
+   * attribute value with the given subject principal.
+   *
+   * If the attribute value is not an absolute URL, the subject principal will
+   * be ignored, and the node principal of aContent will be used instead.
+   * If aContent is non-null, this function will always return a principal.
+   * Otherewise, it may return null if aSubjectPrincipal is null or is rejected
+   * based on the attribute value.
+   *
+   * @param aContent The content on which the attribute is being set.
+   * @param aAttrValue The URL value of the attribute. For parsed attribute
+   *        values, such as `srcset`, this function should be called separately
+   *        for each URL value it contains.
+   * @param aSubjectPrincipal The subject principal of the scripted caller
+   *        responsible for setting the attribute, or null if no scripted caller
+   *        can be determined.
+   */
+  static nsIPrincipal* GetAttrTriggeringPrincipal(nsIContent* aContent,
+                                                  const nsAString& aAttrValue,
+                                                  nsIPrincipal* aSubjectPrincipal);
+
+  /**
+   * Returns true if the given string is guaranteed to be treated as an absolute
+   * URL, rather than a relative URL. In practice, this means any complete URL
+   * as supported by nsStandardURL, or any string beginning with a valid scheme
+   * which is known to the IO service, and has the URI_NORELATIVE flag.
+   */
+  static bool IsAbsoluteURL(const nsACString& aURL);
+
+  /**
    * GetDocumentFromCaller gets its document by looking at the last called
    * function and finding the document that the function itself relates to.
    * For example, consider two windows A and B in the same origin. B has a
    * function which does something that ends up needing the current document.
    * If a script in window A were to call B's function, GetDocumentFromCaller
    * would find that function (in B) and return B's document.
    *
    * @return The document or null if no JS Context.
@@ -3054,17 +3084,25 @@ public:
    * (which is System Principal).
    *
    * Return true if aLoadingPrincipal has 'loadingprincipal' attributes, and
    * the value 'loadingprincipal' is also successfully deserialized, otherwise
    * return false.
    */
   static bool
   GetLoadingPrincipalForXULNode(nsIContent* aLoadingNode,
-                                nsIPrincipal** aLoadingPrincipal);
+                                nsIPrincipal* aDefaultPrincipal,
+                                nsIPrincipal** aTriggeringPrincipal);
+
+  static bool
+  GetLoadingPrincipalForXULNode(nsIContent* aLoadingNode,
+                                nsIPrincipal** aTriggeringPrincipal)
+  {
+    return GetLoadingPrincipalForXULNode(aLoadingNode, nullptr, aTriggeringPrincipal);
+  }
 
   /**
    * Returns the content policy type that should be used for loading images
    * for displaying in the UI.  The sources of such images can be <xul:image>,
    * <xul:menuitem> on OSX where we load the image through nsMenuItemIconX, etc.
    */
   static void
   GetContentPolicyTypeForUIImageLoading(nsIContent* aLoadingNode,
--- a/dom/base/nsImageLoadingContent.cpp
+++ b/dom/base/nsImageLoadingContent.cpp
@@ -892,17 +892,18 @@ nsImageLoadingContent::UnblockOnload(img
 /*
  * Non-interface methods
  */
 
 nsresult
 nsImageLoadingContent::LoadImage(const nsAString& aNewURI,
                                  bool aForce,
                                  bool aNotify,
-                                 ImageLoadType aImageLoadType)
+                                 ImageLoadType aImageLoadType,
+                                 nsIPrincipal* aTriggeringPrincipal)
 {
   // First, get a document (needed for security checks and the like)
   nsIDocument* doc = GetOurOwnerDoc();
   if (!doc) {
     // No reason to bother, I think...
     return NS_OK;
   }
 
@@ -926,27 +927,29 @@ nsImageLoadingContent::LoadImage(const n
   // Parse the URI string to get image URI
   nsCOMPtr<nsIURI> imageURI;
   nsresult rv = StringToURI(aNewURI, doc, getter_AddRefs(imageURI));
   NS_ENSURE_SUCCESS(rv, rv);
   // XXXbiesi fire onerror if that failed?
 
   NS_TryToSetImmutable(imageURI);
 
-  return LoadImage(imageURI, aForce, aNotify, aImageLoadType, false, doc);
+  return LoadImage(imageURI, aForce, aNotify, aImageLoadType, false, doc,
+                   nsIRequest::LOAD_NORMAL, aTriggeringPrincipal);
 }
 
 nsresult
 nsImageLoadingContent::LoadImage(nsIURI* aNewURI,
                                  bool aForce,
                                  bool aNotify,
                                  ImageLoadType aImageLoadType,
                                  bool aLoadStart,
                                  nsIDocument* aDocument,
-                                 nsLoadFlags aLoadFlags)
+                                 nsLoadFlags aLoadFlags,
+                                 nsIPrincipal* aTriggeringPrincipal)
 {
   MOZ_ASSERT(!mIsStartingImageLoad, "some evil code is reentering LoadImage.");
   if (mIsStartingImageLoad) {
     return NS_OK;
   }
 
   // Pending load/error events need to be canceled in some situations. This
   // is not documented in the spec, but can cause site compat problems if not
@@ -1036,17 +1039,17 @@ nsImageLoadingContent::LoadImage(nsIURI*
   }
 
   RefPtr<imgRequestProxy>& req = PrepareNextRequest(aImageLoadType);
   nsCOMPtr<nsIContent> content =
       do_QueryInterface(static_cast<nsIImageLoadingContent*>(this));
 
   nsCOMPtr<nsIPrincipal> triggeringPrincipal;
   bool result =
-    nsContentUtils::GetLoadingPrincipalForXULNode(content,
+    nsContentUtils::GetLoadingPrincipalForXULNode(content, aTriggeringPrincipal,
                                                   getter_AddRefs(triggeringPrincipal));
 
   // If result is true, which means this node has specified 'loadingprincipal'
   // attribute on it, so we use favicon as the policy type.
   nsContentPolicyType policyType = result ?
                                      nsIContentPolicy::TYPE_INTERNAL_IMAGE_FAVICON:
                                      PolicyTypeForLoad(aImageLoadType);
 
--- a/dom/base/nsImageLoadingContent.h
+++ b/dom/base/nsImageLoadingContent.h
@@ -98,19 +98,22 @@ protected:
    * into this superclass.
    *
    * @param aNewURI the URI spec to be loaded (may be a relative URI)
    * @param aForce If true, make sure to load the URI.  If false, only
    *        load if the URI is different from the currently loaded URI.
    * @param aNotify If true, nsIDocumentObserver state change notifications
    *                will be sent as needed.
    * @param aImageLoadType The ImageLoadType for this request
+   * @param aTriggeringPrincipal Optional parameter specifying the triggering
+   *        principal to use for the image load
    */
   nsresult LoadImage(const nsAString& aNewURI, bool aForce,
-                     bool aNotify, ImageLoadType aImageLoadType);
+                     bool aNotify, ImageLoadType aImageLoadType,
+                     nsIPrincipal* aTriggeringPrincipal = nullptr);
 
   /**
    * ImageState is called by subclasses that are computing their content state.
    * The return value will have the NS_EVENT_STATE_BROKEN,
    * NS_EVENT_STATE_USERDISABLED, and NS_EVENT_STATE_SUPPRESSED bits set as
    * needed.  Note that this state assumes that this node is "trying" to be an
    * image (so for example complete lack of attempt to load an image will lead
    * to NS_EVENT_STATE_BROKEN being set).  Subclasses that are not "trying" to
@@ -130,21 +133,33 @@ protected:
    * @param aNotify If true, nsIDocumentObserver state change notifications
    *                will be sent as needed.
    * @param aImageLoadType The ImageLoadType for this request
    * @param aLoadStart If true, dispatch "loadstart" event.
    * @param aDocument Optional parameter giving the document this node is in.
    *        This is purely a performance optimization.
    * @param aLoadFlags Optional parameter specifying load flags to use for
    *        the image load
+   * @param aTriggeringPrincipal Optional parameter specifying the triggering
+   *        principal to use for the image load
    */
   nsresult LoadImage(nsIURI* aNewURI, bool aForce, bool aNotify,
                      ImageLoadType aImageLoadType, bool aLoadStart = true,
                      nsIDocument* aDocument = nullptr,
-                     nsLoadFlags aLoadFlags = nsIRequest::LOAD_NORMAL);
+                     nsLoadFlags aLoadFlags = nsIRequest::LOAD_NORMAL,
+                     nsIPrincipal* aTriggeringPrincipal = nullptr);
+
+  nsresult LoadImage(nsIURI* aNewURI, bool aForce, bool aNotify,
+                     ImageLoadType aImageLoadType,
+                     nsIPrincipal* aTriggeringPrincipal)
+  {
+    return LoadImage(aNewURI, aForce, aNotify, aImageLoadType,
+                     true, nullptr, nsIRequest::LOAD_NORMAL,
+                     aTriggeringPrincipal);
+  }
 
   /**
    * helpers to get the document for this content (from the nodeinfo
    * and such).  Not named GetOwnerDoc/GetCurrentDoc to prevent ambiguous
    * method names in subclasses
    *
    * @return the document we belong to
    */
--- a/dom/html/HTMLImageElement.cpp
+++ b/dom/html/HTMLImageElement.cpp
@@ -413,16 +413,19 @@ HTMLImageElement::AfterMaybeChangeAttr(i
   // Both cases handle unsetting src in AfterSetAttr
   if (aNamespaceID == kNameSpaceID_None &&
       aName == nsGkAtoms::src) {
 
     // Mark channel as urgent-start before load image if the image load is
     // initaiated by a user interaction.
     mUseUrgentStartForChannel = EventStateManager::IsHandlingUserInput();
 
+    mSrcTriggeringPrincipal = nsContentUtils::GetAttrTriggeringPrincipal(
+        this, aValue.String(), aSubjectPrincipal);
+
     if (InResponsiveMode()) {
       if (mResponsiveSelector &&
           mResponsiveSelector->Content() == this) {
         mResponsiveSelector->SetDefaultSource(aValue.String());
       }
       QueueImageLoadTask(true);
     } else if (aNotify && OwnerDoc()->IsCurrentActiveDocument()) {
       // If aNotify is false, we are coming from the parser or some such place;
@@ -438,17 +441,18 @@ HTMLImageElement::AfterMaybeChangeAttr(i
       // network if it's set to be not cacheable.
       // Potentially, false could be passed here rather than aNotify since
       // UpdateState will be called by SetAttrAndNotify, but there are two
       // obstacles to this: 1) LoadImage will end up calling
       // UpdateState(aNotify), and we do not want it to call UpdateState(false)
       // when aNotify is true, and 2) When this function is called by
       // OnAttrSetButNotChanged, SetAttrAndNotify will not subsequently call
       // UpdateState.
-      LoadImage(aValue.String(), true, aNotify, eImageLoadType_Normal);
+      LoadImage(aValue.String(), true, aNotify, eImageLoadType_Normal,
+                mSrcTriggeringPrincipal);
 
       mNewRequestsWillNeedAnimationReset = false;
     }
   } else if (aNamespaceID == kNameSpaceID_None &&
              aName == nsGkAtoms::crossorigin &&
              aNotify) {
     if (aValueMaybeChanged && GetCORSMode() != AttrValueToCORSMode(aOldValue)) {
       // Force a new load of the image with the new cross origin policy.
@@ -997,17 +1001,18 @@ HTMLImageElement::LoadSelectedImage(bool
         }
       }
 
       // If we have a srcset attribute or are in a <picture> element,
       // we always use the Imageset load type, even if we parsed no
       // valid responsive sources from either, per spec.
       rv = LoadImage(src, aForce, aNotify,
                      HaveSrcsetOrInPicture() ? eImageLoadType_Imageset
-                                             : eImageLoadType_Normal);
+                                             : eImageLoadType_Normal,
+                     mSrcTriggeringPrincipal);
     }
   }
   mLastSelectedSource = selectedSource;
   mCurrentDensity = currentDensity;
 
   if (NS_FAILED(rv)) {
     CancelImageRequests(aNotify);
   }
--- a/dom/html/HTMLImageElement.h
+++ b/dom/html/HTMLImageElement.h
@@ -137,23 +137,23 @@ public:
   void GetAlt(nsAString& aAlt)
   {
     GetHTMLAttr(nsGkAtoms::alt, aAlt);
   }
   void SetAlt(const nsAString& aAlt, ErrorResult& aError)
   {
     SetHTMLAttr(nsGkAtoms::alt, aAlt, aError);
   }
-  void GetSrc(nsAString& aSrc)
+  void GetSrc(nsAString& aSrc, nsIPrincipal&)
   {
     GetURIAttr(nsGkAtoms::src, nullptr, aSrc);
   }
-  void SetSrc(const nsAString& aSrc, ErrorResult& aError)
+  void SetSrc(const nsAString& aSrc, nsIPrincipal& aSubjectPrincipal, ErrorResult& aError)
   {
-    SetHTMLAttr(nsGkAtoms::src, aSrc, aError);
+    SetHTMLAttr(nsGkAtoms::src, aSrc, aSubjectPrincipal, aError);
   }
   void GetSrcset(nsAString& aSrcset)
   {
     GetHTMLAttr(nsGkAtoms::srcset, aSrcset);
   }
   void SetSrcset(const nsAString& aSrcset, ErrorResult& aError)
   {
     SetHTMLAttr(nsGkAtoms::srcset, aSrcset, aError);
@@ -421,16 +421,17 @@ private:
   void AfterMaybeChangeAttr(int32_t aNamespaceID, nsIAtom* aName,
                             const nsAttrValueOrString& aValue,
                             const nsAttrValue* aOldValue,
                             nsIPrincipal* aSubjectPrincipal,
                             bool aValueMaybeChanged, bool aNotify);
 
   bool mInDocResponsiveContent;
   RefPtr<ImageLoadTask> mPendingImageLoadTask;
+  nsCOMPtr<nsIPrincipal> mSrcTriggeringPrincipal;
 
   // Last URL that was attempted to load by this element.
   nsCOMPtr<nsIURI> mLastSelectedSource;
   // Last pixel density that was selected.
   double mCurrentDensity;
 };
 
 } // namespace dom
--- a/dom/webidl/HTMLImageElement.webidl
+++ b/dom/webidl/HTMLImageElement.webidl
@@ -16,17 +16,17 @@ interface imgIRequest;
 interface URI;
 interface nsIStreamListener;
 
 [HTMLConstructor,
  NamedConstructor=Image(optional unsigned long width, optional unsigned long height)]
 interface HTMLImageElement : HTMLElement {
            [CEReactions, SetterThrows]
            attribute DOMString alt;
-           [CEReactions, SetterThrows]
+           [CEReactions, NeedsSubjectPrincipal, SetterThrows]
            attribute DOMString src;
            [CEReactions, SetterThrows]
            attribute DOMString srcset;
            [CEReactions, SetterThrows]
            attribute DOMString? crossOrigin;
            [CEReactions, SetterThrows]
            attribute DOMString useMap;
            [CEReactions, SetterThrows]
--- a/image/test/browser/browser_docshell_type_editor.js
+++ b/image/test/browser/browser_docshell_type_editor.js
@@ -104,15 +104,17 @@ add_task(async function() {
         }
         image.onerror = function() {
           ok(true, "APP_TYPE_UNKNOWN is *not* allowed to acces privileged image");
           // restore appType of rootDocShell before moving on to the next test
           rootDocShell.appType = defaultAppType;
           resolve();
         }
         doc.body.appendChild(image);
-        image.src = "chrome://test1/skin/privileged.png";
+        // Set the src via wrappedJSObject so the load is triggered with
+        // the content page's principal rather than ours.
+        image.wrappedJSObject.src = "chrome://test1/skin/privileged.png";
       });
     });
   });
 
   Components.manager.removeBootstrappedManifestLocation(manifestDir);
 });
--- a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html
+++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html
@@ -22,17 +22,19 @@ SimpleTest.registerCleanupFunction(() =>
 let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
                  "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=");
 const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer;
 
 async function testImageLoading(src, expectedAction) {
   let imageLoadingPromise = new Promise((resolve, reject) => {
     let cleanupListeners;
     let testImage = document.createElement("img");
-    testImage.setAttribute("src", src);
+    // Set the src via wrappedJSObject so the load is triggered with the
+    // content page's principal rather than ours.
+    testImage.wrappedJSObject.setAttribute("src", src);
 
     let loadListener = () => {
       cleanupListeners();
       resolve(expectedAction === "loaded");
     };
 
     let errorListener = () => {
       cleanupListeners();
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
@@ -0,0 +1,512 @@
+"use strict";
+
+/**
+ * Tests that various types of inline content elements initiate requests
+ * with the triggering pringipal of the caller that requested the load.
+ */
+
+const {escaped} = Cu.import("resource://testing-common/AddonTestUtils.jsm", {});
+
+Cu.importGlobalProperties(["URL"]);
+
+// ExtensionContent.jsm needs to know when it's running from xpcshell,
+// to use the right timeout for content scripts executed at document_idle.
+ExtensionTestUtils.mockAppInfo();
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+/**
+ * Registers a static HTML document with the given content at the given
+ * path in our test HTTP server.
+ *
+ * @param {string} path
+ * @param {string} content
+ */
+function registerStaticPage(path, content) {
+  server.registerPathHandler(path, (request, response) => {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.setHeader("Content-Type", "text/html");
+    response.write(content);
+  });
+}
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+
+/**
+ * A set of tags which are automatically closed in HTML documents, and
+ * do not require an explicit closing tag.
+ */
+const AUTOCLOSE_TAGS = new Set(["img"]);
+
+/**
+ * An object describing the elements to create for a specific test.
+ *
+ * @typedef {object} ElementTestCase
+ * @property {Array} element
+ *        A recursive array, describing the element to create, in the
+ *        following format:
+ *
+ *          ["tagname", {attr: "attrValue"},
+ *            ["child-tagname", {attr: "value"}],
+ *            ...]
+ *
+ *        For each test, a DOM tree will be created with this structure.
+ *        A source attribute, with the name `test.srcAttr` and a value
+ *        based on the values of `test.src` and `opts`, will be added to
+ *        the first leaf node encountered.
+ * @property {string} src
+ *        The relative URL to use as the source of the element. Each
+ *        load of this URL will have a separate set of query parameters
+ *        appended to it, based on the values in `opts`.
+ * @property {string} [srcAttr = "src"]
+ *        The attribute in which to store the element's source URL.
+ * @property {string} [srcAttr = "src"]
+ *        The attribute in which to store the element's source URL.
+ * @property {boolean} [liveSrc = false]
+ *        If true, changing the source attribute after the element has
+ *        been inserted into the document is expected to trigger a new
+ *        load, and that configuration will be tested.
+ */
+
+/**
+ * Options for this specific configuration of an element test.
+ *
+ * @typedef {object} ElementTestOptions
+ * @property {string} origin
+ *        The origin with which the content is expected to load. This
+ *        may be either "page" or "extension". The actual load of the
+ *        URL will be tested against the computed origin strings for
+ *        those two contexts.
+ * @property {string} source
+ *        An arbitrary string which uniquely identifies the source of
+ *        the load. For instance, each of these should have separate
+ *        origin strings:
+ *
+ *         - An element present in the initial page HTML.
+ *         - An element injected by a page script belonging to web
+ *           content.
+ *         - An element injected by an extension content script.
+ */
+
+/**
+ * Data describing a test element, which can be used to create a
+ * corresponding DOM tree.
+ *
+ * @typedef {object} ElementData
+ * @property {string} tagName
+ *        The tag name for the element.
+ * @property {object} attrs
+ *        A property containing key-value pairs for each of the
+ *        attribute's elements.
+ * @property {Array<ElementData>} children
+ *        A possibly empty array of element data for child elements.
+ */
+
+/**
+ * Returns data necessary to create test elements for the given test,
+ * with the given options.
+ *
+ * @param {ElementTestCase} test
+ *        An object describing the elements to create for a specific
+ *        test. This element will be created under various
+ *        circumstances, as described by `opts`.
+ * @param {ElementTestOptions} opts
+ *        Options for this specific configuration of the test.
+ * @returns {ElementData}
+ */
+function getElementData(test, opts) {
+  let baseURL = typeof BASE_URL !== "undefined" ? BASE_URL : location.href;
+
+  let {srcAttr, src} = test;
+
+  // Absolutify the URL, so it passes sanity checks that ignore
+  // triggering principals for relative URLs.
+  src = new URL(src + `?origin=${encodeURIComponent(opts.origin)}&source=${encodeURIComponent(opts.source)}`,
+                baseURL).href;
+
+  let haveSrc = false;
+  function rec(element) {
+    let [tagName, attrs, ...children] = element;
+
+    if (children.length) {
+      children = children.map(rec);
+    } else if (!haveSrc) {
+      attrs = Object.assign({[srcAttr]: src}, attrs);
+      haveSrc = true;
+    }
+
+    return {tagName, attrs, children};
+  }
+  return rec(test.element);
+}
+
+/**
+ * The result type of the {@see createElement} function.
+ *
+ * @typedef {object} CreateElementResult
+ * @property {Element} elem
+ *        The root element of the created DOM tree.
+ * @property {Element} srcElem
+ *        The element in the tree to which the source attribute must be
+ *        added.
+ * @property {string} src
+ *        The value of the source element.
+ */
+
+/**
+ * Creates a DOM tree for a given test, in a given configuration, as
+ * understood by {@see getElementData}, but without the `test.srcAttr`
+ * attribute having been set. The caller must set the value of that
+ * attribute to the returned `src` value.
+ *
+ * There are many different ways most source values can be set
+ * (DOM attribute, DOM property, ...) and many different contexts
+ * (content script verses page script). Each test should be run with as
+ * many variants of these as possible.
+ *
+ * @param {ElementTestCase} test
+ *        A test object, as passed to {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ *        An options object, as passed to {@see getElementData}.
+ * @returns {CreateElementResult}
+ */
+function createElement(test, opts) {
+  let srcElem;
+  let src;
+
+  function rec({tagName, attrs, children}) {
+    let elem = document.createElement(tagName);
+
+    for (let [key, val] of Object.entries(attrs)) {
+      if (key === test.srcAttr) {
+        srcElem = elem;
+        src = val;
+      } else {
+        elem.setAttribute(key, val);
+      }
+    }
+    for (let child of children) {
+      elem.appendChild(rec(child));
+    }
+    return elem;
+  }
+  let elem = rec(getElementData(test, opts));
+
+  return {elem, srcElem, src};
+}
+
+/**
+ * Converts the given test data, as accepted by {@see getElementData},
+ * to an HTML representation.
+ *
+ * @param {ElementTestCase} test
+ *        A test object, as passed to {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ *        An options object, as passed to {@see getElementData}.
+ * @returns {string}
+ */
+function toHTML(test, opts) {
+  function rec({tagName, attrs, children}) {
+    let html = [`<${tagName}`];
+    for (let [key, val] of Object.entries(attrs)) {
+      html.push(escaped` ${key}="${val}"`);
+    }
+
+    html.push(">");
+    if (!AUTOCLOSE_TAGS.has(tagName)) {
+      for (let child of children) {
+        html.push(rec(child));
+      }
+
+      html.push(`</${tagName}>`);
+    }
+    return html.join("");
+  }
+  return rec(getElementData(test, opts));
+}
+
+/**
+ * A function which will be stringified, and run both as a page script
+ * and an extension content script, to test element injection under
+ * various configurations.
+ *
+ * @param {Array<ElementTestCase>} tests
+ *        A list of test objects, as understood by {@see getElementData}.
+ * @param {ElementTestOptions} baseOpts
+ *        A base options object, as understood by {@see getElementData},
+ *        which represents the default values for injections under this
+ *        context.
+ */
+function injectElements(tests, baseOpts) {
+  window.addEventListener("load", () => {
+    let overrideOpts = opts => Object.assign({}, baseOpts, opts);
+    let opts = baseOpts;
+
+    // Build the full element with setAttr, then inject.
+    for (let test of tests) {
+      let {elem, srcElem, src} = createElement(test, opts);
+      srcElem.setAttribute(test.srcAttr, src);
+      document.body.appendChild(elem);
+    }
+
+    // Build the full element with a property setter.
+    opts = overrideOpts({source: `${baseOpts.source}-prop`});
+    for (let test of tests) {
+      let {elem, srcElem, src} = createElement(test, opts);
+      srcElem[test.srcAttr] = src;
+      document.body.appendChild(elem);
+    }
+
+    // Build the element without the source attribute, inject, then set
+    // it.
+    opts = overrideOpts({source: `${baseOpts.source}-attr-after-inject`});
+    for (let test of tests) {
+      let {elem, srcElem, src} = createElement(test, opts);
+      document.body.appendChild(elem);
+      srcElem.setAttribute(test.srcAttr, src);
+    }
+
+    // Build the element without the source attribute, inject, then set
+    // the corresponding property.
+    opts = overrideOpts({source: `${baseOpts.source}-prop-after-inject`});
+    for (let test of tests) {
+      let {elem, srcElem, src} = createElement(test, opts);
+      document.body.appendChild(elem);
+      srcElem[test.srcAttr] = src;
+    }
+
+    // Build the element with a relative, rather than absolute, URL, and
+    // make sure it always has the page origin.
+    opts = overrideOpts({source: `${baseOpts.source}-relative-url`,
+                         origin: "page"});
+    for (let test of tests) {
+      let {elem, srcElem, src} = createElement(test, opts);
+      // Note: This assumes that the content page and the src URL are
+      // always at the server root. If that changes, the test will
+      // timeout waiting for matching requests.
+      src = src.replace(/.*\//, "");
+      srcElem.setAttribute(test.srcAttr, src);
+      document.body.appendChild(elem);
+    }
+
+    // If we're in an extension content script, do some additional checks.
+    if (typeof browser !== "undefined") {
+      // Build the element without the source attribute, inject, then
+      // have content set it.
+      opts = overrideOpts({source: `${baseOpts.source}-content-attr-after-inject`,
+                           origin: "page"});
+
+      for (let test of tests) {
+        let {elem, srcElem, src} = createElement(test, opts);
+        document.body.appendChild(elem);
+        window.wrappedJSObject.elem = srcElem;
+        window.wrappedJSObject.eval(`elem.setAttribute(${uneval(test.srcAttr)}, ${uneval(src)})`);
+      }
+
+      // Build the full element, then let content inject.
+      opts = overrideOpts({source: `${baseOpts.source}-content-inject-after-attr`});
+      for (let test of tests) {
+        let {elem, srcElem, src} = createElement(test, opts);
+        srcElem.setAttribute(test.srcAttr, src);
+        window.wrappedJSObject.elem = elem;
+        window.wrappedJSObject.eval(`document.body.appendChild(elem)`);
+      }
+
+      // Build the element without the source attribute, let content set
+      // it, then inject.
+      opts = overrideOpts({source: `${baseOpts.source}-inject-after-content-attr`,
+                           origin: "page"});
+
+      for (let test of tests) {
+        let {elem, srcElem, src} = createElement(test, opts);
+        window.wrappedJSObject.elem = srcElem;
+        window.wrappedJSObject.eval(`elem.setAttribute(${uneval(test.srcAttr)}, ${uneval(src)})`);
+        document.body.appendChild(elem);
+      }
+
+      // Build the element with a dummy source attribute, inject, then
+      // let content change it.
+      opts = overrideOpts({source: `${baseOpts.source}-content-change-after-inject`,
+                           origin: "page"});
+
+      for (let test of tests) {
+        let {elem, srcElem, src} = createElement(test, opts);
+        srcElem.setAttribute(test.srcAttr, "meh.txt");
+        document.body.appendChild(elem);
+        window.wrappedJSObject.elem = srcElem;
+        window.wrappedJSObject.eval(`elem.setAttribute(${uneval(test.srcAttr)}, ${uneval(src)})`);
+      }
+    }
+  }, {once: true});
+}
+
+/**
+ * Stringifies the {@see injectElements} function for use as a page or
+ * content script.
+ *
+ * @param {Array<ElementTestCase>} tests
+ *        A list of test objects, as understood by {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ *        A base options object, as understood by {@see getElementData},
+ *        which represents the default values for injections under this
+ *        context.
+ * @returns {string}
+ */
+function getInjectionScript(tests, opts) {
+  return `
+    ${getElementData}
+    ${createElement}
+    (${injectElements})(${JSON.stringify(tests)},
+                        ${JSON.stringify(opts)});
+  `;
+}
+
+/**
+ * Awaits the content loads for each of the given tests, with each of
+ * the given sources, and checks that their origin strings are as
+ * expected.
+ *
+ * @param {Array<ElementTestCase>} tests
+ *        A list of tests, as understood by {@see getElementData}.
+ * @param {Object<string, object>} sources
+ *        A set of sources for which each of the above tests is expected
+ *        to generate one request, if each of the properties in the
+ *        value object matches the value of the same property in the
+ *        test object.
+ * @param {object<string, string>} origins
+ *        A mapping of origin parameters as they appear in URL query
+ *        strings to the origin strings returned by corresponding
+ *        principals. These values are used to test requests against
+ *        their expected origins.
+ * @returns {Promise}
+ *        A promise which resolves when all requests have been
+ *        processed.
+ */
+function awaitLoads(tests, sources, origins) {
+  let expectedURLs = new Set();
+
+  for (let test of tests) {
+    for (let [source, attrs] of Object.entries(sources)) {
+      if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) {
+        let urlPrefix = `${BASE_URL}/${test.src}?source=${source}`;
+        expectedURLs.add(urlPrefix);
+      }
+    }
+  }
+
+  return new Promise(resolve => {
+    let observer = (channel, topic, data) => {
+      channel.QueryInterface(Ci.nsIChannel);
+
+      let url = new URL(channel.URI.spec);
+      let origin = url.searchParams.get("origin");
+      url.searchParams.delete("origin");
+
+      if (expectedURLs.has(url.href)) {
+        expectedURLs.delete(url.href);
+
+        equal(channel.loadInfo.triggeringPrincipal.origin,
+              origins[origin],
+              `Got expected origin for URL ${channel.URI.spec}`);
+
+        if (!expectedURLs.size) {
+          Services.obs.removeObserver(observer, "http-on-modify-request");
+          resolve();
+        }
+      }
+    };
+    Services.obs.addObserver(observer, "http-on-modify-request");
+  });
+}
+
+add_task(async function test_contentscript_triggeringPrincipals() {
+  /**
+   * A list of tests to run in each context, as understood by
+   * {@see getElementData}.
+   */
+  const TESTS = [
+    {
+      element: ["img", {}],
+      src: "img.png",
+    },
+  ];
+
+  /**
+   * A set of sources for which each of the above tests is expected to
+   * generate one request, if each of the properties in the value object
+   * matches the value of the same property in the test object.
+   */
+  const SOURCES = {
+    "contentScript": {},
+    "contentScript-attr-after-inject": {liveSrc: true},
+    "contentScript-content-attr-after-inject": {liveSrc: true},
+    "contentScript-content-change-after-inject": {liveSrc: true},
+    "contentScript-content-inject-after-attr": {},
+    "contentScript-inject-after-content-attr": {},
+    "contentScript-prop": {},
+    "contentScript-prop-after-inject": {},
+    "pageHTML": {},
+    "pageScript": {},
+    "pageScript-attr-after-inject": {},
+    "pageScript-prop": {},
+    "pageScript-prop-after-inject": {},
+  };
+
+  for (let test of TESTS) {
+    if (!test.srcAttr) {
+      test.srcAttr = "src";
+    }
+    if (!("liveSrc" in test)) {
+      test.liveSrc = true;
+    }
+  }
+
+
+  registerStaticPage("/page.html", `<!DOCTYPE html>
+    <html lang="en">
+    <head>
+      <meta charset="UTF-8">
+      <title></title>
+      <script>
+        ${getInjectionScript(TESTS, {source: "pageScript", origin: "page"})}
+      </script>
+    </head>
+    <body>
+      ${TESTS.map(test => toHTML(test, {source: "pageHTML", origin: "page"})).join("\n  ")}
+    </body>
+    </html>`);
+
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      content_scripts: [{
+        "matches": ["http://*/page.html"],
+        "run_at": "document_start",
+        "js": ["content_script.js"],
+      }],
+    },
+
+    files: {
+      "content_script.js": getInjectionScript(TESTS, {source: "contentScript", origin: "extension"}),
+    },
+  });
+
+  await extension.startup();
+
+  const pageURL = `${BASE_URL}/page.html`;
+  const pageURI = Services.io.newURI(pageURL);
+
+  let origins = {
+    page: Services.scriptSecurityManager.createCodebasePrincipal(pageURI, {}).origin,
+    extension: Cu.getObjectPrincipal(Cu.Sandbox([extension.extension.principal, pageURL])).origin,
+  };
+  let finished = awaitLoads(TESTS, SOURCES, origins);
+
+  let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+  await finished;
+
+  await extension.unload();
+  await contentPage.close();
+});
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
@@ -1,6 +1,7 @@
 [test_ext_i18n.js]
 skip-if = os == "android" || (os == "win" && debug)
 [test_ext_i18n_css.js]
 [test_ext_contentscript.js]
 [test_ext_contentscript_scriptCreated.js]
+[test_ext_contentscript_triggeringPrincipal.js]
 [test_ext_contentscript_xrays.js]