'vertical-scroll' for programmatic scrolling

If 'vertical-scroll' is disabled for an <iframe>, then it should not be
able to affect the vertical scroll position. One way to block is to use
scripted scrolling by calling "element.scrollIntoView()".

To block such frames (whose feature's disabled), programmatic recursive
scroll calls are not forwarded to parent frames. This means if a given
<iframe> is blocked, then all the calls to scrollIntoView() are limited
to the scope of frame (i.e., elements becoming visible in the frame).
This applies to all the nested <iframe>'s of a disabled frame as well
since they would have the feature disabled as part of propagating the
container policy.

Link to explainer/design document for "vertical-scroll":
https://docs.google.com/document/d/1qiWelnMlsOHuT_CQ0Zm_qEAf54HS5DhoIvEDHBlfqps/edit?usp=sharing

Bug: 611982
Change-Id: I0e06b399ad890e263128b997cfbb04eb3bdd1494
Reviewed-on: https://chromium-review.googlesource.com/1014191
Reviewed-by: Ian Clelland <iclelland@chromium.org>
Reviewed-by: Ehsan Karamad <ekaramad@chromium.org>
Reviewed-by: David Bokan <bokan@chromium.org>
Commit-Queue: Ehsan Karamad <ekaramad@chromium.org>
Cr-Commit-Position: refs/heads/master@{#553561}
diff --git a/feature-policy/experimental-features/resources/vertical-scroll-scrollintoview.html b/feature-policy/experimental-features/resources/vertical-scroll-scrollintoview.html
new file mode 100644
index 0000000..7bed27c
--- /dev/null
+++ b/feature-policy/experimental-features/resources/vertical-scroll-scrollintoview.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<style>
+html, body, #container {
+  width: 100%;
+  height: 100%;
+}
+
+#spacer {
+  width: 200%;
+  height: 200%;
+}
+</style>
+<div id="container">
+  <div id="spacer"></div>
+  <button>Element To Scroll</button>
+</div>
+<script>
+  window.addEventListener('message', onMessageReceived);
+
+  function scrollingElementBounds() {
+    var rect = document.querySelector("button").getBoundingClientRect();
+    return {
+        x: rect.x, y: rect.y, width: rect.width, height: rect.height
+      };
+  }
+
+  function onMessageReceived(e) {
+    if (!e.data || !e.data.type)
+      return;
+    switch(e.data.type) {
+      case "scroll":
+        document.querySelector("button").scrollIntoView({behavior: "instant"});
+        ackMessage({bounds: scrollingElementBounds()}, e.source);
+      break;
+
+      case "scrolling-element-bounds":
+        ackMessage({bounds: scrollingElementBounds()}, e.source);
+      break;
+    }
+  }
+
+  function ackMessage(msg, source) {
+    source.postMessage(msg, "*");
+  }
+</script>
diff --git a/feature-policy/experimental-features/resources/vertical-scroll.js b/feature-policy/experimental-features/resources/vertical-scroll.js
new file mode 100644
index 0000000..bbae658
--- /dev/null
+++ b/feature-policy/experimental-features/resources/vertical-scroll.js
@@ -0,0 +1,55 @@
+const url_base = "/feature-policy/experimental-features/resources/";
+window.messageResponseCallback = null;
+
+function rectMaxY(rect) {
+  return rect.height + rect.y;
+}
+
+function rectMaxX(rect) {
+  return rect.width + rect.x;
+}
+
+function isEmptyRect(rect) {
+  return !rect.width || !rect.height;
+}
+
+// Returns true if the given rectangles intersect.
+function rects_intersect(rect1, rect2) {
+  if (isEmptyRect(rect1) || isEmptyRect(rect2))
+    return false;
+  return rect1.x < rectMaxX(rect2) &&
+         rect2.x < rectMaxX(rect1) &&
+         rect1.y < rectMaxY(rect2) &&
+         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/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.sub.html b/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.sub.html
new file mode 100644
index 0000000..e9c7cba
--- /dev/null
+++ b/feature-policy/experimental-features/vertical-scroll-scrollintoview.tentative.sub.html
@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/feature-policy/experimental-features/resources/vertical-scroll.js"></script>
+<style>
+html, body {
+  height: 100%;
+  width: 100%;
+}
+
+iframe {
+  width: 95%;
+  height: 95%;
+  overflow: scroll;
+  margin-top: 200%;
+}
+
+.spacer {
+  width: 100%;
+  height: 100%;
+  margin-top: 100%;
+  margin-bottom: 100%;
+}
+
+</style>
+<p> An &lt;iframe&gt; further below which is not allowed to block scroll.</p>
+<div class="spacer"></div>
+<iframe></iframe>
+<p> Making sure there is room for vertical scroll </p>
+<script>
+  "use strict";
+
+  let url = url_base + "vertical-scroll-scrollintoview.html";
+  let iframeElement = document.querySelector("iframe");
+
+  function iframeBounds() {
+    return iframeElement.getBoundingClientRect();
+  }
+
+  // Enabled 'vertical-scroll': scrollIntoView should work in all frames.
+  promise_test(async() => {
+    window.scrollTo(0, 0);
+    await loadUrlInIframe(iframeElement, url);
+
+    await sendMessageAndGetResponse(
+      iframeElement.contentWindow,
+      {type: "scrolling-element-bounds"}).then((response) => {
+        let iframeBoundsAtOrigin = {
+          x: 0,
+          y: 0,
+          width: iframeBounds().width,
+          height: iframeBounds().height};
+          let scrollingElementBounds = response.bounds;
+          assert_false(
+            rects_intersect(iframeBoundsAtOrigin, scrollingElementBounds),
+            "Scrolling element should not be visible in <iframe>." +
+            `Scrolling element's bounds is: ${rectToString(response.bounds)}  ` +
+            "but <iframe>'s size is:" +
+            `${iframeBounds().width}x${iframeBounds().height}.`);
+      });
+
+    // Scroll the scrolling element inside the <iframe>.
+    await sendMessageAndGetResponse(iframeElement.contentWindow,
+                                   {type: "scroll"});
+    // The page should have scrolled vertically.
+      assert_greater_than(window.scrollY,
+                          0,
+                          "Main frame must scroll vertically.");
+    }, "Calling 'scrollIntoView()' inside a <iframe> will propagate up by" +
+       " default('vertical-scroll' enabled).");
+
+  // Disabled 'vertical-scroll': The scope of scrollIntoView is within the set
+  // of disabled frames (does not propagate to main frame).
+  promise_test(async() => {
+    window.scrollTo(0, 0);
+    iframeElement.allow = "vertical-scroll 'none';";
+    await loadUrlInIframe(iframeElement, url);
+
+    await sendMessageAndGetResponse(
+      iframeElement.contentWindow,
+      {type: "scrolling-element-bounds"}).then((response) => {
+      let iframeBoundsAtOrigin = {
+        x: 0,
+        y: 0,
+        width: iframeBounds().width,
+        height: iframeBounds().height};
+      let scrollingElementBounds = response.bounds;
+      assert_false(rects_intersect(iframeBoundsAtOrigin, scrollingElementBounds),
+            "Scrolling element should not be visible in <iframe>." +
+            `Scrolling element's bounds is: ${rectToString(response.bounds)}` +
+            "but <iframe>'s size is:" +
+            `${iframeBounds().width}x${iframeBounds().height}.`);
+      });
+
+    // Scroll scrolling element inside the <iframe>.
+    await sendMessageAndGetResponse(iframeElement.contentWindow,
+      {type: "scroll"}).then((response) => {
+      // Make sure the nested <iframe> is visible.
+      let scrollingElementBounds = response.bounds;
+      let iframeBoundsAtOrigin = {
+          x: 0,
+          y: 0,
+          width: iframeBounds().width,
+          height: iframeBounds().height};
+      // The scrolling element should be visible inside <iframe>.
+      assert_true(rects_intersect(iframeBoundsAtOrigin, scrollingElementBounds),
+          "Scrolling element should be visible in <iframe>." +
+          `Scrolling element's bounds is: ${rectToString(response.bounds)}` +
+          "but <iframe>'s size is:" +
+          `${iframeBounds().width}, ${iframeBounds().height}.`);
+      // The page however should not have scrolled.
+      assert_equals(window.scrollY, 0, "Main frame must not scroll vertically.");
+    });
+    }, "Calling 'scrollIntoView()' inside a <iframe> with" +
+       " 'vertical-scroll none;'will not propagate upwards.");
+</script>