Implement policy: 'document-stream-insertion'

This CL adds the actual implementation for the experimental policy
'document-stream-insertion'. The policy is used to block usages of
specific APIs mentioned section "dynamic markup insertion" of the HTML
spec. This essentially includes document.{close, open, write, writeln}.

With the current CL, the calls to banned API lead to a DOMException.
The feature itself was introduced in a previous CL:
https://chromium-review.googlesource.com/c/chromium/src/+/1053349

Bug: 841605
Change-Id: I1a764bc7545a0d26a29d217027cf43e561d8dfbd
Reviewed-on: https://chromium-review.googlesource.com/1058138
Commit-Queue: Ehsan Karamad <ekaramad@chromium.org>
Reviewed-by: Ian Clelland <iclelland@chromium.org>
Reviewed-by: Kent Tamura <tkent@chromium.org>
Cr-Commit-Position: refs/heads/master@{#561275}
diff --git a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/document-stream-insertion.tentative.html b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/document-stream-insertion.tentative.html
new file mode 100644
index 0000000..12b844c
--- /dev/null
+++ b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/document-stream-insertion.tentative.html
@@ -0,0 +1,82 @@
+<!doctype html>
+<title>'document-stream-insertion' tests</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/feature-policy/experimental-features/resources/common.js"></script>
+<style>
+html, body {
+  height: 100%;
+  width: 100%;
+}
+</style>
+<iframe></iframe>
+<script>
+  "use strict";
+
+  let iframeElement = document.querySelector("iframe");
+  let url = url_base + "document-stream-insertion.html";
+
+  let text_to_write = "<div>FOO<\/div>";
+  let test_cases = [{
+                      api: "open",
+                      query: "body",
+                      expected_value_enabled: false,
+                    },
+                    {
+                      api: "close"
+                    },
+                    {
+                      api: "write",
+                      args: text_to_write,
+                      query: "div",
+                      expected_value_enabled: "FOO"
+                    },
+                    {
+                      api: "writeln",
+                      args: text_to_write,
+                      query: "div",
+                      expected_value_enabled: "FOO"
+                    }];
+
+  // The feature 'document-stream-insertion' is enabled by default and when it
+  // is enabled, all dynamic markup insertion API work as intended.
+  test_cases.forEach((tc) => {
+    promise_test(async() => {
+      await loadUrlInIframe(iframeElement, url);
+      await sendMessageAndGetResponse(iframeElement.contentWindow, tc).then((response) => {
+        assert_false(
+          response.did_throw_exception,
+          `When feature is disabled, invoking 'document.${tc.api}' should not` +
+          " throw an exception.");
+        if (tc.query) {
+          assert_equals(
+            response.value,
+            tc.expected_value_enabled,
+            `The added script tag by 'document.${tc.api}' must have run.`);
+        }
+      });
+    }, `Verify 'document.${tc.api}' is not normally blocked.` );
+  });
+
+
+  // Disabling 'document-stream-insertion' throws exception on the included API.
+  test_cases.forEach((tc) => {
+    promise_test(async() => {
+      setFeatureState(iframeElement, "document-stream-insertion", "'none'");
+      await loadUrlInIframe(iframeElement, url);
+      await sendMessageAndGetResponse(iframeElement.contentWindow, tc).then((response) => {
+        assert_true(
+          response.did_throw_exception,
+          `When feature is enabled, invoking 'document.${tc.api}' should ` +
+          " throw an exception.");
+        if (tc.query) {
+          assert_not_equals(
+            response.value,
+            tc.expected_value_enabled,
+            `The added script tag by 'document.${tc.api}' must not have run.`);
+        }
+      });
+    }, `Verify 'document.${tc.api}' is blocked when the feature is disabled.` );
+  });
+
+</script>
diff --git a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/common.js b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/common.js
new file mode 100644
index 0000000..08d3aef
--- /dev/null
+++ b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/common.js
@@ -0,0 +1,34 @@
+const url_base = "/feature-policy/experimental-features/resources/";
+window.messageResponseCallback = null;
+
+function setFeatureState(iframe, feature, origins) {
+    iframe.setAttribute("allow", `${feature} ${origins};`);
+}
+
+// Returns a promise which is resolved when the <iframe> is navigated to |url|
+// and "load" handler has been called.
+function loadUrlInIframe(iframe, url) {
+  return new Promise((resolve) => {
+    iframe.addEventListener("load", resolve);
+    iframe.src = url;
+  });
+}
+
+// Posts |message| to |target| and resolves the promise with the response coming
+// back from |target|.
+function sendMessageAndGetResponse(target, message) {
+  return new Promise((resolve) => {
+    window.messageResponseCallback = resolve;
+    target.postMessage(message, "*");
+  });
+}
+
+
+function onMessage(e) {
+  if (window.messageResponseCallback) {
+    window.messageResponseCallback(e.data);
+    window.messageResponseCallback = null;
+  }
+}
+
+window.addEventListener("message", onMessage);
diff --git a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/document-stream-insertion.html b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/document-stream-insertion.html
new file mode 100644
index 0000000..633fa85
--- /dev/null
+++ b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/document-stream-insertion.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<style>
+#spacer {
+  width: 200%;
+  height: 200%;
+}
+</style>
+<body>
+<script>
+  window.addEventListener("message", onMessageReceived);
+
+  function test(api, args) {
+    let did_throw = false;
+    try {
+     document[api](args);
+    } catch(e) {
+      did_throw = true;
+    }
+    return did_throw;
+  }
+
+  function onMessageReceived(e) {
+    let msg = e.data;
+
+    msg.did_throw_exception = test(msg.api, msg.args);
+    if (msg.query) {
+      let el = document.querySelector(msg.query);
+      msg.value = el ? el.innerHTML : false;
+    }
+    ackMessage(msg, e.source);
+  }
+
+  function ackMessage(msg, source) {
+    source.postMessage(msg, "*");
+  }
+</script>
+</body>
diff --git a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/vertical-scroll.js b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/vertical-scroll.js
index bbae658..88835cc 100644
--- a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/vertical-scroll.js
+++ b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/resources/vertical-scroll.js
@@ -1,6 +1,3 @@
-const url_base = "/feature-policy/experimental-features/resources/";
-window.messageResponseCallback = null;
-
 function rectMaxY(rect) {
   return rect.height + rect.y;
 }
