// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/content_browser_test.h"
#include "content/public/test/content_browser_test_utils.h"
#include "content/public/test/test_navigation_observer.h"
#include "content/shell/browser/shell.h"
#include "content/test/content_browser_test_utils_internal.h"
#include "net/dns/mock_host_resolver.h"
#include "net/test/embedded_test_server/embedded_test_server.h"

namespace content {

class FramebustingBrowserTest : public ContentBrowserTest {
 public:
  FramebustingBrowserTest() = default;

 protected:
  void SetUpOnMainThread() override {
    host_resolver()->AddRule("*", "127.0.0.1");
    ASSERT_TRUE(embedded_test_server()->Start());
  }

  WebContentsImpl* web_contents() const {
    return static_cast<WebContentsImpl*>(shell()->web_contents());
  }
};

// Verifies that cross-origin iframes cannot navigate the top frame to a
// different origin (sometimes called "framebusting") without user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest, FailsWithoutUserActivation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents(), "child",
      embedded_test_server()->GetURL("other.test", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  EXPECT_FALSE(
      ExecJs(child, "top.location = 'foo'", EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());
}

// Verifies that cross-origin iframes can navigate the top frame to a different
// origin (sometimes called "framebusting") with user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest, SucceedsWithUserActivation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  GURL other_url =
      embedded_test_server()->GetURL("other.test", "/defaultresponse");
  RenderFrameHost* child = CreateSubframe(web_contents(), "child", other_url,
                                          /*wait_for_navigation=*/true);

  TestNavigationObserver observer(web_contents());

  // By default `ExecJs()` executes the provided script with user activation.
  EXPECT_TRUE(ExecJs(child, "top.location = '/defaultresponse'"));

  // The top frame is indeed navigated successfully.
  observer.Wait();
  EXPECT_EQ(web_contents()->GetLastCommittedURL(), other_url);
}

// Verifies that cross-origin iframes can navigate the top frame to a different
// origin (sometimes called "framebusting") with user activation, even after
// a couple `setTimeout()` calls.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       SucceedsWithAsyncUserActivation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  GURL other_url =
      embedded_test_server()->GetURL("other.test", "/defaultresponse");
  RenderFrameHost* child = CreateSubframe(web_contents(), "child", other_url,
                                          /*wait_for_navigation=*/true);

  TestNavigationObserver observer(web_contents());

  // By default `ExecJs()` executes the provided script with a user activation.
  //
  // With user activation, the navigation should succeed even through nested
  // `setTimeout()` calls.
  EXPECT_TRUE(ExecJs(child, R"(
    setTimeout(() => {
      setTimeout(() => {
        top.location = '/defaultresponse';
      }, 0);
    }, 0);
  )"));

  // The top frame is indeed navigated successfully.
  observer.Wait();
  EXPECT_EQ(web_contents()->GetLastCommittedURL(), other_url);
}

// Verifies that cross-origin unsandboxed iframes cannot escalate the
// allow-top-navigation sandbox privilege in a child iframe, which would allow
// it to navigate the top frame to a different origin (sometimes called
// "framebusting") without user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       FailsFromGrandchildPrivilegeEscalationInSandboxFlags) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents()->GetPrimaryMainFrame(), "child",
      embedded_test_server()->GetURL("other.test", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  RenderFrameHost* grandchild = CreateSubframe(
      child, "grandchild",
      embedded_test_server()->GetURL("other.test", "/defaultresponse"),
      /*wait_for_navigation=*/true,
      {.sandbox_flags = "allow-scripts allow-top-navigation"});

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  EXPECT_FALSE(ExecJs(grandchild, "window.top.location = 'foo'",
                      EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());
}

// Verifies that a grandchild cross-origin unsandboxed iframe cannot give itself
// allow-top-navigation sandbox privileges via its delivered sandbox flags in
// the HTTP response header, which would allow it to navigate the top frame to a
// different origin (sometimes called "framebusting") without user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       FailsFromGrandchildPrivilegeEscalationInDeliveredFlags) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents()->GetPrimaryMainFrame(), "child",
      embedded_test_server()->GetURL("other.test", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  RenderFrameHost* grandchild =
      CreateSubframe(child, "grandchild",
                     embedded_test_server()->GetURL(
                         "other.test",
                         "/set-header?Content-Security-Policy: sandbox "
                         "allow-scripts allow-top-navigation"),
                     /*wait_for_navigation=*/true);

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  EXPECT_FALSE(ExecJs(grandchild, "window.top.location = 'foo'",
                      EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());
}

// Verifies that a child cross-origin unsandboxed iframe document cannot give
// itself allow-top-navigation sandbox privileges via its delivered sandbox
// flags in the HTTP response header, which would allow it to navigate the top
// frame to a different origin (sometimes called "framebusting") without user
// activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       FailsFromChildPrivilegeEscalationInDeliveredFlags) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child =
      CreateSubframe(web_contents()->GetPrimaryMainFrame(), "child",
                     embedded_test_server()->GetURL(
                         "other.test",
                         "/set-header?Content-Security-Policy: sandbox "
                         "allow-scripts allow-top-navigation"),
                     /*wait_for_navigation=*/true);

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  EXPECT_FALSE(ExecJs(child, "window.top.location = 'foo'",
                      EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());
}

// Verifies that a navigation to a cross-site document consumes sticky user
// activation, preventing the new document from navigating the top frame to a
// different origin (sometimes called "framebusting") without user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest, FailsAfterCrossSiteNavigation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents()->GetPrimaryMainFrame(), "child",
      embedded_test_server()->GetURL("foo.com", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  // Give the child iframe user activation.
  EXPECT_TRUE(ExecJs(child, ""));

  // Perform a cross-site navigation. This should clear the sticky user
  // activation state.
  GURL navigate_url =
      embedded_test_server()->GetURL("other.test", "/defaultresponse");
  EXPECT_TRUE(ExecJs(child, JsReplace("location.href = $1", navigate_url),
                     EXECUTE_SCRIPT_NO_USER_GESTURE));
  EXPECT_TRUE(content::WaitForLoadStop(web_contents()));
  child =
      web_contents()->GetPrimaryMainFrame()->child_at(0)->current_frame_host();
  EXPECT_EQ(child->GetLastCommittedURL(), navigate_url);

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  EXPECT_FALSE(ExecJs(child, "window.top.location = 'foo'",
                      EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());
}

// Verifies that a navigation to a same-site document maintains sticky user
// activation, allow the new document to navigate the top frame to a
// different origin (sometimes called "framebusting") without transient user
// activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       SucceedsAfterSameSiteNavigation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents()->GetPrimaryMainFrame(), "child",
      embedded_test_server()->GetURL("foo.com", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  // Give the child iframe user activation.
  EXPECT_TRUE(ExecJs(child, ""));

  // Perform a same-site but cross-origin navigation. This should keep the
  // sticky user activation state.
  GURL navigate_url =
      embedded_test_server()->GetURL("subdomain.foo.com", "/defaultresponse");
  EXPECT_TRUE(ExecJs(child, JsReplace("location.href = $1", navigate_url),
                     EXECUTE_SCRIPT_NO_USER_GESTURE));
  EXPECT_TRUE(content::WaitForLoadStop(web_contents()));
  child =
      web_contents()->GetPrimaryMainFrame()->child_at(0)->current_frame_host();
  EXPECT_EQ(child->GetLastCommittedURL(), navigate_url);

  EXPECT_TRUE(ExecJs(child, "window.top.location = 'foo'",
                     EXECUTE_SCRIPT_NO_USER_GESTURE));
}

// Verifies that a navigation to a same-site document without sticky user
// activation keeps the unset activation state, preventing the new document from
// navigating the top frame to a different origin (sometimes called
// "framebusting") without transient user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       FailsAfterSameSiteNavigationWithoutUserActivation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents()->GetPrimaryMainFrame(), "child",
      embedded_test_server()->GetURL("foo.com", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  // Perform a same-site but cross-origin navigation. There is no sticky user
  // activation state, so the newly navigated page should not have sticky user
  // activation either.
  GURL navigate_url =
      embedded_test_server()->GetURL("subdomain.foo.com", "/defaultresponse");
  EXPECT_TRUE(ExecJs(child, JsReplace("location.href = $1", navigate_url),
                     EXECUTE_SCRIPT_NO_USER_GESTURE));
  EXPECT_TRUE(content::WaitForLoadStop(web_contents()));
  child =
      web_contents()->GetPrimaryMainFrame()->child_at(0)->current_frame_host();
  EXPECT_EQ(child->GetLastCommittedURL(), navigate_url);

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  EXPECT_FALSE(ExecJs(child, "window.top.location = 'foo'",
                      EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());
}

// Verifies that cross-origin iframes sandboxed with
// "allow-top-navigation-by-user-activation" can only navigate the top frame to
// a different origin (sometimes called "framebusting") when they have user
// activation.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       AllowTopNavigationByUserActivation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents()->GetPrimaryMainFrame(), "child",
      embedded_test_server()->GetURL("other.test", "/defaultresponse"),
      /*wait_for_navigation=*/true,
      {.sandbox_flags =
           "allow-scripts allow-top-navigation-by-user-activation"});

  WebContentsConsoleObserver console_observer(web_contents());
  console_observer.SetPattern("*permission to navigate the target frame*");

  // The initial top-level navigation should fail without user activation.
  EXPECT_FALSE(ExecJs(child, "window.top.location = 'foo'",
                      EXECUTE_SCRIPT_NO_USER_GESTURE));

  ASSERT_TRUE(console_observer.Wait());

  // Once the frame has user activation, the top-level navigation should
  // succeed.
  EXPECT_TRUE(ExecJs(child, ""));
  EXPECT_TRUE(ExecJs(child, "window.top.location = 'foo'",
                     EXECUTE_SCRIPT_NO_USER_GESTURE));
}

// Verifies that cross-origin iframes can navigate the top frame to another URL
// belonging to the top frame's origin without user activation.
//
// This is non-standard, unspecified behavior.
// See also https://www.chromestatus.com/features/5851021045661696.
IN_PROC_BROWSER_TEST_F(FramebustingBrowserTest,
                       SucceedsInSameOriginWithoutUserActivation) {
  ASSERT_TRUE(NavigateToURL(
      shell(), embedded_test_server()->GetURL("/defaultresponse")));

  RenderFrameHost* child = CreateSubframe(
      web_contents(), "child",
      embedded_test_server()->GetURL("other.test", "/defaultresponse"),
      /*wait_for_navigation=*/true);

  TestNavigationObserver observer(web_contents());

  GURL destination = embedded_test_server()->GetURL("/echo");
  EXPECT_TRUE(ExecJs(child, JsReplace("top.location = $1", destination),
                     EXECUTE_SCRIPT_NO_USER_GESTURE));

  // The top frame is indeed navigated successfully.
  observer.Wait();
  EXPECT_EQ(web_contents()->GetLastCommittedURL(), destination);
}

}  // namespace content