@@ -23,33 +20,6 @@
          rect2.y < rectMaxY(rect1);
 }
 
-// Returns a promise which is resolved when the <iframe> is navigated to |url|
-// and "load" handler has been called.
-function loadUrlInIframe(iframe, url) {
-  return new Promise((resolve) => {
-    iframe.addEventListener("load", resolve);
-    iframe.src = url;
-  });
-}
-
-// Posts |message| to |target| and resolves the promise with the response coming
-// back from |target|.
-function sendMessageAndGetResponse(target, message) {
-  return new Promise((resolve) => {
-    window.messageResponseCallback = resolve;
-    target.postMessage(message, "*");
-  });
-}
-
 function rectToString(rect) {
   return `Location: (${rect.x}, ${rect.y}) Size: (${rect.width}, ${rect.height})`;
 }
-
-function onMessage(e) {
-  if (window.messageResponseCallback) {
-    window.messageResponseCallback(e.data);
-    window.messageResponseCallback = null;
-  }
-}
-
-window.addEventListener("message", onMessage);
diff --git a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.html b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.html
index e9c7cba..689685a 100644
--- a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.html
+++ b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.html
@@ -1,6 +1,7 @@
 <!DOCTYPE html>
 <script src="/resources/testharness.js"></script>
 <script src="/resources/testharnessreport.js"></script>
+<script src="/feature-policy/experimental-features/resources/common.js"></script>
 <script src="/feature-policy/experimental-features/resources/vertical-scroll.js"></script>
 <style>
 html, body {
@@ -73,7 +74,7 @@
   // of disabled frames (does not propagate to main frame).
   promise_test(async() => {
     window.scrollTo(0, 0);
-    iframeElement.allow = "vertical-scroll 'none';";
+    setFeatureState(iframeElement, "vertical-scroll", "'none'");
     await loadUrlInIframe(iframeElement, url);
 
     await sendMessageAndGetResponse(
diff --git a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-touch-action-manual.tentative.html b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-touch-action-manual.tentative.html
index 761c0d7..ed6bff1 100644
--- a/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-touch-action-manual.tentative.html
+++ b/third_party/WebKit/LayoutTests/external/wpt/feature-policy/experimental-features/vertical-scroll-touch-action-manual.tentative.html
@@ -2,6 +2,7 @@
 <title>vertical-scroll test for touch-action</title>
 <script src="/resources/testharness.js"></script>
 <script src="/resources/testharnessreport.js"></script>
+<script src="/feature-policy/experimental-features/resources/common.js"></script>
 <script src="/feature-policy/experimental-features/resources/vertical-scroll.js"></script>
 <style>
 html, body {
@@ -81,7 +82,7 @@
     window.scrollTo(0, 0);
 
     // Disallow vertical scroll and reload the <iframe>.
-    iframeElement.setAttribute("allow", "vertical-scroll 'none';");
+    setFeatureState(iframeElement, "vertical-scroll", "'none'");
     await loadUrlInIframe(iframeElement, url);
 
     // Apply the scroll gesture. Main frame should scroll vertically.
diff --git a/third_party/blink/renderer/core/dom/document.cc b/third_party/blink/renderer/core/dom/document.cc
index 3ec6680..76cf6d8 100644
--- a/third_party/blink/renderer/core/dom/document.cc
+++ b/third_party/blink/renderer/core/dom/document.cc
@@ -2953,6 +2953,9 @@
     return;
   }
 
+  if (!AllowedToUseDynamicMarkUpInsertion("open", exception_state))
+    return;
+
   if (entered_document) {
     if (!GetSecurityOrigin()->IsSameSchemeHostPort(
             entered_document->GetSecurityOrigin())) {
@@ -3222,6 +3225,9 @@
     return;
   }
 
+  if (!AllowedToUseDynamicMarkUpInsertion("close", exception_state))
+    return;
+
   close();
 }
 
@@ -3717,6 +3723,9 @@
     return;
   }
 
+  if (!AllowedToUseDynamicMarkUpInsertion("write", exception_state))
+    return;
+
   StringBuilder builder;
   for (const String& string : text)
     builder.Append(string);
@@ -3735,6 +3744,9 @@
     return;
   }
 
+  if (!AllowedToUseDynamicMarkUpInsertion("writeln", exception_state))
+    return;
+
   StringBuilder builder;
   for (const String& string : text)
     builder.Append(string);
@@ -6021,6 +6033,32 @@
                           parent_feature_policy);
 }
 
+bool Document::AllowedToUseDynamicMarkUpInsertion(
+    const char* api_name,
+    ExceptionState& exception_state) {
+  if (!IsSupportedInFeaturePolicy(
+          mojom::FeaturePolicyFeature::kDocumentStreamInsertion)) {
+    return true;
+  }
+  if (!frame_ || frame_->IsFeatureEnabled(
+                     mojom::FeaturePolicyFeature::kDocumentStreamInsertion)) {
+    return true;
+  }
+
+  // TODO(ekaramad): Throwing an exception seems an ideal resolution to mishaps
+  // in using the API against the policy. But this cannot be applied to cross-
+  // origin as there are security risks involved. We should perhaps unload the
+  // whole frame instead of throwing.
+  exception_state.ThrowDOMException(
+      kNotAllowedError,
+      String::Format(
+          "The use of method '%s' has been blocked by feature policy. The "
+          "feature "
+          "'document-stream-insertion' is disabled in this document.",
+          api_name));
+  return false;
+}
+
 ukm::UkmRecorder* Document::UkmRecorder() {
   if (ukm_recorder_)
     return ukm_recorder_.get();
diff --git a/third_party/blink/renderer/core/dom/document.h b/third_party/blink/renderer/core/dom/document.h
index 53e2acf..13bc7ba 100644
--- a/third_party/blink/renderer/core/dom/document.h
+++ b/third_party/blink/renderer/core/dom/document.h
@@ -1547,6 +1547,11 @@
   // the LocalFrameClient.
   void ApplyFeaturePolicy(const ParsedFeaturePolicy& declared_policy);
 
+  // Returns true if use of |method_name| for markup insertion is allowed by
+  // feature policy; otherwise returns false and throws a DOM exception.
+  bool AllowedToUseDynamicMarkUpInsertion(const char* method_name,
+                                          ExceptionState&);
+
   void SetFreezingInProgress(bool is_freezing_in_progress) {
     is_freezing_in_progress_ = is_freezing_in_progress;
   };